Compare commits

..

14 Commits

Author SHA1 Message Date
Felipe Coutinho
bf4138db82 chore: preparar versão 2.7.13 2026-08-09 19:32:12 -03:00
Felipe Coutinho
ed9196797b chore: preparar versão 2.7.12 2026-06-30 16:53:44 -03:00
Felipe Coutinho
532186fe39 fix: corrigir seleção de faturas em popovers 2026-06-30 16:50:23 -03:00
Felipe Coutinho
a2ce7f1283 chore: preparar versão 2.7.11 2026-06-28 19:48:56 -03:00
Felipe Coutinho
f3c3d98aeb feat: agrupar lançamentos por data 2026-06-28 19:48:39 -03:00
Felipe Coutinho
24709ec232 style: compactar checkboxes 2026-06-28 19:48:29 -03:00
Felipe Coutinho
2fd94118f2 fix: corrigir seleção de data nos lançamentos 2026-06-28 19:48:25 -03:00
Felipe Coutinho
01f161f011 docs: documenta fluxo completo de publicacao 2026-06-27 18:24:16 -03:00
Felipe Coutinho
4741087feb chore: prepara versao 2.7.10 2026-06-27 14:24:47 -03:00
Felipe Coutinho
32b190ab4e feat(lancamentos): oculta parcelas antecipadas por preferencia 2026-06-27 14:24:47 -03:00
Felipe Coutinho
d06bac5624 chore(deps): atualiza dependencias 2026-06-27 14:24:47 -03:00
Felipe Coutinho
be6fa6dcfc chore: prepara versao 2.7.9 2026-06-21 12:20:12 -03:00
Felipe Coutinho
954fdc148e docs: versiona instrucoes dos agentes 2026-06-21 12:20:01 -03:00
Felipe Coutinho
fb1759c2ee ci: publica releases somente por tags semver 2026-06-21 12:19:53 -03:00
64 changed files with 12488 additions and 1778 deletions

57
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,57 @@
name: CI
on:
push:
branches:
- main
pull_request:
branches:
- main
workflow_dispatch:
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
quality:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v5
- name: Setup pnpm
uses: pnpm/action-setup@v4
- name: Setup Node.js
uses: actions/setup-node@v5
with:
node-version: 22
cache: pnpm
- name: Cache Next.js build
uses: actions/cache@v4
with:
path: ${{ github.workspace }}/.next/cache
key: ${{ runner.os }}-nextjs-${{ hashFiles('pnpm-lock.yaml') }}-${{ hashFiles('src/**/*.ts', 'src/**/*.tsx', 'src/**/*.css', 'next.config.ts') }}
restore-keys: |
${{ runner.os }}-nextjs-${{ hashFiles('pnpm-lock.yaml') }}-
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Generate Next.js types
run: pnpm exec next typegen
- name: Typecheck
run: pnpm exec tsc --noEmit
- name: Lint
run: pnpm run lint
- name: Build application
run: pnpm run build

View File

@@ -1,87 +0,0 @@
name: Build and Push to Docker Hub
on:
push:
branches:
- main
tags:
- 'v*.*.*'
pull_request:
branches:
- main
workflow_dispatch:
env:
DOCKER_IMAGE_NAME: openmonetis
jobs:
quality:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v5
- name: Setup pnpm
uses: pnpm/action-setup@v4
- name: Setup Node.js
uses: actions/setup-node@v5
with:
node-version: 22
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Lint
run: pnpm run lint
build-and-push:
runs-on: ubuntu-latest
needs: quality
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v5
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Docker Hub
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Extract metadata (tags, labels)
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ secrets.DOCKER_USERNAME }}/${{ env.DOCKER_IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=raw,value=latest,enable={{is_default_branch}}
type=sha,prefix={{branch}}-
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
platforms: linux/amd64,linux/arm64
- name: Image digest
run: echo ${{ steps.meta.outputs.digest }}

View File

@@ -2,58 +2,151 @@ name: Release
on:
push:
branches:
- main
tags:
- "v*.*.*"
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false
env:
DOCKER_IMAGE_NAME: openmonetis
permissions:
contents: read
jobs:
release:
quality:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v5
- name: Validate release version
shell: bash
run: |
TAG_VERSION="${GITHUB_REF_NAME#v}"
PACKAGE_VERSION="$(jq -r '.version' package.json)"
if [[ ! "$TAG_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "A tag $GITHUB_REF_NAME não segue o formato vX.Y.Z."
exit 1
fi
if [[ "$TAG_VERSION" != "$PACKAGE_VERSION" ]]; then
echo "A tag $GITHUB_REF_NAME não corresponde à versão $PACKAGE_VERSION do package.json."
exit 1
fi
if ! grep -Fq "## [$TAG_VERSION]" CHANGELOG.md; then
echo "A versão $TAG_VERSION não foi encontrada no CHANGELOG.md."
exit 1
fi
- name: Setup pnpm
uses: pnpm/action-setup@v4
- name: Setup Node.js
uses: actions/setup-node@v5
with:
node-version: 22
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Generate Next.js types
run: pnpm exec next typegen
- name: Typecheck
run: pnpm exec tsc --noEmit
- name: Lint
run: pnpm run lint
docker:
runs-on: ubuntu-latest
needs: quality
steps:
- name: Checkout repository
uses: actions/checkout@v5
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ secrets.DOCKER_USERNAME }}/${{ env.DOCKER_IMAGE_NAME }}
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=raw,value=latest
- name: Build and push Docker image
id: build
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
platforms: linux/amd64,linux/arm64
- name: Image digest
run: echo "${{ steps.build.outputs.digest }}"
github-release:
runs-on: ubuntu-latest
needs: docker
permissions:
contents: write
steps:
- name: Checkout repository
uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Read version from package.json
id: version
run: |
VERSION=$(jq -r '.version' package.json)
echo "value=$VERSION" >> $GITHUB_OUTPUT
echo "tag=v$VERSION" >> $GITHUB_OUTPUT
- name: Check if tag already exists
id: tag_check
run: |
if git ls-remote --tags origin "refs/tags/v${{ steps.version.outputs.value }}" | grep -q .; then
echo "exists=true" >> $GITHUB_OUTPUT
else
echo "exists=false" >> $GITHUB_OUTPUT
fi
- name: Extract changelog for this version
if: steps.tag_check.outputs.exists == 'false'
id: changelog
shell: bash
run: |
VERSION="${{ steps.version.outputs.value }}"
# Extrai o bloco entre ## [X.Y.Z] e o próximo ## [
NOTES=$(awk "/^## \[$VERSION\]/{found=1; next} found && /^## \[/{exit} found{print}" CHANGELOG.md)
# Remove linhas em branco do início e fim
NOTES=$(echo "$NOTES" | sed '/./,$!d' | sed -e :a -e '/^\n*$/{$d;N;ba}')
VERSION="${GITHUB_REF_NAME#v}"
NOTES=$(awk -v version="$VERSION" '
index($0, "## [" version "]") == 1 { found=1; next }
found && /^## \[/ { exit }
found && !started && /^[[:space:]]*$/ { next }
found { started=1; print }
' CHANGELOG.md)
if [[ -z "$NOTES" ]]; then
echo "Não foi possível extrair as notas da versão $VERSION."
exit 1
fi
{
echo "notes<<EOF"
echo "$NOTES"
echo "EOF"
} >> $GITHUB_OUTPUT
} >> "$GITHUB_OUTPUT"
- name: Create tag and GitHub Release
if: steps.tag_check.outputs.exists == 'false'
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ steps.version.outputs.tag }}
name: ${{ steps.version.outputs.tag }}
tag_name: ${{ github.ref_name }}
name: ${{ github.ref_name }}
body: ${{ steps.changelog.outputs.notes }}
draft: false
prerelease: false

1
.gitignore vendored
View File

@@ -105,7 +105,6 @@ docker-compose.override.yml
.gemini/
.cursor/
QWEN.md
AGENTS.md
.codex
# === Backups locais ===
/backup/

View File

@@ -1,4 +1,4 @@
# CLAUDE.md - OpenMonetis
# AGENTS.md - OpenMonetis
> Self-hosted personal finance app (Next.js 16, React 19, PostgreSQL, Drizzle ORM, Better Auth, Tailwind 4, shadcn/ui).
> Portuguese UI, English folders/imports. Linter: Biome 2.x. Package manager: pnpm.
@@ -16,7 +16,7 @@
3. **Periods** usam formato `YYYY-MM` (ex: `"2025-11"`). Utils em `src/shared/utils/period/`.
4. **Moeda**: R$ com 2 decimais. DB: `numeric(12, 2)`. Utils em `src/shared/utils/currency.ts`.
5. **Revalidation**: usar `revalidateForEntity("entity")` de `src/shared/lib/actions/helpers.ts` apos mutations.
6. **Versionamento**: registrar mudancas no `CHANGELOG.md` seguindo Keep a Changelog, também altere o `package.json` e `readme.md` (Badges do README.md). Cada versão deve ter um parágrafo introdutório em linguagem humana logo abaixo do cabeçalho `## [x.y.z]`, antes das seções `### Adicionado/Alterado/Removido` — descrevendo em prosa o que a versão representa (ex: "Esta versão foca em polimento visual e reorganização interna...").
6. **Versionamento e publicação**: registrar mudancas no `CHANGELOG.md` seguindo Keep a Changelog, também alterar o `package.json` e o badge de versão do `README.md`. Cada versão deve ter um parágrafo introdutório em linguagem humana logo abaixo do cabeçalho `## [x.y.z]`, antes das seções `### Adicionado/Alterado/Removido` — descrevendo em prosa o que a versão representa (ex: "Esta versão foca em polimento visual e reorganização interna..."). A `main` executa somente a CI; imagens Docker e GitHub Releases são publicadas exclusivamente por tags SemVer no formato `vX.Y.Z`. Antes de criar a tag, confirmar que a CI da `main` passou e que tag, `package.json`, `CHANGELOG.md` e badge do `README.md` usam a mesma versão. A tag deve apontar para o commit validado. Criar ou enviar uma tag dispara publicação externa (`X.Y.Z`, `X.Y`, `X` e `latest` no Docker Hub, seguida da GitHub Release), portanto agentes nunca devem criar ou fazer push de tags sem autorização explícita do usuário. Quando o usuário pedir **"commit e push"** em uma mudança que prepara uma nova versão, isso conta como autorização explícita para executar o fluxo completo: commitar, enviar a `main`, aguardar a CI da `main` passar, criar a tag SemVer correspondente à versão preparada e enviar essa tag. Se não houver versão preparada ou se houver divergência entre `package.json`, `CHANGELOG.md`, badge do `README.md` e tag pretendida, parar e pedir confirmação. Não voltar a publicar `latest` diretamente de pushes na `main`.
7. **Comunicacao**: responder em portugues clara e direta com o time.
8. **Commit messages**: agrupar por natureza. em pt-br. seguindo o padrao do sistema.
9. **README.md**: sempre que fizer alteracoes significativas, atualize o README.md.
@@ -364,3 +364,13 @@ Erros nao devem expor stack traces, paths ou nomes de bibliotecas ao cliente. Us
Verificar pacotes novos sugeridos pela IA em npmjs.com antes de instalar. Red flags: menos de 1.000 downloads/semana, publicado nos ultimos 30 dias, nome muito parecido com pacote popular. Rodar `pnpm audit` periodicamente.
---
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
<!-- END:nextjs-agent-rules -->

View File

@@ -5,6 +5,72 @@ Todas as mudanças notáveis deste projeto serão documentadas neste arquivo.
O formato é baseado em [Keep a Changelog](https://keepachangelog.com/pt-BR/1.1.0/),
e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR/).
## [2.7.13] - 2026-08-09
Esta versão atualiza a base técnica do OpenMonetis com as correções de segurança e desempenho do Next.js 16.3, acelera as verificações de tipos, melhora a recuperação de falhas nas áreas mais complexas do aplicativo e corrige a importação de extratos OFX que reutilizam identificadores bancários.
### Adicionado
- Interface: dashboard, Insights, anexos e relatórios agora possuem limites de erro próprios, preservando a navegação e oferecendo uma nova tentativa sem derrubar toda a área autenticada.
- Desenvolvimento: agentes de código passam a consultar a documentação do Next.js correspondente à versão instalada no projeto.
### Alterado
- ATENÇÃO — mudança no banco de dados: esta versão adiciona a coluna ofx_import_fingerprint e substitui o índice único baseado apenas no FITID por um índice de fingerprint. Aplique a migração 0034_superb_blonde_phantom.sql antes de iniciar a aplicação atualizada (pnpm run db:migrate em instalações manuais). A imagem Docker tenta aplicar as migrações automaticamente durante a inicialização.
- Banco de dados: a deduplicação de importações OFX agora considera os dados completos e a ocorrência de cada transação, em vez de depender somente do FITID fornecido pela instituição financeira.
- Dependências: Next.js atualizado para 16.3.0 e TypeScript para 7.0.2.
- Desenvolvimento: Turbopack e seus caches passam a usar os padrões nativos do Next.js 16.3, removendo flags redundantes da configuração.
- CI: o cache de build em `.next/cache` agora é preservado entre execuções para acelerar compilações sucessivas.
### Corrigido
- Importação: transações OFX legítimas que compartilham o mesmo FITID agora permanecem independentes na revisão e podem ser importadas sem sobrescrever linhas, travar seletores ou serem descartadas como duplicatas (issue #88 - @cunhanai).
- Interface: a identidade estável de cada linha evita duplicações visuais e mantém funcionando a seleção, a escolha de categoria e a rolagem da tabela de revisão.
- Segurança: incorporadas as correções do Next.js 16.3 para Server Actions, Proxy, cache de respostas e otimização de imagens.
- Segurança: o otimizador de imagens agora aceita somente avatares do Google e logos do Logo.dev, removendo os curingas globais de HTTP e HTTPS; anexos do S3 continuam servidos diretamente, sem passar pelo otimizador.
## [2.7.12] - 2026-06-30
Esta versão corrige a seleção de faturas e períodos em popovers usados dentro de diálogos, alinhando esses componentes ao mesmo comportamento seguro aplicado recentemente aos seletores de data.
### Corrigido
- Lançamentos: o seletor inline de fatura volta a aceitar cliques no mês em diálogos de criação/edição e no modal de múltiplos lançamentos.
- Períodos: botões internos do seletor mensal agora são explicitamente `type="button"`, evitando submits acidentais quando o componente aparece dentro de formulários.
- Interface: popovers de período e escolha de logo de estabelecimento passam a usar modo modal quando podem aparecer sobre diálogos, preservando foco e clique.
## [2.7.11] - 2026-06-28
Esta atualização melhora a leitura diária dos lançamentos e deixa a nova visualização opcional, mantendo a possibilidade de voltar ao formato anterior quando a lista agrupada não for a melhor escolha para o usuário.
### Adicionado
- Preferências: nova opção `Agrupar por data` em Ajustes > Preferências > Lançamentos para alternar entre a lista agrupada por data e a visualização anterior.
- Lançamentos: a lista agora pode exibir uma barra de data por grupo no formato `TER, 26 JUN 2026`, reunindo os lançamentos daquele dia.
### Alterado
- Lançamentos: quando o agrupamento por data está ativo, os cards e linhas deixam de repetir a data em cada item, reduzindo ruído visual e mantendo vencimentos de boleto como informação do lançamento.
- Lançamentos: a preferência de agrupamento por data é aplicada nas listagens principais, extratos de conta, faturas de cartão, detalhes de pessoa e detalhes de categoria.
- Interface: checkboxes passam a usar um visual mais compacto.
- Documentação: o README agora cita o agrupamento por data entre as opções de personalização.
### Corrigido
- Lançamentos: o seletor de data em modais de criação e edição volta a aceitar a data selecionada no calendário.
## [2.7.10] - 2026-06-27
Esta versão ajusta a experiência de leitura dos lançamentos parcelados após antecipações, permitindo esconder parcelas já liquidadas por antecipação sem perder o histórico quando ele ainda for necessário.
### Adicionado
- Ajustes: nova preferência `Ocultar parcelas antecipadas` para remover da tabela lançamentos marcados como parcela antecipada.
### Alterado
- Lançamentos: a preferência passa a ser aplicada nas listagens principais, extratos de conta, faturas de cartão, detalhes de pessoa, detalhes de categoria e exportação de lançamentos, preservando paginação e contagens visíveis.
## [2.7.9] - 2026-06-21
Esta versão torna a publicação mais previsível ao separar a validação contínua da entrega de versões oficiais. Pull requests e a branch principal continuam sendo verificadas, enquanto imagens Docker e releases passam a ser produzidas somente a partir de uma tag SemVer validada.
### Alterado
- CI: pull requests e pushes na `main` agora executam geração de tipos, verificação TypeScript, lint e build sem publicar imagens.
- Releases: tags `vX.Y.Z` agora validam sua correspondência com o `package.json` e o `CHANGELOG.md` antes de publicar as imagens Docker e criar a GitHub Release.
- Docker: as tags versionadas e `latest` passam a ser publicadas exclusivamente por releases oficiais, depois das verificações de qualidade.
## [2.7.8] - 2026-06-21
Esta versão deixa documentos e comprovantes mais fáceis de guardar e encontrar sem tirar o foco da rotina financeira. Agora é possível manter arquivos junto às notas, consultar a galeria por pessoa com identificação visual e abrir uma categoria diretamente das tendências do dashboard, sempre preservando o contexto do período selecionado.

View File

@@ -10,7 +10,7 @@
> **Não há versão online hospedada.** Você precisa clonar o repositório e rodar localmente ou no seu próprio servidor.
[![Version](https://img.shields.io/badge/version-2.7.8-blue?style=flat-square)](CHANGELOG.md)
[![Version](https://img.shields.io/badge/version-2.7.13-blue?style=flat-square)](CHANGELOG.md)
[![Next.js](https://img.shields.io/badge/Next.js-black?style=flat-square&logo=next.js)](https://nextjs.org/)
[![TypeScript](https://img.shields.io/badge/TypeScript-blue?style=flat-square&logo=typescript)](https://www.typescriptlang.org/)
[![PostgreSQL](https://img.shields.io/badge/PostgreSQL-blue?style=flat-square&logo=postgresql)](https://www.postgresql.org/)
@@ -65,7 +65,7 @@ A ideia é simples: ter um lugar onde consigo ver todas as minhas contas, cartõ
### Funcionalidades
💰 **Contas e transações** — Contas bancárias, cartões, dinheiro. Receitas, despesas, rendimentos e transferências. Categorização, divisão de lançamentos entre várias pessoas, filtros combináveis com intervalo de datas, extratos detalhados com identificação visual clara da conta e importação de extratos OFX e XLS/XLSX com detecção automática de categoria.
💰 **Contas e transações** — Contas bancárias, cartões, dinheiro. Receitas, despesas, rendimentos e transferências. Categorização, divisão de lançamentos entre várias pessoas, filtros combináveis com intervalo de datas, extratos detalhados com identificação visual clara da conta e importação de extratos OFX e XLS/XLSX com detecção automática de categoria e deduplicação resiliente a identificadores bancários repetidos.
📊 **Dashboard e relatórios** — Widgets personalizáveis com listas consistentes, métricas com atalhos para lançamentos, gráficos de evolução, comparativos por categoria, tendências, uso de cartões, top estabelecimentos e navegação direta entre meses pelo seletor de período. Exportação em PDF e Excel.
@@ -89,7 +89,7 @@ A ideia é simples: ter um lugar onde consigo ver todas as minhas contas, cartõ
<img src="./public/images/companion-preview-light.webp" alt="OpenMonetis Companion" width="300" height="600" />
</p>
⚙️ **Personalização** — Tema dark/light, modo privacidade, ordem das colunas, exibição de anotações, tamanho máximo de anexos, resumo opcional no modal de lançamento e changelog visual para acompanhar as novidades do app.
⚙️ **Personalização** — Tema dark/light, modo privacidade, ordem das colunas, agrupamento por data em lançamentos, exibição de anotações, tamanho máximo de anexos, resumo opcional no modal de lançamento e changelog visual para acompanhar as novidades do app.
### Stack técnica
@@ -623,6 +623,17 @@ A regra é: `actions.ts` e `queries.ts` são as portas de entrada da feature. Tu
Antes de começar, leia o [`CLAUDE.md`](CLAUDE.md) — ele documenta a arquitetura, convenções de nomenclatura, regras de queries e o checklist para novas features. Use TypeScript, commits semânticos e mantenha o `CHANGELOG.md` atualizado.
### Publicando uma versão
As validações rodam em pull requests e em cada push na `main`. A publicação só começa quando uma tag SemVer aponta para um commit validado e a versão da tag corresponde ao `package.json` e ao `CHANGELOG.md`.
```bash
git tag -a v2.7.13 -m "v2.7.13"
git push origin v2.7.13
```
O workflow da tag valida o código, publica as imagens Docker versionadas e `latest` e, somente depois, cria a GitHub Release com as notas do changelog.
---
## 💖 Apoie o Projeto

View File

@@ -0,0 +1 @@
ALTER TABLE "preferencias_usuario" ADD COLUMN "ocultar_parcelas_antecipadas" boolean DEFAULT false NOT NULL;

View File

@@ -0,0 +1 @@
ALTER TABLE "preferencias_usuario" ADD COLUMN "agrupar_lancamentos_por_data" boolean DEFAULT true NOT NULL;

View File

@@ -0,0 +1,3 @@
DROP INDEX "lancamentos_ofx_fit_id_user_id_idx";--> statement-breakpoint
ALTER TABLE "lancamentos" ADD COLUMN "ofx_import_fingerprint" text;--> statement-breakpoint
CREATE UNIQUE INDEX "lancamentos_ofx_import_fingerprint_user_id_idx" ON "lancamentos" USING btree ("user_id","ofx_import_fingerprint") WHERE ofx_import_fingerprint IS NOT NULL;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -218,6 +218,27 @@
"when": 1782051007412,
"tag": "0031_lame_cerise",
"breakpoints": true
},
{
"idx": 32,
"version": "7",
"when": 1782569103402,
"tag": "0032_bumpy_spencer_smythe",
"breakpoints": true
},
{
"idx": 33,
"version": "7",
"when": 1782685465530,
"tag": "0033_demonic_supreme_intelligence",
"breakpoints": true
},
{
"idx": 34,
"version": "7",
"when": 1786299746933,
"tag": "0034_superb_blonde_phantom",
"breakpoints": true
}
]
}

View File

@@ -4,23 +4,34 @@ import type { NextConfig } from "next";
// Carregar variáveis de ambiente explicitamente
dotenv.config();
type RemotePattern = NonNullable<
NonNullable<NextConfig["images"]>["remotePatterns"]
>[number];
const imageRemotePatterns: RemotePattern[] = [
{
protocol: "https",
hostname: "lh3.googleusercontent.com",
pathname: "/**",
},
{
protocol: "https",
hostname: "img.logo.dev",
pathname: "/**",
},
];
const nextConfig: NextConfig = {
output: "standalone",
cacheComponents: true,
reactCompiler: true,
images: {
remotePatterns: [
new URL("https://lh3.googleusercontent.com/**"),
{ protocol: "https", hostname: "**" },
{ protocol: "http", hostname: "**" },
],
remotePatterns: imageRemotePatterns,
},
devIndicators: {
position: "bottom-right",
},
experimental: {
prefetchInlining: true,
turbopackFileSystemCacheForDev: true,
optimizePackageImports: ["@remixicon/react"],
},

View File

@@ -1,10 +1,10 @@
{
"name": "openmonetis",
"version": "2.7.8",
"version": "2.7.13",
"private": true,
"packageManager": "pnpm@11.1.3",
"scripts": {
"dev": "next dev --turbopack",
"dev": "next dev",
"db:seed": "tsx scripts/mock-data.ts",
"build": "next build",
"start": "next start",
@@ -31,32 +31,32 @@
"mockup": "tsx scripts/mock-data.ts"
},
"dependencies": {
"@ai-sdk/anthropic": "^3.0.81",
"@ai-sdk/google": "^3.0.80",
"@ai-sdk/openai": "^3.0.67",
"@ai-sdk/openai-compatible": "^2.0.48",
"@aws-sdk/client-s3": "^3.1059.0",
"@aws-sdk/s3-request-presigner": "^3.1059.0",
"@better-auth/passkey": "^1.6.14",
"@ai-sdk/anthropic": "^3.0.88",
"@ai-sdk/google": "^3.0.85",
"@ai-sdk/openai": "^3.0.76",
"@ai-sdk/openai-compatible": "^2.0.53",
"@aws-sdk/client-s3": "^3.1075.0",
"@aws-sdk/s3-request-presigner": "^3.1075.0",
"@better-auth/passkey": "^1.6.22",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@openrouter/ai-sdk-provider": "^2.9.0",
"@openrouter/ai-sdk-provider": "^2.10.0",
"@radix-ui/react-alert-dialog": "1.1.15",
"@radix-ui/react-avatar": "1.1.11",
"@radix-ui/react-checkbox": "1.3.3",
"@radix-ui/react-collapsible": "1.1.12",
"@radix-ui/react-dialog": "1.1.15",
"@radix-ui/react-dropdown-menu": "2.1.16",
"@radix-ui/react-hover-card": "^1.1.15",
"@radix-ui/react-hover-card": "^1.1.17",
"@radix-ui/react-label": "2.1.8",
"@radix-ui/react-navigation-menu": "^1.2.14",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-navigation-menu": "^1.2.16",
"@radix-ui/react-popover": "^1.1.17",
"@radix-ui/react-progress": "1.1.8",
"@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-radio-group": "^1.4.1",
"@radix-ui/react-select": "2.2.6",
"@radix-ui/react-separator": "1.1.8",
"@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slider": "^1.4.1",
"@radix-ui/react-slot": "1.2.4",
"@radix-ui/react-switch": "1.2.6",
"@radix-ui/react-tabs": "1.1.13",
@@ -64,11 +64,11 @@
"@radix-ui/react-toggle-group": "1.1.11",
"@radix-ui/react-tooltip": "1.2.8",
"@remixicon/react": "4.9.0",
"@tanstack/react-query": "^5.101.0",
"@tanstack/react-query": "^5.101.1",
"@tanstack/react-table": "8.21.3",
"@tanstack/react-virtual": "^3.14.2",
"ai": "^6.0.195",
"better-auth": "1.6.14",
"@tanstack/react-virtual": "^3.14.4",
"ai": "^6.0.213",
"better-auth": "1.6.22",
"canvas-confetti": "^1.9.4",
"class-variance-authority": "0.7.1",
"clsx": "2.1.1",
@@ -78,7 +78,7 @@
"exceljs": "^4.4.0",
"jspdf": "^4.2.1",
"jspdf-autotable": "^5.0.8",
"next": "16.2.7",
"next": "16.3.0",
"next-themes": "0.4.6",
"pdfjs-dist": "^6.0.227",
"pg": "8.21.0",
@@ -86,7 +86,7 @@
"react-day-picker": "^10.0.1",
"react-dom": "19.2.7",
"recharts": "3.8.1",
"resend": "^6.12.4",
"resend": "^6.16.0",
"sonner": "2.0.7",
"tailwind-merge": "3.6.0",
"tw-animate-css": "^1.4.0",
@@ -105,9 +105,9 @@
"babel-plugin-react-compiler": "^1.0.0",
"dotenv": "^17.4.2",
"drizzle-kit": "0.31.10",
"knip": "^6.15.0",
"knip": "^6.22.0",
"tailwindcss": "4.3.0",
"tsx": "4.22.4",
"typescript": "6.0.3"
"typescript": "7.0.2"
}
}

3358
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -109,6 +109,8 @@ export default async function Page({ params, searchParams }: PageProps) {
filters: searchFilters,
slugMaps,
accountId: account.id,
hideAnticipatedInstallments:
userPreferences?.hideAnticipatedInstallments ?? false,
});
const transactionsPage = await fetchAccountTransactionsPage(
@@ -233,6 +235,9 @@ export default async function Page({ params, searchParams }: PageProps) {
)}
noteAsColumn={userPreferences?.statementNoteAsColumn ?? false}
columnOrder={userPreferences?.transactionsColumnOrder ?? null}
groupTransactionsByDate={
userPreferences?.groupTransactionsByDate ?? true
}
attachmentMaxSizeMb={userPreferences?.attachmentMaxSizeMb ?? 50}
/>
</section>

View File

@@ -1,6 +1,7 @@
import { connection } from "next/server";
import { AttachmentsPage } from "@/features/attachments/components/attachments-page";
import { fetchAttachmentsPageData } from "@/features/attachments/queries";
import { ContentErrorBoundary } from "@/shared/components/feedback/content-error-boundary";
import { getUserId } from "@/shared/lib/auth/server";
import { parsePeriodParam } from "@/shared/utils/period";
@@ -19,7 +20,18 @@ const getSingleParam = (
return Array.isArray(value) ? (value[0] ?? null) : value;
};
export default async function Page({ searchParams }: PageProps) {
export default function Page({ searchParams }: PageProps) {
return (
<ContentErrorBoundary
title="Não foi possível carregar os anexos"
description="Os documentos e comprovantes não puderam ser carregados agora."
>
<AttachmentsContent searchParams={searchParams} />
</ContentErrorBoundary>
);
}
async function AttachmentsContent({ searchParams }: PageProps) {
await connection();
const userId = await getUserId();
const resolvedSearchParams = searchParams ? await searchParams : undefined;

View File

@@ -82,6 +82,8 @@ export default async function Page({ params, searchParams }: PageProps) {
filters: searchFilters,
slugMaps,
cardId: card.id,
hideAnticipatedInstallments:
userPreferences?.hideAnticipatedInstallments ?? false,
});
const transactionRows = await fetchCardTransactions(filters);
@@ -210,6 +212,9 @@ export default async function Page({ params, searchParams }: PageProps) {
allowCreate
noteAsColumn={userPreferences?.statementNoteAsColumn ?? false}
columnOrder={userPreferences?.transactionsColumnOrder ?? null}
groupTransactionsByDate={
userPreferences?.groupTransactionsByDate ?? true
}
attachmentMaxSizeMb={userPreferences?.attachmentMaxSizeMb ?? 50}
defaultCardId={card.id}
defaultPaymentMethod="Cartão de crédito"

View File

@@ -41,13 +41,17 @@ export default async function Page({ params, searchParams }: PageProps) {
const periodoParam = getSingleParam(resolvedSearchParams, "periodo");
const { period: selectedPeriod } = parsePeriodParam(periodoParam);
const [detail, filterSources, estabelecimentos, userPreferences] =
await Promise.all([
fetchCategoryDetails(userId, categoryId, selectedPeriod),
fetchTransactionFilterSources(userId),
fetchRecentEstablishments(userId),
fetchUserPreferences(userId),
]);
const [filterSources, estabelecimentos, userPreferences] = await Promise.all([
fetchTransactionFilterSources(userId),
fetchRecentEstablishments(userId),
fetchUserPreferences(userId),
]);
const detail = await fetchCategoryDetails(
userId,
categoryId,
selectedPeriod,
userPreferences?.hideAnticipatedInstallments ?? false,
);
if (!detail) {
notFound();
@@ -101,6 +105,9 @@ export default async function Page({ params, searchParams }: PageProps) {
allowCreate={true}
noteAsColumn={userPreferences?.statementNoteAsColumn ?? false}
columnOrder={userPreferences?.transactionsColumnOrder ?? null}
groupTransactionsByDate={
userPreferences?.groupTransactionsByDate ?? true
}
attachmentMaxSizeMb={userPreferences?.attachmentMaxSizeMb ?? 50}
/>
</main>

View File

@@ -6,6 +6,7 @@ import { extractDashboardLogoNames } from "@/features/dashboard/lib/extract-logo
import { fetchDashboardPageData } from "@/features/dashboard/page-data-queries";
import { getSingleParam } from "@/features/transactions/lib/page-helpers";
import { LogoPrefetchProvider } from "@/shared/components/entity-avatar";
import { ContentErrorBoundary } from "@/shared/components/feedback/content-error-boundary";
import MonthNavigation from "@/shared/components/month-picker/month-navigation";
import { getUser } from "@/shared/lib/auth/server";
import { prefetchLogoMappings } from "@/shared/lib/logo/prefetch-server";
@@ -17,7 +18,18 @@ type PageProps = {
searchParams?: PageSearchParams;
};
export default async function Page({ searchParams }: PageProps) {
export default function Page({ searchParams }: PageProps) {
return (
<ContentErrorBoundary
title="Não foi possível carregar o dashboard"
description="Seus dados financeiros não puderam ser carregados agora."
>
<DashboardContent searchParams={searchParams} />
</ContentErrorBoundary>
);
}
async function DashboardContent({ searchParams }: PageProps) {
await connection();
const user = await getUser();
const resolvedSearchParams = searchParams ? await searchParams : undefined;
@@ -41,19 +53,29 @@ export default async function Page({ searchParams }: PageProps) {
<main className="flex flex-col gap-4">
<DashboardWelcome name={user.name} />
<MonthNavigation />
<DashboardMetricsCards
metrics={dashboardData.metrics}
period={selectedPeriod}
adminPayerSlug={adminPayerSlug}
/>
<LogoPrefetchProvider mappings={logoMappings}>
<DashboardGridEditable
data={dashboardData}
<ContentErrorBoundary
title="Não foi possível exibir o resumo"
description="Os indicadores do período não puderam ser exibidos agora."
>
<DashboardMetricsCards
metrics={dashboardData.metrics}
period={selectedPeriod}
initialPreferences={dashboardWidgets}
quickActionOptions={quickActionOptions}
adminPayerSlug={adminPayerSlug}
/>
</LogoPrefetchProvider>
</ContentErrorBoundary>
<ContentErrorBoundary
title="Não foi possível exibir os widgets"
description="Os detalhes do dashboard não puderam ser exibidos agora."
>
<LogoPrefetchProvider mappings={logoMappings}>
<DashboardGridEditable
data={dashboardData}
period={selectedPeriod}
initialPreferences={dashboardWidgets}
quickActionOptions={quickActionOptions}
/>
</LogoPrefetchProvider>
</ContentErrorBoundary>
</main>
);
}

View File

@@ -1,5 +1,6 @@
import { connection } from "next/server";
import { InsightsPage } from "@/features/insights/components/insights-page";
import { ContentErrorBoundary } from "@/shared/components/feedback/content-error-boundary";
import MonthNavigation from "@/shared/components/month-picker/month-navigation";
import { parsePeriodParam } from "@/shared/utils/period";
@@ -18,7 +19,18 @@ const getSingleParam = (
return Array.isArray(value) ? (value[0] ?? null) : value;
};
export default async function Page({ searchParams }: PageProps) {
export default function Page({ searchParams }: PageProps) {
return (
<ContentErrorBoundary
title="Não foi possível carregar os Insights"
description="As análises financeiras não puderam ser carregadas agora."
>
<InsightsContent searchParams={searchParams} />
</ContentErrorBoundary>
);
}
async function InsightsContent({ searchParams }: PageProps) {
await connection();
const resolvedSearchParams = searchParams ? await searchParams : undefined;
const periodoParam = getSingleParam(resolvedSearchParams, "periodo");

View File

@@ -131,6 +131,7 @@ export default async function Page({ params, searchParams }: PageProps) {
...EMPTY_FILTERS,
searchFilter: allSearchFilters.searchFilter, // Permitir busca mesmo em modo read-only
};
const userPreferences = await fetchUserPreferences(userId);
let filterSources: Awaited<
ReturnType<typeof fetchTransactionFilterSources>
@@ -163,6 +164,8 @@ export default async function Page({ params, searchParams }: PageProps) {
filters: searchFilters,
slugMaps,
payerId: pagador.id,
hideAnticipatedInstallments:
userPreferences?.hideAnticipatedInstallments ?? false,
});
const sharesPromise = canEdit
@@ -184,7 +187,6 @@ export default async function Page({ params, searchParams }: PageProps) {
shareRows,
currentUserShare,
estabelecimentos,
userPreferences,
] = await Promise.all([
fetchPayerTransactions(filters),
fetchPayerMonthlyBreakdown({
@@ -220,7 +222,6 @@ export default async function Page({ params, searchParams }: PageProps) {
sharesPromise,
currentUserSharePromise,
fetchRecentEstablishments(userId),
fetchUserPreferences(userId),
]);
const mappedTransactions = mapTransactionsData(transactionRows);
@@ -407,6 +408,9 @@ export default async function Page({ params, searchParams }: PageProps) {
allowCreate={canEdit}
noteAsColumn={userPreferences?.statementNoteAsColumn ?? false}
columnOrder={userPreferences?.transactionsColumnOrder ?? null}
groupTransactionsByDate={
userPreferences?.groupTransactionsByDate ?? true
}
attachmentMaxSizeMb={userPreferences?.attachmentMaxSizeMb ?? 50}
importPayerOptions={loggedUserOptionSets?.payerOptions}
importSplitPayerOptions={

View File

@@ -6,6 +6,7 @@ import { CardTopExpenses } from "@/features/reports/components/cards/card-top-ex
import { CardUsageChart } from "@/features/reports/components/cards/card-usage-chart";
import { CardsOverview } from "@/features/reports/components/cards/cards-overview";
import { fetchCartoesReportData } from "@/features/reports/lib/cards-report-queries";
import { ContentErrorBoundary } from "@/shared/components/feedback/content-error-boundary";
import MonthNavigation from "@/shared/components/month-picker/month-navigation";
import { Card } from "@/shared/components/ui/card";
import { getUser } from "@/shared/lib/auth/server";
@@ -26,9 +27,18 @@ const getSingleParam = (
return Array.isArray(value) ? (value[0] ?? null) : value;
};
export default async function RelatorioCartoesPage({
searchParams,
}: PageProps) {
export default function CardUsagePage({ searchParams }: PageProps) {
return (
<ContentErrorBoundary
title="Não foi possível carregar o uso dos cartões"
description="Os dados deste relatório não puderam ser carregados agora."
>
<CardUsageContent searchParams={searchParams} />
</ContentErrorBoundary>
);
}
async function CardUsageContent({ searchParams }: PageProps) {
await connection();
const user = await getUser();
const resolvedSearchParams = searchParams ? await searchParams : undefined;

View File

@@ -10,6 +10,7 @@ import { fetchCategoryChartData } from "@/features/reports/lib/category-chart-qu
import { fetchCategoryReport } from "@/features/reports/lib/category-report-queries";
import { fetchUserCategories } from "@/features/reports/lib/category-trends-queries";
import { validateDateRange } from "@/features/reports/lib/utils";
import { ContentErrorBoundary } from "@/shared/components/feedback/content-error-boundary";
import { getUserId } from "@/shared/lib/auth/server";
import type { CategoryReportFilters } from "@/shared/lib/types/reports";
import { addMonthsToPeriod, getCurrentPeriod } from "@/shared/utils/period";
@@ -29,7 +30,18 @@ const getSingleParam = (
return Array.isArray(value) ? (value[0] ?? null) : value;
};
export default async function Page({ searchParams }: PageProps) {
export default function Page({ searchParams }: PageProps) {
return (
<ContentErrorBoundary
title="Não foi possível carregar as tendências"
description="A evolução das categorias não pôde ser carregada agora."
>
<CategoryTrendsContent searchParams={searchParams} />
</ContentErrorBoundary>
);
}
async function CategoryTrendsContent({ searchParams }: PageProps) {
await connection();
// Get authenticated user
const userId = await getUserId();

View File

@@ -8,6 +8,7 @@ import {
fetchTopEstablishmentsData,
type PeriodFilter,
} from "@/features/reports/establishments/queries";
import { ContentErrorBoundary } from "@/shared/components/feedback/content-error-boundary";
import { Card } from "@/shared/components/ui/card";
import { getUser } from "@/shared/lib/auth/server";
import { parsePeriodParam } from "@/shared/utils/period";
@@ -34,9 +35,18 @@ const validatePeriodFilter = (value: string | null): PeriodFilter => {
return "6";
};
export default async function TopEstablishmentsPage({
searchParams,
}: PageProps) {
export default function EstablishmentsPage({ searchParams }: PageProps) {
return (
<ContentErrorBoundary
title="Não foi possível carregar os estabelecimentos"
description="Os dados deste relatório não puderam ser carregados agora."
>
<EstablishmentsContent searchParams={searchParams} />
</ContentErrorBoundary>
);
}
async function EstablishmentsContent({ searchParams }: PageProps) {
await connection();
const user = await getUser();
const resolvedSearchParams = searchParams ? await searchParams : undefined;

View File

@@ -2,10 +2,22 @@ import { connection } from "next/server";
import { InstallmentAnalysisPage } from "@/features/dashboard/components/installment-analysis/installment-analysis-page";
import { fetchInstallmentAnalysis } from "@/features/dashboard/expenses/installment-analysis-queries";
import { LogoPrefetchProvider } from "@/shared/components/entity-avatar";
import { ContentErrorBoundary } from "@/shared/components/feedback/content-error-boundary";
import { getUser } from "@/shared/lib/auth/server";
import { prefetchLogoMappings } from "@/shared/lib/logo/prefetch-server";
export default async function Page() {
export default function Page() {
return (
<ContentErrorBoundary
title="Não foi possível carregar a análise de parcelas"
description="Os parcelamentos não puderam ser analisados agora."
>
<InstallmentAnalysisContent />
</ContentErrorBoundary>
);
}
async function InstallmentAnalysisContent() {
await connection();
const user = await getUser();
const data = await fetchInstallmentAnalysis(user.id);

View File

@@ -85,6 +85,12 @@ export default async function Page() {
showTransactionSummary={
userPreferences?.showTransactionSummary ?? true
}
groupTransactionsByDate={
userPreferences?.groupTransactionsByDate ?? true
}
hideAnticipatedInstallments={
userPreferences?.hideAnticipatedInstallments ?? false
}
/>
</div>
</Card>

View File

@@ -53,6 +53,8 @@ export default async function Page({ searchParams }: PageProps) {
period: selectedPeriod,
filters: searchFilters,
slugMaps,
hideAnticipatedInstallments:
userPreferences?.hideAnticipatedInstallments ?? false,
});
const [transactionsPage, estabelecimentos] = await Promise.all([
@@ -112,6 +114,9 @@ export default async function Page({ searchParams }: PageProps) {
}}
noteAsColumn={userPreferences?.statementNoteAsColumn ?? false}
columnOrder={userPreferences?.transactionsColumnOrder ?? null}
groupTransactionsByDate={
userPreferences?.groupTransactionsByDate ?? true
}
attachmentMaxSizeMb={userPreferences?.attachmentMaxSizeMb ?? 50}
/>
</LogoPrefetchProvider>

View File

@@ -4,11 +4,10 @@ import {
RiShieldCheckLine,
RiSmartphoneLine,
} from "@remixicon/react";
import { headers } from "next/headers";
import Image from "next/image";
import Link from "next/link";
import { AnimateOnScroll } from "@/features/landing/components/animate-on-scroll";
import { MobileNav } from "@/features/landing/components/mobile-nav";
import { LandingNavbar } from "@/features/landing/components/landing-navbar";
import { SetupTabs } from "@/features/landing/components/setup-tabs";
import {
companionBanks,
@@ -16,98 +15,31 @@ import {
extraFeatures,
getMetricsItems,
mainFeatures,
navLinks,
pwaHighlights,
stackItems,
whoIsItForItems,
} from "@/features/landing/constants";
import { landingImages } from "@/features/landing/images";
import { fetchGitHubStats } from "@/features/landing/queries";
import { AnimatedThemeToggler } from "@/shared/components/animated-theme-toggler";
import {
fetchGitHubStats,
getLandingCopyrightYear,
} from "@/features/landing/queries";
import { Logo } from "@/shared/components/brand/logo";
import { NavbarShell } from "@/shared/components/navigation/navbar/navbar-shell";
import { Badge } from "@/shared/components/ui/badge";
import { Button } from "@/shared/components/ui/button";
import { Card, CardContent } from "@/shared/components/ui/card";
import { getOptionalUserSession } from "@/shared/lib/auth/server";
import { isSignupDisabled } from "@/shared/lib/auth/signup";
export default async function Page() {
const [session, headersList, githubStats] = await Promise.all([
getOptionalUserSession(),
headers(),
const [githubStats, copyrightYear] = await Promise.all([
fetchGitHubStats(),
getLandingCopyrightYear(),
]);
const hostname = headersList.get("host")?.replace(/:\d+$/, "");
const publicDomain = process.env.PUBLIC_DOMAIN?.replace(
/^https?:\/\//,
"",
).replace(/:\d+$/, "");
const isPublicDomain = !!(publicDomain && hostname === publicDomain);
const signupDisabled = isSignupDisabled();
const metricsItems = getMetricsItems(githubStats.stars, githubStats.forks);
return (
<div className="flex min-h-screen flex-col">
{/* Navigation */}
<NavbarShell>
{/* Center Navigation Links */}
<nav className="hidden md:flex items-center gap-1 absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2">
{navLinks.map(({ href, label }) => (
<Link
key={href}
href={href}
className="inline-flex h-9 items-center justify-center rounded-md px-2 text-sm font-medium leading-none text-primary-foreground/75 transition-colors hover:bg-primary-foreground/10 hover:text-primary-foreground dark:text-white/75 dark:hover:bg-white/10 dark:hover:text-white"
>
{label}
</Link>
))}
</nav>
<nav className="ml-auto flex items-center gap-1">
<AnimatedThemeToggler variant="navbar" />
{!isPublicDomain &&
(session?.user ? (
<Link prefetch href="/dashboard" className="hidden md:block">
<Button
variant="navbar"
size="sm"
className="h-9 text-primary-foreground/75 hover:bg-primary-foreground/10 hover:text-primary-foreground shadow-none dark:text-white/75 dark:hover:bg-white/10 dark:hover:text-white"
>
Dashboard
</Button>
</Link>
) : (
<div className="hidden md:flex items-center gap-1">
<Link href="/login">
<Button
variant="ghost"
size="sm"
className="h-9 text-primary-foreground/75 hover:bg-primary-foreground/10 hover:text-primary-foreground shadow-none dark:text-white/75 dark:hover:bg-white/10 dark:hover:text-white"
>
Entrar
</Button>
</Link>
{!signupDisabled && (
<Link href="/signup">
<Button
variant="ghost"
size="sm"
className="h-9 text-primary-foreground/75 hover:bg-primary-foreground/10 hover:text-primary-foreground shadow-none dark:text-white/75 dark:hover:bg-white/10 dark:hover:text-white"
>
Começar
</Button>
</Link>
)}
</div>
))}
<MobileNav
isPublicDomain={isPublicDomain}
isLoggedIn={!!session?.user}
signupDisabled={signupDisabled}
/>
</nav>
</NavbarShell>
<LandingNavbar />
{/* Hero Section */}
<section className="relative overflow-hidden pt-14 md:pt-20 lg:pt-24 pb-0">
@@ -698,8 +630,7 @@ export default async function Page() {
<div className="border-t mt-8 md:mt-12 pt-6 md:pt-8 flex flex-col md:flex-row justify-between items-center gap-3 md:gap-4 text-sm text-muted-foreground">
<p>
© {new Date().getFullYear()} openmonetis. Projeto open source
sob licença.
© {copyrightYear} openmonetis. Projeto open source sob licença.
</p>
<div className="flex items-center gap-2">
<RiShieldCheckLine className="size-4 text-primary" />

View File

@@ -157,6 +157,12 @@ export const userPreferences = pgTable("preferencias_usuario", {
showTransactionSummary: boolean("mostrar_resumo_lancamento")
.notNull()
.default(true),
groupTransactionsByDate: boolean("agrupar_lancamentos_por_data")
.notNull()
.default(true),
hideAnticipatedInstallments: boolean("ocultar_parcelas_antecipadas")
.notNull()
.default(false),
dashboardWidgets: jsonb("dashboard_widgets").$type<{
order: string[];
hidden: string[];
@@ -676,6 +682,7 @@ export const transactions = pgTable(
splitGroupId: uuid("split_group_id"),
transferId: uuid("transfer_id"),
ofxFitId: text("ofx_fit_id"),
ofxImportFingerprint: text("ofx_import_fingerprint"),
importBatchId: text("import_batch_id"),
},
(table) => ({
@@ -729,10 +736,12 @@ export const transactions = pgTable(
anticipationIdIdx: index("lancamentos_antecipacao_id_idx").on(
table.anticipationId,
),
// Dedup OFX: garante FITID único por usuário
ofxFitIdUserIdIdx: uniqueIndex("lancamentos_ofx_fit_id_user_id_idx")
.on(table.userId, table.ofxFitId)
.where(sql`ofx_fit_id IS NOT NULL`),
// Dedup OFX: identifica a transação completa sem assumir FITID único
ofxImportFingerprintUserIdIdx: uniqueIndex(
"lancamentos_ofx_import_fingerprint_user_id_idx",
)
.on(table.userId, table.ofxImportFingerprint)
.where(sql`ofx_import_fingerprint IS NOT NULL`),
}),
);

View File

@@ -36,6 +36,7 @@ export async function fetchCategoryDetails(
userId: string,
categoryId: string,
period: string,
hideAnticipatedInstallments = false,
): Promise<CategoryDetailData | null> {
const category = await db.query.categories.findFirst({
where: and(eq(categories.userId, userId), eq(categories.id, categoryId)),
@@ -63,6 +64,14 @@ export async function fetchCategoryDetails(
eq(transactions.transactionType, transactionType),
eq(transactions.period, period),
eq(transactions.payerId, adminPayerId),
...(hideAnticipatedInstallments
? [
or(
isNull(transactions.isAnticipated),
eq(transactions.isAnticipated, false),
),
]
: []),
...(isInvoiceCategory ? [] : [sanitizedNote]),
),
with: {

View File

@@ -0,0 +1,103 @@
import { headers } from "next/headers";
import Link from "next/link";
import { Suspense } from "react";
import { AnimatedThemeToggler } from "@/shared/components/animated-theme-toggler";
import { NavbarShell } from "@/shared/components/navigation/navbar/navbar-shell";
import { Button } from "@/shared/components/ui/button";
import { getOptionalUserSession } from "@/shared/lib/auth/server";
import { isSignupDisabled } from "@/shared/lib/auth/signup";
import { navLinks } from "../constants";
import { MobileNav } from "./mobile-nav";
async function LandingNavbarControls() {
const [session, headersList] = await Promise.all([
getOptionalUserSession(),
headers(),
]);
const hostname = headersList.get("host")?.replace(/:\d+$/, "");
const publicDomain = process.env.PUBLIC_DOMAIN?.replace(
/^https?:\/\//,
"",
).replace(/:\d+$/, "");
const isPublicDomain = !!(publicDomain && hostname === publicDomain);
const signupDisabled = isSignupDisabled();
return (
<>
{!isPublicDomain &&
(session?.user ? (
<Link prefetch href="/dashboard" className="hidden md:block">
<Button
variant="navbar"
size="sm"
className="h-9 text-primary-foreground/75 hover:bg-primary-foreground/10 hover:text-primary-foreground shadow-none dark:text-white/75 dark:hover:bg-white/10 dark:hover:text-white"
>
Dashboard
</Button>
</Link>
) : (
<div className="hidden md:flex items-center gap-1">
<Link href="/login">
<Button
variant="ghost"
size="sm"
className="h-9 text-primary-foreground/75 hover:bg-primary-foreground/10 hover:text-primary-foreground shadow-none dark:text-white/75 dark:hover:bg-white/10 dark:hover:text-white"
>
Entrar
</Button>
</Link>
{!signupDisabled && (
<Link href="/signup">
<Button
variant="ghost"
size="sm"
className="h-9 text-primary-foreground/75 hover:bg-primary-foreground/10 hover:text-primary-foreground shadow-none dark:text-white/75 dark:hover:bg-white/10 dark:hover:text-white"
>
Começar
</Button>
</Link>
)}
</div>
))}
<MobileNav
isPublicDomain={isPublicDomain}
isLoggedIn={!!session?.user}
signupDisabled={signupDisabled}
/>
</>
);
}
function LandingNavbarControlsFallback() {
return (
<>
<div className="hidden h-9 w-36 md:block" aria-hidden="true" />
<MobileNav isPublicDomain isLoggedIn={false} signupDisabled />
</>
);
}
export function LandingNavbar() {
return (
<NavbarShell>
<nav className="hidden md:flex items-center gap-1 absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2">
{navLinks.map(({ href, label }) => (
<Link
key={href}
href={href}
className="inline-flex h-9 items-center justify-center rounded-md px-2 text-sm font-medium leading-none text-primary-foreground/75 transition-colors hover:bg-primary-foreground/10 hover:text-primary-foreground dark:text-white/75 dark:hover:bg-white/10 dark:hover:text-white"
>
{label}
</Link>
))}
</nav>
<nav className="ml-auto flex items-center gap-1">
<AnimatedThemeToggler variant="navbar" />
<Suspense fallback={<LandingNavbarControlsFallback />}>
<LandingNavbarControls />
</Suspense>
</nav>
</NavbarShell>
);
}

View File

@@ -1,11 +1,22 @@
import { cacheLife } from "next/cache";
export async function getLandingCopyrightYear(): Promise<number> {
"use cache";
cacheLife({ revalidate: 86_400 });
return new Date().getFullYear();
}
export async function fetchGitHubStats(): Promise<{
stars: number;
forks: number;
}> {
"use cache";
cacheLife({ revalidate: 3600 });
try {
const res = await fetch(
"https://api.github.com/repos/felipegcoutinho/openmonetis",
{ next: { revalidate: 3600 } },
);
if (!res.ok) return { stars: 200, forks: 60 };
const data = await res.json();

View File

@@ -228,6 +228,7 @@ export function CategoryReportFilters({
<Popover open={startMonthOpen} onOpenChange={setStartMonthOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
className="w-[calc(50%-0.25rem)] md:w-[150px] justify-start text-sm border-dashed"
disabled={isLoading}
@@ -248,6 +249,7 @@ export function CategoryReportFilters({
<Popover open={endMonthOpen} onOpenChange={setEndMonthOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
className="w-[calc(50%-0.25rem)] md:w-[150px] justify-start text-sm border-dashed"
disabled={isLoading}

View File

@@ -69,6 +69,8 @@ const updatePreferencesSchema = z.object({
transactionsColumnOrder: z.array(z.string()).nullable(),
attachmentMaxSizeMb: z.number().int().min(1).max(100),
showTransactionSummary: z.boolean(),
groupTransactionsByDate: z.boolean(),
hideAnticipatedInstallments: z.boolean(),
});
type ResettableUser = {
@@ -584,6 +586,8 @@ export async function updatePreferencesAction(
transactionsColumnOrder: validated.transactionsColumnOrder,
attachmentMaxSizeMb: validated.attachmentMaxSizeMb,
showTransactionSummary: validated.showTransactionSummary,
groupTransactionsByDate: validated.groupTransactionsByDate,
hideAnticipatedInstallments: validated.hideAnticipatedInstallments,
updatedAt: new Date(),
})
.where(eq(schema.userPreferences.userId, session.user.id));
@@ -595,6 +599,8 @@ export async function updatePreferencesAction(
transactionsColumnOrder: validated.transactionsColumnOrder,
attachmentMaxSizeMb: validated.attachmentMaxSizeMb,
showTransactionSummary: validated.showTransactionSummary,
groupTransactionsByDate: validated.groupTransactionsByDate,
hideAnticipatedInstallments: validated.hideAnticipatedInstallments,
});
}

View File

@@ -43,6 +43,8 @@ interface PreferencesFormProps {
transactionsColumnOrder: string[] | null;
attachmentMaxSizeMb: number;
showTransactionSummary: boolean;
groupTransactionsByDate: boolean;
hideAnticipatedInstallments: boolean;
}
function SortableColumnItem({ id }: { id: string }) {
@@ -87,6 +89,8 @@ export function PreferencesForm({
transactionsColumnOrder: initialColumnOrder,
attachmentMaxSizeMb: initialAttachmentMaxSizeMb,
showTransactionSummary: initialShowTransactionSummary,
groupTransactionsByDate: initialGroupTransactionsByDate,
hideAnticipatedInstallments: initialHideAnticipatedInstallments,
}: PreferencesFormProps) {
const router = useRouter();
const [isPending, startTransition] = useTransition();
@@ -109,6 +113,11 @@ export function PreferencesForm({
const [showTransactionSummary, setShowTransactionSummary] = useState(
initialShowTransactionSummary,
);
const [groupTransactionsByDate, setGroupTransactionsByDate] = useState(
initialGroupTransactionsByDate,
);
const [hideAnticipatedInstallments, setHideAnticipatedInstallments] =
useState(initialHideAnticipatedInstallments);
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
@@ -135,6 +144,8 @@ export function PreferencesForm({
transactionsColumnOrder: columnOrder,
attachmentMaxSizeMb,
showTransactionSummary,
groupTransactionsByDate,
hideAnticipatedInstallments,
});
if (result.success) {
@@ -198,6 +209,46 @@ export function PreferencesForm({
<Separator />
<section className="flex items-center justify-between max-w-md gap-4">
<div className="space-y-2">
<Label htmlFor="group-transactions-by-date" className="text-sm">
Agrupar por data
</Label>
<p className="text-sm text-muted-foreground">
Mostra uma barra de data acima dos lançamentos daquele dia. Quando
desativado, a data volta a aparecer em cada lançamento.
</p>
</div>
<Switch
id="group-transactions-by-date"
checked={groupTransactionsByDate}
onCheckedChange={setGroupTransactionsByDate}
disabled={isPending}
/>
</section>
<Separator />
<section className="flex items-center justify-between max-w-md gap-4">
<div className="space-y-2">
<Label htmlFor="hide-anticipated-installments" className="text-sm">
Ocultar parcelas antecipadas
</Label>
<p className="text-sm text-muted-foreground">
Quando ativo, parcelas antecipadas não aparecem na tabela de
lançamentos.
</p>
</div>
<Switch
id="hide-anticipated-installments"
checked={hideAnticipatedInstallments}
onCheckedChange={setHideAnticipatedInstallments}
disabled={isPending}
/>
</section>
<Separator />
<section className="space-y-2 max-w-md">
<Label className="text-sm">Ordem das colunas</Label>
<p className="text-sm text-muted-foreground">

View File

@@ -7,6 +7,8 @@ interface UserPreferences {
transactionsColumnOrder: string[] | null;
attachmentMaxSizeMb: number;
showTransactionSummary: boolean;
groupTransactionsByDate: boolean;
hideAnticipatedInstallments: boolean;
}
interface ApiToken {
@@ -36,6 +38,9 @@ export async function fetchUserPreferences(
transactionsColumnOrder: schema.userPreferences.transactionsColumnOrder,
attachmentMaxSizeMb: schema.userPreferences.attachmentMaxSizeMb,
showTransactionSummary: schema.userPreferences.showTransactionSummary,
groupTransactionsByDate: schema.userPreferences.groupTransactionsByDate,
hideAnticipatedInstallments:
schema.userPreferences.hideAnticipatedInstallments,
})
.from(schema.userPreferences)
.where(eq(schema.userPreferences.userId, userId))

View File

@@ -2,6 +2,7 @@
import { z } from "zod";
import { fetchAccountTransactions } from "@/features/accounts/statement-queries";
import { fetchUserPreferences } from "@/features/settings/queries";
import type { TransactionsExportContext } from "@/features/transactions/lib/export-types";
import {
buildSluggedFilters,
@@ -60,7 +61,10 @@ export async function exportTransactionsDataAction(
try {
const userId = await getUserId();
const validated = exportTransactionsSchema.parse(input);
const filterSources = await fetchTransactionFilterSources(userId);
const [filterSources, userPreferences] = await Promise.all([
fetchTransactionFilterSources(userId),
fetchUserPreferences(userId),
]);
const sluggedFilters = buildSluggedFilters(filterSources);
const slugMaps = buildSlugMaps(sluggedFilters);
@@ -72,6 +76,8 @@ export async function exportTransactionsDataAction(
accountId: validated.accountId ?? undefined,
cardId: validated.cardId ?? undefined,
payerId: validated.payerId ?? undefined,
hideAnticipatedInstallments:
userPreferences?.hideAnticipatedInstallments ?? false,
});
const rows =

View File

@@ -1,6 +1,6 @@
"use server";
import { and, eq, inArray } from "drizzle-orm";
import { and, eq, inArray, isNull, sql } from "drizzle-orm";
import { z } from "zod";
import { transactions } from "@/db/schema";
import {
@@ -9,23 +9,49 @@ import {
validateCartaoOwnership,
validateContaOwnership,
} from "@/features/transactions/actions/core";
import { createOfxImportFingerprint } from "@/features/transactions/lib/ofx-import-fingerprint";
import { revalidateForEntity } from "@/shared/lib/actions/helpers";
import { getUserId } from "@/shared/lib/auth/server";
import { db } from "@/shared/lib/db";
import {
normalizeOfxIdentityText,
type OfxIdentityRow,
type OfxImportDestination,
} from "@/shared/lib/import/ofx-identity";
import { uuidSchema } from "@/shared/lib/schemas/common";
import { parseLocalDateString } from "@/shared/utils/date";
import { formatDecimalForDbRequired } from "@/shared/utils/currency";
import { parseLocalDateString, toDateOnlyString } from "@/shared/utils/date";
const importRowSchema = z.object({
const ofxIdentityRowSchema = z.object({
externalId: z.string().nullable(),
externalIdOccurrence: z.number().int().nonnegative(),
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Data inválida."),
amount: z.number().positive(),
description: z.string().min(1, "Descrição obrigatória."),
transactionType: z.enum(["income", "expense"]),
sourceDescription: z.string(),
});
const ofxImportDestinationSchema = z.discriminatedUnion("type", [
z.object({ type: z.literal("account"), id: uuidSchema("Conta") }),
z.object({ type: z.literal("card"), id: uuidSchema("Cartão") }),
]);
const duplicateCheckSchema = z.object({
source: z.string().min(1),
accountNumber: z.string().nullable(),
destination: ofxImportDestinationSchema,
rows: z.array(ofxIdentityRowSchema),
});
const importRowSchema = ofxIdentityRowSchema.extend({
description: z.string().min(1, "Descrição obrigatória."),
categoryId: uuidSchema("Category").nullable().optional(),
payerId: uuidSchema("Payer").nullable().optional(),
});
const importSchema = z.object({
source: z.string().min(1),
accountNumber: z.string().nullable(),
rows: z.array(importRowSchema).min(1, "Selecione ao menos uma transação."),
payerId: uuidSchema("Payer").nullable().optional(),
accountId: uuidSchema("FinancialAccount").nullable().optional(),
@@ -44,22 +70,197 @@ type ImportResult =
| { success: true; imported: number; skipped: number; importBatchId: string }
| { success: false; error: string };
// Retorna os externalIds que já existem para o usuário (para marcar duplicatas)
export async function checkDuplicateFitIds(
fitIds: string[],
): Promise<string[]> {
const userId = await getUserId();
const ids = fitIds.filter(Boolean);
if (ids.length === 0) return [];
type ImportMatch = {
fingerprint: string | null;
existingTransactionId: string | null;
};
const rows = await db
.select({ ofxFitId: transactions.ofxFitId })
.from(transactions)
.where(
and(eq(transactions.userId, userId), inArray(transactions.ofxFitId, ids)),
type LegacyImportCandidate = {
id: string;
name: string;
amount: string;
purchaseDate: Date;
transactionType: string;
ofxFitId: string | null;
accountId: string | null;
cardId: string | null;
};
function isDestinationMatch(
candidate: LegacyImportCandidate,
destination: OfxImportDestination,
): boolean {
return destination.type === "card"
? candidate.cardId === destination.id
: candidate.accountId === destination.id;
}
function isLegacyImportMatch(
candidate: LegacyImportCandidate,
row: OfxIdentityRow,
destination: OfxImportDestination,
): boolean {
if (
!row.externalId ||
row.externalIdOccurrence !== 0 ||
candidate.ofxFitId !== row.externalId ||
!isDestinationMatch(candidate, destination)
) {
return false;
}
const expectedType = row.transactionType === "income" ? "Receita" : "Despesa";
const signedAmount =
row.transactionType === "expense" ? -row.amount : row.amount;
return (
toDateOnlyString(candidate.purchaseDate) === row.date &&
formatDecimalForDbRequired(Number(candidate.amount)) ===
formatDecimalForDbRequired(signedAmount) &&
candidate.transactionType === expectedType &&
normalizeOfxIdentityText(candidate.name) ===
normalizeOfxIdentityText(row.sourceDescription)
);
}
async function findExistingImports(
userId: string,
source: string,
accountNumber: string | null,
destination: OfxImportDestination,
rows: OfxIdentityRow[],
): Promise<ImportMatch[]> {
const fingerprints = rows.map((row) =>
createOfxImportFingerprint({
source,
accountNumber,
destination,
row,
}),
);
const fingerprintValues = [
...new Set(
fingerprints.filter(
(fingerprint): fingerprint is string => fingerprint !== null,
),
),
];
const fitIds = [
...new Set(
rows
.map((row) => row.externalId)
.filter((fitId): fitId is string => fitId !== null),
),
];
const [fingerprintMatches, legacyCandidates] = await Promise.all([
fingerprintValues.length > 0
? db
.select({
id: transactions.id,
fingerprint: transactions.ofxImportFingerprint,
})
.from(transactions)
.where(
and(
eq(transactions.userId, userId),
inArray(transactions.ofxImportFingerprint, fingerprintValues),
),
)
: Promise.resolve([]),
fitIds.length > 0
? db
.select({
id: transactions.id,
name: transactions.name,
amount: transactions.amount,
purchaseDate: transactions.purchaseDate,
transactionType: transactions.transactionType,
ofxFitId: transactions.ofxFitId,
accountId: transactions.accountId,
cardId: transactions.cardId,
})
.from(transactions)
.where(
and(
eq(transactions.userId, userId),
isNull(transactions.ofxImportFingerprint),
inArray(transactions.ofxFitId, fitIds),
),
)
: Promise.resolve([]),
]);
const transactionIdByFingerprint = new Map(
fingerprintMatches.flatMap((match) =>
match.fingerprint ? [[match.fingerprint, match.id] as const] : [],
),
);
const consumedLegacyIds = new Set<string>();
return rows.map((row, index) => {
const fingerprint = fingerprints[index] ?? null;
const currentTransactionId = fingerprint
? (transactionIdByFingerprint.get(fingerprint) ?? null)
: null;
if (currentTransactionId) {
return { fingerprint, existingTransactionId: currentTransactionId };
}
const legacyMatch = legacyCandidates.find(
(candidate) =>
!consumedLegacyIds.has(candidate.id) &&
isLegacyImportMatch(candidate, row, destination),
);
return rows.map((r) => r.ofxFitId).filter((id): id is string => id !== null);
if (legacyMatch) {
consumedLegacyIds.add(legacyMatch.id);
}
return {
fingerprint,
existingTransactionId: legacyMatch?.id ?? null,
};
});
}
async function validateDestinationOwnership(
userId: string,
destination: OfxImportDestination,
): Promise<boolean> {
return destination.type === "card"
? validateCartaoOwnership(userId, destination.id)
: validateContaOwnership(userId, destination.id);
}
export async function checkDuplicateOfxTransactions(
input: unknown,
): Promise<
{ success: true; rows: ImportMatch[] } | { success: false; error: string }
> {
const userId = await getUserId();
const parsed = duplicateCheckSchema.safeParse(input);
if (!parsed.success) {
return { success: false, error: "Dados do arquivo inválidos." };
}
const { source, accountNumber, destination, rows } = parsed.data;
if (!(await validateDestinationOwnership(userId, destination))) {
return { success: false, error: "Conta ou cartão não encontrado." };
}
return {
success: true,
rows: await findExistingImports(
userId,
source,
accountNumber,
destination,
rows,
),
};
}
export async function importTransactionsAction(
@@ -75,8 +276,25 @@ export async function importTransactionsAction(
};
}
const { rows, payerId, accountId, cardId, paymentMethod, invoicePeriod } =
parsed.data;
const {
source,
accountNumber,
rows,
payerId,
accountId,
cardId,
paymentMethod,
invoicePeriod,
} = parsed.data;
const destination: OfxImportDestination | null = cardId
? { type: "card", id: cardId }
: accountId
? { type: "account", id: accountId }
: null;
if (!destination || (accountId && cardId)) {
return { success: false, error: "Selecione uma conta ou cartão." };
}
const payerIdsByRow = rows.map((row) => row.payerId ?? payerId ?? null);
@@ -109,8 +327,33 @@ export async function importTransactionsAction(
if (!accountOk) return { success: false, error: "Conta não encontrada." };
if (!cardOk) return { success: false, error: "Cartão não encontrado." };
if (rows.length === 0) {
return { success: true, imported: 0, skipped: 0, importBatchId: "" };
const importMatches = await findExistingImports(
userId,
source,
accountNumber,
destination,
rows,
);
const rowsToImport = rows.flatMap((row, index) => {
const match = importMatches[index];
return match?.existingTransactionId
? []
: [
{
row,
payerId: payerIdsByRow[index],
fingerprint: match?.fingerprint ?? null,
},
];
});
if (rowsToImport.length === 0) {
return {
success: true,
imported: 0,
skipped: rows.length,
importBatchId: "",
};
}
const importBatchId = crypto.randomUUID();
@@ -118,7 +361,7 @@ export async function importTransactionsAction(
// Cartão de crédito: fatura pode ainda não ter sido paga
const isSettled = paymentMethod !== "Cartão de crédito";
const records = rows.map((row, index) => {
const records = rowsToImport.map(({ row, payerId, fingerprint }) => {
const purchaseDate = parseLocalDateString(row.date);
const period =
invoicePeriod ??
@@ -137,21 +380,24 @@ export async function importTransactionsAction(
period,
isSettled,
userId,
payerId: payerIdsByRow[index],
payerId,
accountId: accountId ?? null,
cardId: cardId ?? null,
categoryId: row.categoryId ?? null,
ofxFitId: row.externalId,
ofxImportFingerprint: fingerprint,
importBatchId,
};
});
// onConflictDoNothing usa o uniqueIndex (userId, ofxFitId) WHERE ofxFitId IS NOT NULL
// eliminando o SELECT prévio de checkDuplicateFitIds
// O índice de fingerprint protege contra importações concorrentes do mesmo OFX.
const inserted = await db
.insert(transactions)
.values(records)
.onConflictDoNothing()
.onConflictDoNothing({
target: [transactions.userId, transactions.ofxImportFingerprint],
where: sql`ofx_import_fingerprint IS NOT NULL`,
})
.returning({ id: transactions.id });
await revalidateForEntity("transactions", userId);
@@ -159,23 +405,31 @@ export async function importTransactionsAction(
return {
success: true,
imported: inserted.length,
skipped: records.length - inserted.length,
skipped: rows.length - inserted.length,
importBatchId,
};
}
export async function deleteTransactionByFitId(
fitId: string,
export async function deleteImportedTransaction(
transactionId: string,
): Promise<{ success: boolean; error?: string }> {
if (!fitId) return { success: false, error: "FITID inválido." };
const parsedId = uuidSchema("Lançamento").safeParse(transactionId);
if (!parsedId.success) {
return { success: false, error: "Lançamento inválido." };
}
const userId = await getUserId();
await db
const deleted = await db
.delete(transactions)
.where(
and(eq(transactions.userId, userId), eq(transactions.ofxFitId, fitId)),
);
and(eq(transactions.userId, userId), eq(transactions.id, parsedId.data)),
)
.returning({ id: transactions.id });
if (deleted.length === 0) {
return { success: false, error: "Lançamento não encontrado." };
}
await revalidateForEntity("transactions", userId);

View File

@@ -72,7 +72,7 @@ function InlinePeriodPicker({
return (
<div className="-mt-1">
<span className="text-xs text-muted-foreground">Fatura de </span>
<Popover open={open} onOpenChange={setOpen}>
<Popover modal open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"

View File

@@ -45,7 +45,7 @@ function InlinePeriodPicker({
return (
<div className="ml-1">
<span className="text-xs text-muted-foreground">Fatura de </span>
<Popover open={open} onOpenChange={setOpen}>
<Popover modal open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"

View File

@@ -1,21 +1,15 @@
"use client";
import { useRouter } from "next/navigation";
import {
useCallback,
useEffect,
useMemo,
useState,
useTransition,
} from "react";
import { useCallback, useMemo, useRef, useState, useTransition } from "react";
import { toast } from "sonner";
import {
fetchCategoryMappings,
saveCategoryMappings,
} from "@/features/transactions/actions/category-memory-action";
import {
checkDuplicateFitIds,
deleteTransactionByFitId,
checkDuplicateOfxTransactions,
deleteImportedTransaction,
importTransactionsAction,
undoImportAction,
} from "@/features/transactions/actions/import-action";
@@ -43,6 +37,7 @@ import {
} from "@/shared/components/ui/card";
import { Skeleton } from "@/shared/components/ui/skeleton";
import type { ImportStatement } from "@/shared/lib/import/types";
import { createClientSafeId } from "@/shared/utils/id";
const categoryGroupByTransactionType = {
expense: "despesa",
@@ -69,6 +64,7 @@ export function ImportPage({
const router = useRouter();
const [isPending, startTransition] = useTransition();
const [isChecking, setIsChecking] = useState(false);
const duplicateCheckRequestId = useRef(0);
const [statement, setStatement] = useState<ImportStatement | null>(null);
const [rows, setRows] = useState<ReviewRow[]>([]);
@@ -95,23 +91,45 @@ export function ImportPage({
const handleParsed = useCallback(
async (stmt: ImportStatement) => {
const requestId = ++duplicateCheckRequestId.current;
setStatement(stmt);
setIsChecking(true);
const defaultAccountCardValue = stmt.isCreditCard
? cardOptions[0]
? encodeAccountCard("card", cardOptions[0].value)
: null
: accountOptions[0]
? encodeAccountCard("account", accountOptions[0].value)
: null;
const destination = defaultAccountCardValue
? decodeAccountCard(defaultAccountCardValue)
: null;
setAccountCardValue(defaultAccountCardValue);
try {
const fitIds = stmt.transactions
.map((t) => t.externalId)
.filter((id): id is string => id !== null);
const [duplicates, categoryMappings] = await Promise.all([
checkDuplicateFitIds(fitIds).then((ids) => new Set(ids)),
const [duplicateResult, categoryMappings] = await Promise.all([
destination
? checkDuplicateOfxTransactions({
source: stmt.source,
accountNumber: stmt.accountNumber,
destination,
rows: stmt.transactions,
})
: Promise.resolve({ success: true as const, rows: [] }),
fetchCategoryMappings(stmt.transactions.map((t) => t.description)),
]);
if (requestId !== duplicateCheckRequestId.current) return;
if (!duplicateResult.success) {
toast.error(duplicateResult.error);
}
setRows(
stmt.transactions.map((t) => {
stmt.transactions.map((t, index) => {
let mappedCategoryId =
categoryMappings[normalizeDescriptionKey(t.description)] ?? null;
const existingTransactionId = duplicateResult.success
? (duplicateResult.rows[index]?.existingTransactionId ?? null)
: null;
if (t.categoryRaw) {
const categoryRaw = normalizeCategoryName(t.categoryRaw);
@@ -125,8 +143,10 @@ export function ImportPage({
return {
...t,
isDuplicate: t.externalId ? duplicates.has(t.externalId) : false,
selected: t.externalId ? !duplicates.has(t.externalId) : true,
reviewId: createClientSafeId(),
existingTransactionId,
isDuplicate: existingTransactionId !== null,
selected: existingTransactionId === null,
payerId,
categoryId: isCategoryCompatible(
mappedCategoryId,
@@ -138,23 +158,64 @@ export function ImportPage({
}),
);
} finally {
setIsChecking(false);
if (requestId === duplicateCheckRequestId.current) {
setIsChecking(false);
}
}
},
[isCategoryCompatible, payerId, categoryOptions],
[
accountOptions,
cardOptions,
categoryOptions,
isCategoryCompatible,
payerId,
],
);
// Pré-seleciona cartão ou conta com base no tipo detectado no OFX
useEffect(() => {
if (!statement || accountCardValue) return;
if (statement.isCreditCard && cardOptions[0]) {
setAccountCardValue(encodeAccountCard("card", cardOptions[0].value));
} else if (!statement.isCreditCard && accountOptions[0]) {
setAccountCardValue(
encodeAccountCard("account", accountOptions[0].value),
);
const handleAccountCardChange = async (value: string | null) => {
const requestId = ++duplicateCheckRequestId.current;
setAccountCardValue(value);
const destination = value ? decodeAccountCard(value) : null;
if (!statement || !destination) {
setIsChecking(false);
return;
}
}, [statement, cardOptions, accountOptions, accountCardValue]);
setIsChecking(true);
try {
const result = await checkDuplicateOfxTransactions({
source: statement.source,
accountNumber: statement.accountNumber,
destination,
rows,
});
if (requestId !== duplicateCheckRequestId.current) return;
if (!result.success) {
toast.error(result.error);
return;
}
setRows((previousRows) =>
previousRows.map((row, index) => {
const existingTransactionId =
result.rows[index]?.existingTransactionId ?? null;
const isDuplicate = existingTransactionId !== null;
return {
...row,
existingTransactionId,
selected:
row.isDuplicate === isDuplicate ? row.selected : !isDuplicate,
isDuplicate,
};
}),
);
} finally {
if (requestId === duplicateCheckRequestId.current) {
setIsChecking(false);
}
}
};
const toggleRow = (index: number) => {
setRows((prev) =>
@@ -184,9 +245,9 @@ export function ImportPage({
const handleUndoDuplicate = async (index: number) => {
const row = rows[index];
if (!row?.externalId) return;
if (!row?.existingTransactionId) return;
const result = await deleteTransactionByFitId(row.externalId);
const result = await deleteImportedTransaction(row.existingTransactionId);
if (!result.success) {
toast.error("Não foi possível desfazer a importação anterior.");
return;
@@ -194,7 +255,14 @@ export function ImportPage({
setRows((prev) =>
prev.map((r, i) =>
i === index ? { ...r, isDuplicate: false, selected: true } : r,
i === index
? {
...r,
existingTransactionId: null,
isDuplicate: false,
selected: true,
}
: r,
),
);
toast.success("Importação anterior removida.");
@@ -261,11 +329,15 @@ export function ImportPage({
startTransition(async () => {
const result = await importTransactionsAction({
source: statement.source,
accountNumber: statement.accountNumber,
rows: selectedRows.map((r) => ({
externalId: r.externalId,
externalIdOccurrence: r.externalIdOccurrence,
date: r.date,
amount: r.amount,
description: r.description,
sourceDescription: r.sourceDescription,
transactionType: r.transactionType,
categoryId: r.categoryId,
payerId: r.payerId,
@@ -369,7 +441,7 @@ export function ImportPage({
accountCardValue={accountCardValue}
payerId={payerId}
invoicePeriod={invoicePeriod}
onAccountCardChange={setAccountCardValue}
onAccountCardChange={handleAccountCardChange}
onPayerChange={handleBulkPayerChange}
onInvoicePeriodChange={setInvoicePeriod}
onBulkCategoryChange={handleBulkCategoryChange}

View File

@@ -43,8 +43,10 @@ const categoryGroupByTransactionType: Record<
};
export type ReviewRow = ImportedTransaction & {
reviewId: string;
selected: boolean;
isDuplicate: boolean;
existingTransactionId: string | null;
categoryId: string | null;
payerId: string | null;
};
@@ -80,6 +82,7 @@ export function ReviewTable({
const virtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => parentRef.current,
getItemKey: (index) => rows[index]?.reviewId ?? index,
estimateSize: () => 44,
overscan: 8,
});
@@ -141,7 +144,7 @@ export function ReviewTable({
);
return (
<TableRow
key={row.externalId ?? `${row.date}-${index}`}
key={row.reviewId}
className={
row.isDuplicate && !row.selected ? "opacity-50" : ""
}

View File

@@ -82,6 +82,7 @@ interface TransactionsPageProps {
allowCreate?: boolean;
noteAsColumn?: boolean;
columnOrder?: string[] | null;
groupTransactionsByDate?: boolean;
defaultCardId?: string | null;
defaultPaymentMethod?: string | null;
lockCardSelection?: boolean;
@@ -119,6 +120,7 @@ export function TransactionsPage({
allowCreate = true,
noteAsColumn = false,
columnOrder = null,
groupTransactionsByDate = true,
defaultCardId,
defaultPaymentMethod,
lockCardSelection,
@@ -745,6 +747,7 @@ export function TransactionsPage({
currentUserId={currentUserId}
noteAsColumn={noteAsColumn}
columnOrder={columnOrder}
groupTransactionsByDate={groupTransactionsByDate}
payerFilterOptions={payerFilterOptions}
categoryFilterOptions={categoryFilterOptions}
accountCardFilterOptions={accountCardFilterOptions}

View File

@@ -51,6 +51,7 @@ type BuildColumnsArgs = {
onConvertToRecurring?: (item: TransactionItem) => void;
isSettlementLoading: (id: string) => boolean;
showActions: boolean;
showDateGroups: boolean;
columnOrder?: string[] | null;
};
@@ -115,6 +116,7 @@ function buildColumns({
onConvertToRecurring,
isSettlementLoading,
showActions,
showDateGroups,
}: BuildColumnsArgs): ColumnDef<TransactionItem>[] {
const noop = () => undefined;
const handleEdit = onEdit ?? noop;
@@ -194,12 +196,14 @@ function buildColumns({
<span className="flex items-center gap-2">
<EstablishmentLogo name={name} size={32} />
<span className="flex flex-col py-0.5">
<span className="text-xs text-muted-foreground flex items-center gap-2">
{formatDate(purchaseDate)}
{dueDateLabel ? (
<span className="text-primary">{dueDateLabel}</span>
) : null}
</span>
{showDateGroups ? null : (
<span className="text-xs text-muted-foreground flex items-center gap-2">
{formatDate(purchaseDate)}
{dueDateLabel ? (
<span className="text-primary">{dueDateLabel}</span>
) : null}
</span>
)}
<span className="flex items-center gap-1">
<Tooltip>
<TooltipTrigger asChild>
@@ -254,6 +258,15 @@ function buildColumns({
</Badge>
) : null}
{showDateGroups && dueDateLabel ? (
<Badge
variant="outline"
className="px-2 text-xs text-primary"
>
{dueDateLabel}
</Badge>
) : null}
{isAnticipated && (
<Tooltip>
<TooltipTrigger asChild>

View File

@@ -20,7 +20,7 @@ import {
TooltipContent,
TooltipTrigger,
} from "@/shared/components/ui/tooltip";
import { formatDate } from "@/shared/utils/date";
import { formatDate, formatDateGroupLabel } from "@/shared/utils/date";
import { getConditionIcon, getPaymentMethodIcon } from "@/shared/utils/icons";
import { cn } from "@/shared/utils/ui";
import type { TransactionItem } from "../types";
@@ -43,6 +43,7 @@ type TransactionsMobileListProps = {
onConvertToRecurring?: (item: TransactionItem) => void;
isSettlementLoading: (id: string) => boolean;
showActions?: boolean;
showDateGroups?: boolean;
};
export function TransactionsMobileList({
@@ -61,28 +62,87 @@ export function TransactionsMobileList({
onConvertToRecurring,
isSettlementLoading,
showActions = true,
showDateGroups = true,
}: TransactionsMobileListProps) {
const groups = data.reduce<
Array<{ date: string; label: string; items: TransactionItem[] }>
>((acc, item) => {
const date = item.purchaseDate?.slice(0, 10) ?? "";
const existingGroup = acc.find((group) => group.date === date);
if (existingGroup) {
existingGroup.items.push(item);
return acc;
}
acc.push({
date,
label: formatDateGroupLabel(item.purchaseDate),
items: [item],
});
return acc;
}, []);
if (!showDateGroups) {
return (
<div className="space-y-3 md:hidden">
{data.map((item) => (
<TransactionMobileCard
key={item.id}
item={item}
currentUserId={currentUserId}
onEdit={onEdit}
onCopy={onCopy}
onImport={onImport}
onConfirmDelete={onConfirmDelete}
onViewDetails={onViewDetails}
onRefund={onRefund}
onToggleSettlement={onToggleSettlement}
onAnticipate={onAnticipate}
onViewAnticipationHistory={onViewAnticipationHistory}
onConvertToInstallment={onConvertToInstallment}
onConvertToRecurring={onConvertToRecurring}
isSettlementLoading={isSettlementLoading}
showActions={showActions}
showDate
/>
))}
</div>
);
}
return (
<div className="space-y-3 md:hidden">
{data.map((item) => (
<TransactionMobileCard
key={item.id}
item={item}
currentUserId={currentUserId}
onEdit={onEdit}
onCopy={onCopy}
onImport={onImport}
onConfirmDelete={onConfirmDelete}
onViewDetails={onViewDetails}
onRefund={onRefund}
onToggleSettlement={onToggleSettlement}
onAnticipate={onAnticipate}
onViewAnticipationHistory={onViewAnticipationHistory}
onConvertToInstallment={onConvertToInstallment}
onConvertToRecurring={onConvertToRecurring}
isSettlementLoading={isSettlementLoading}
showActions={showActions}
/>
<div className="space-y-4 md:hidden">
{groups.map((group, groupIndex) => (
<section
key={`${group.date || group.label}-${groupIndex}`}
className="space-y-2"
>
<div className="rounded-md border bg-muted/60 px-3 py-1.5 text-xs font-semibold tracking-wide text-muted-foreground">
{group.label}
</div>
<div className="space-y-3">
{group.items.map((item) => (
<TransactionMobileCard
key={item.id}
item={item}
currentUserId={currentUserId}
onEdit={onEdit}
onCopy={onCopy}
onImport={onImport}
onConfirmDelete={onConfirmDelete}
onViewDetails={onViewDetails}
onRefund={onRefund}
onToggleSettlement={onToggleSettlement}
onAnticipate={onAnticipate}
onViewAnticipationHistory={onViewAnticipationHistory}
onConvertToInstallment={onConvertToInstallment}
onConvertToRecurring={onConvertToRecurring}
isSettlementLoading={isSettlementLoading}
showActions={showActions}
/>
))}
</div>
</section>
))}
</div>
);
@@ -90,6 +150,7 @@ export function TransactionsMobileList({
type TransactionMobileCardProps = Omit<TransactionsMobileListProps, "data"> & {
item: TransactionItem;
showDate?: boolean;
};
function TransactionMobileCard({
@@ -108,6 +169,7 @@ function TransactionMobileCard({
onConvertToRecurring,
isSettlementLoading,
showActions = true,
showDate = false,
}: TransactionMobileCardProps) {
const installmentBadge =
item.currentInstallment && item.installmentCount
@@ -156,10 +218,12 @@ function TransactionMobileCard({
{item.name}
</h3>
<div className="mt-1 flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground">
<span className="inline-flex items-center gap-1">
<RiCalendarEventLine className="size-3.5" aria-hidden />
{formatDate(item.purchaseDate)}
</span>
{showDate ? (
<span className="inline-flex items-center gap-1">
<RiCalendarEventLine className="size-3.5" aria-hidden />
{formatDate(item.purchaseDate)}
</span>
) : null}
{dueDateLabel ? (
<span className="font-medium text-primary">
{dueDateLabel}

View File

@@ -9,13 +9,14 @@ import {
getCoreRowModel,
getPaginationRowModel,
getSortedRowModel,
type Row,
type RowSelectionState,
type SortingState,
useReactTable,
type VisibilityState,
} from "@tanstack/react-table";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { type ReactNode, useMemo, useState } from "react";
import { Fragment, type ReactNode, useMemo, useState } from "react";
import type {
TransactionsExportContext,
TransactionsPaginationState,
@@ -37,6 +38,7 @@ import {
TooltipProvider,
TooltipTrigger,
} from "@/shared/components/ui/tooltip";
import { formatDateGroupLabel } from "@/shared/utils/date";
import { cn } from "@/shared/utils/ui";
import { TransactionsExport } from "../transactions-export";
import type {
@@ -79,6 +81,7 @@ type TransactionsTableProps = {
isSettlementLoading?: (id: string) => boolean;
showActions?: boolean;
showFilters?: boolean;
groupTransactionsByDate?: boolean;
};
export function TransactionsTable({
@@ -110,6 +113,7 @@ export function TransactionsTable({
isSettlementLoading,
showActions = true,
showFilters = true,
groupTransactionsByDate = true,
}: TransactionsTableProps) {
const router = useRouter();
const pathname = usePathname();
@@ -145,12 +149,14 @@ export function TransactionsTable({
onViewAnticipationHistory,
isSettlementLoading: isSettlementLoading ?? (() => false),
showActions,
showDateGroups: groupTransactionsByDate,
columnOrder: columnOrderPreference,
}),
[
currentUserId,
noteAsColumn,
columnOrderPreference,
groupTransactionsByDate,
onEdit,
onCopy,
onImport,
@@ -191,6 +197,24 @@ export function TransactionsTable({
const rowModel = table.getRowModel();
const hasRows = rowModel.rows.length > 0;
const groupedRows = rowModel.rows.reduce<
Array<{ date: string; label: string; rows: Row<TransactionItem>[] }>
>((acc, row) => {
const date = row.original.purchaseDate?.slice(0, 10) ?? "";
const existingGroup = acc.find((group) => group.date === date);
if (existingGroup) {
existingGroup.rows.push(row);
return acc;
}
acc.push({
date,
label: formatDateGroupLabel(row.original.purchaseDate),
rows: [row],
});
return acc;
}, []);
const visibleColumnCount = table.getVisibleLeafColumns().length;
const totalRows = isServerPaginated
? (serverPagination?.totalItems ?? 0)
: table.getCoreRowModel().rows.length;
@@ -275,6 +299,25 @@ export function TransactionsTable({
const showTopControls =
Boolean(createSlot) || Boolean(onMassAdd) || showFilters;
const renderTransactionRow = (row: Row<TransactionItem>) => (
<TableRow
key={row.id}
className={cn(
row.original.paymentMethod === "Boleto" &&
row.original.dueDate &&
!row.original.isSettled &&
new Date(row.original.dueDate) < new Date()
? "bg-destructive/3 hover:bg-destructive/5"
: undefined,
)}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
);
return (
<TooltipProvider>
@@ -366,7 +409,7 @@ export function TransactionsTable({
) : null}
<Card className="py-2">
<CardContent className="px-2 py-4 sm:px-4">
<CardContent className="px-2 sm:px-4">
{hasRows ? (
<>
<TransactionsMobileList
@@ -383,6 +426,7 @@ export function TransactionsTable({
onViewAnticipationHistory={onViewAnticipationHistory}
isSettlementLoading={isSettlementLoading ?? (() => false)}
showActions={showActions}
showDateGroups={groupTransactionsByDate}
/>
<div className="hidden overflow-x-auto md:block">
@@ -407,28 +451,23 @@ export function TransactionsTable({
))}
</TableHeader>
<TableBody>
{rowModel.rows.map((row) => (
<TableRow
key={row.id}
className={cn(
row.original.paymentMethod === "Boleto" &&
row.original.dueDate &&
!row.original.isSettled &&
new Date(row.original.dueDate) < new Date()
? "bg-destructive/3 hover:bg-destructive/5"
: undefined,
)}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</TableCell>
))}
</TableRow>
))}
{groupTransactionsByDate
? groupedRows.map((group, groupIndex) => (
<Fragment
key={`${group.date || group.label}-${groupIndex}`}
>
<TableRow className="border-y bg-muted/40 hover:bg-muted/60">
<TableCell
colSpan={visibleColumnCount}
className="h-9 px-3 py-2 text-xs font-semibold text-muted-foreground"
>
{group.label}
</TableCell>
</TableRow>
{group.rows.map(renderTransactionRow)}
</Fragment>
))
: rowModel.rows.map(renderTransactionRow)}
</TableBody>
</Table>
</div>

View File

@@ -0,0 +1,24 @@
import "server-only";
import { createHash } from "node:crypto";
import {
buildOfxFingerprintPayload,
type OfxIdentityRow,
type OfxImportDestination,
} from "@/shared/lib/import/ofx-identity";
type CreateOfxImportFingerprintInput = {
source: string;
accountNumber: string | null;
destination: OfxImportDestination;
row: OfxIdentityRow;
};
export function createOfxImportFingerprint(
input: CreateOfxImportFingerprintInput,
): string | null {
const payload = buildOfxFingerprintPayload(input);
if (!payload) return null;
return createHash("sha256").update(payload).digest("hex");
}

View File

@@ -6,6 +6,7 @@ import {
ilike,
inArray,
isNotNull,
isNull,
lte,
or,
sql,
@@ -384,6 +385,7 @@ export const buildTransactionWhere = ({
cardId,
accountId,
payerId,
hideAnticipatedInstallments = false,
}: {
userId: string;
period: string;
@@ -392,6 +394,7 @@ export const buildTransactionWhere = ({
cardId?: string;
accountId?: string;
payerId?: string;
hideAnticipatedInstallments?: boolean;
}): SQL[] => {
const where: SQL[] = [eq(transactions.userId, userId)];
@@ -421,6 +424,15 @@ export const buildTransactionWhere = ({
where.push(eq(transactions.payerId, payerId));
}
if (hideAnticipatedInstallments) {
where.push(
or(
isNull(transactions.isAnticipated),
eq(transactions.isAnticipated, false),
) as SQL,
);
}
if (cardId) {
where.push(eq(transactions.cardId, cardId));
}

View File

@@ -102,7 +102,7 @@ export function EstablishmentLogoPicker({
}
return (
<Popover open={open} onOpenChange={onOpenChange}>
<Popover modal open={open} onOpenChange={onOpenChange}>
<PopoverTrigger asChild>{children}</PopoverTrigger>
<PopoverContent className="w-80 p-3" align="start" side="bottom">
<p className="mb-2 text-muted-foreground text-xs">

View File

@@ -0,0 +1,38 @@
"use client";
import { RiErrorWarningLine } from "@remixicon/react";
import { catchError, type ErrorInfo } from "next/error";
import { EmptyState } from "@/shared/components/feedback/empty-state";
import { Button } from "@/shared/components/ui/button";
type ContentErrorBoundaryProps = {
title: string;
description: string;
};
function ContentErrorFallback(
{ title, description }: ContentErrorBoundaryProps,
{ retry }: ErrorInfo,
) {
return (
<div
role="alert"
className="flex min-h-64 w-full items-center justify-center"
>
<EmptyState
title={title}
description={description}
media={<RiErrorWarningLine className="size-5 text-destructive" />}
mediaVariant="icon"
className="min-h-64 border border-dashed"
>
<Button type="button" variant="outline" onClick={retry}>
Tentar novamente
</Button>
</EmptyState>
</div>
);
}
export const ContentErrorBoundary =
catchError<ContentErrorBoundaryProps>(ContentErrorFallback);

View File

@@ -93,6 +93,7 @@ export default function MonthNavigation() {
<Popover open={isPickerOpen} onOpenChange={setIsPickerOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
disabled={isPending}

View File

@@ -44,9 +44,10 @@ export function PeriodPicker({
};
return (
<Popover open={open} onOpenChange={setOpen}>
<Popover modal open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant={variant}
size={size}
disabled={disabled}

View File

@@ -197,6 +197,7 @@ function CalendarDayButton({
return (
<Button
ref={ref}
type="button"
variant="ghost"
size="icon"
data-day={day.date.toLocaleDateString()}

View File

@@ -13,7 +13,7 @@ function Checkbox({
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary data-[state=indeterminate]:bg-primary data-[state=indeterminate]:text-primary-foreground data-[state=indeterminate]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-lg border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
"peer border-input dark:bg-input/40 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary data-[state=indeterminate]:bg-primary data-[state=indeterminate]:text-primary-foreground data-[state=indeterminate]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-3.5 shrink-0 rounded border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}

View File

@@ -148,7 +148,7 @@ export function DatePicker({
required={required}
disabled={disabled}
/>
<Popover open={open} onOpenChange={setOpen}>
<Popover modal open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
type="button"

View File

@@ -128,6 +128,7 @@ function MonthCal({
</div>
<div className="space-x-1 flex items-center">
<button
type="button"
onClick={() => {
setMenuYear(menuYear - 1);
if (onYearBackward) onYearBackward();
@@ -140,6 +141,7 @@ function MonthCal({
<RiArrowLeftSFill className="opacity-50 size-4" />
</button>
<button
type="button"
onClick={() => {
setMenuYear(menuYear + 1);
if (onYearForward) onYearForward();
@@ -165,6 +167,7 @@ function MonthCal({
className="h-10 w-1/4 text-center text-sm p-0 relative [&:has([aria-selected].day-range-end)]:rounded-r-md [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected])]:bg-accent first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20"
>
<button
type="button"
onClick={() => {
setMonth(m.number);
setYear(menuYear);

View File

@@ -0,0 +1,69 @@
import { formatDecimalForDbRequired } from "@/shared/utils/currency";
export type OfxImportDestination = {
type: "account" | "card";
id: string;
};
export type OfxIdentityRow = {
externalId: string | null;
externalIdOccurrence: number;
date: string;
amount: number;
transactionType: "income" | "expense";
sourceDescription: string;
};
type OfxIdentityInput = {
source: string;
accountNumber: string | null;
destination: OfxImportDestination;
row: OfxIdentityRow;
};
export function normalizeOfxIdentityText(value: string): string {
return value.normalize("NFKC").trim().replace(/\s+/g, " ").toLowerCase();
}
export function buildOfxOccurrenceKey(
row: Omit<OfxIdentityRow, "externalIdOccurrence">,
): string | null {
if (!row.externalId) return null;
const signedAmount =
row.transactionType === "expense" ? -row.amount : row.amount;
return JSON.stringify([
row.externalId.trim(),
row.date,
formatDecimalForDbRequired(signedAmount),
row.transactionType,
normalizeOfxIdentityText(row.sourceDescription),
]);
}
export function buildOfxFingerprintPayload({
source,
accountNumber,
destination,
row,
}: OfxIdentityInput): string | null {
if (!row.externalId) return null;
const signedAmount =
row.transactionType === "expense" ? -row.amount : row.amount;
return JSON.stringify([
"openmonetis-ofx-v1",
normalizeOfxIdentityText(source),
normalizeOfxIdentityText(accountNumber ?? ""),
destination.type,
destination.id,
row.externalId.trim(),
row.date,
formatDecimalForDbRequired(signedAmount),
row.transactionType,
normalizeOfxIdentityText(row.sourceDescription),
row.externalIdOccurrence,
]);
}

View File

@@ -1,3 +1,4 @@
import { buildOfxOccurrenceKey } from "./ofx-identity";
import type { ImportedTransaction, ImportStatement } from "./types";
// Extrai o valor de uma tag leaf do OFX SGML: <TAG>valor
@@ -32,6 +33,7 @@ export function parseOfx(content: string): ImportStatement {
// Transações
const blocks = xml.match(/<STMTTRN>[\s\S]*?<\/STMTTRN>/g) ?? [];
const occurrenceCounts = new Map<string, number>();
const transactions: ImportedTransaction[] = blocks.map((block) => {
const trnType = getField(block, "TRNTYPE") ?? "DEBIT";
const dtPosted = getField(block, "DTPOSTED") ?? "";
@@ -41,16 +43,30 @@ export function parseOfx(content: string): ImportStatement {
const name = getField(block, "NAME");
const amount = Number.parseFloat(trnAmt.replace(",", "."));
const transactionType =
const transactionType: ImportedTransaction["transactionType"] =
amount > 0 || trnType === "CREDIT" ? "income" : "expense";
return {
const description = memo ?? name ?? "";
const transaction = {
externalId: fitId,
date: parseOfxDate(dtPosted),
amount: Math.abs(amount),
description: memo ?? name ?? "",
description,
sourceDescription: description,
transactionType,
};
const occurrenceKey = buildOfxOccurrenceKey(transaction);
const externalIdOccurrence = occurrenceKey
? (occurrenceCounts.get(occurrenceKey) ?? 0)
: 0;
if (occurrenceKey) {
occurrenceCounts.set(occurrenceKey, externalIdOccurrence + 1);
}
return {
...transaction,
externalIdOccurrence,
};
});
const isCreditCard = xml.includes("<CREDITCARDMSGSRSV1>");

View File

@@ -1,8 +1,10 @@
export type ImportedTransaction = {
externalId: string | null; // FITID do OFX
externalIdOccurrence: number; // posição entre registros OFX idênticos
date: string; // YYYY-MM-DD
amount: number; // positivo = receita, negativo = despesa
description: string; // MEMO ou NAME
sourceDescription: string; // descrição original, preservada para deduplicação
transactionType: "income" | "expense";
categoryRaw?: string | null;
};

View File

@@ -105,9 +105,11 @@ export async function parseXls(buffer: ArrayBuffer): Promise<ImportStatement> {
transactions.push({
externalId: null,
externalIdOccurrence: 0,
date,
amount,
description,
sourceDescription: description,
transactionType,
categoryRaw,
});

View File

@@ -331,6 +331,45 @@ export function formatDate(value: string | Date | null | undefined): string {
.replace(" de", "");
}
/**
* Formats a date-only value as a compact group label.
* @example
* formatDateGroupLabel("2026-06-26") // "SEX, 26 JUN 2026"
*/
export function formatDateGroupLabel(
value: string | Date | null | undefined,
): string {
const dateString = toDateOnlyString(value);
if (!dateString) {
return "—";
}
const parsed = parseUtcDateString(dateString);
if (!parsed) {
return "—";
}
const parts = new Intl.DateTimeFormat("pt-BR", {
weekday: "short",
day: "2-digit",
month: "short",
year: "numeric",
timeZone: "UTC",
}).formatToParts(parsed);
const weekday = parts.find((part) => part.type === "weekday")?.value;
const day = parts.find((part) => part.type === "day")?.value;
const month = parts.find((part) => part.type === "month")?.value;
const year = parts.find((part) => part.type === "year")?.value;
if (!weekday || !day || !month || !year) {
return "—";
}
return `${weekday.replace(".", "").toUpperCase()}, ${day} ${month
.replace(".", "")
.toUpperCase()} ${year}`;
}
/**
* Formats a date-only value (YYYY-MM-DD) using UTC to preserve the civil day
*/