mirror of
https://github.com/felipegcoutinho/openmonetis.git
synced 2026-05-09 11:01:45 +00:00
feat: importação de extratos OFX/XLS com memória de categorias
Adiciona fluxo completo de importação de extratos bancários: - Upload e parsing de arquivos OFX e XLS/XLSX - Tela de revisão com virtualização (@tanstack/react-virtual) - Detecção automática de categoria por histórico de uso - Deduplicação por FITID (OFX) e importBatchId - Tabela `import_category_mappings` para persistir mapeamentos - Botão de acesso ao fluxo na tabela de transações Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
176
src/features/transactions/actions/import-action.ts
Normal file
176
src/features/transactions/actions/import-action.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
"use server";
|
||||
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { transactions } from "@/db/schema";
|
||||
import {
|
||||
validateCartaoOwnership,
|
||||
validateContaOwnership,
|
||||
validatePagadorOwnership,
|
||||
} from "@/features/transactions/actions/core";
|
||||
import { revalidateForEntity } from "@/shared/lib/actions/helpers";
|
||||
import { getUserId } from "@/shared/lib/auth/server";
|
||||
import { db } from "@/shared/lib/db";
|
||||
import { uuidSchema } from "@/shared/lib/schemas/common";
|
||||
import { parseLocalDateString } from "@/shared/utils/date";
|
||||
|
||||
const importRowSchema = z.object({
|
||||
externalId: z.string().nullable(),
|
||||
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"]),
|
||||
categoryId: uuidSchema("Category").nullable().optional(),
|
||||
});
|
||||
|
||||
const importSchema = z.object({
|
||||
rows: z.array(importRowSchema).min(1, "Selecione ao menos uma transação."),
|
||||
payerId: uuidSchema("Payer").nullable().optional(),
|
||||
accountId: uuidSchema("FinancialAccount").nullable().optional(),
|
||||
cardId: uuidSchema("Cartão").nullable().optional(),
|
||||
paymentMethod: z.string().min(1),
|
||||
invoicePeriod: z.string().regex(/^\d{4}-\d{2}$/, "Período inválido.").nullable().optional(),
|
||||
});
|
||||
|
||||
export type ImportRow = z.infer<typeof importRowSchema>;
|
||||
export type ImportInput = z.infer<typeof importSchema>;
|
||||
|
||||
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 [];
|
||||
|
||||
const rows = await db
|
||||
.select({ ofxFitId: transactions.ofxFitId })
|
||||
.from(transactions)
|
||||
.where(
|
||||
and(
|
||||
eq(transactions.userId, userId),
|
||||
inArray(transactions.ofxFitId, ids),
|
||||
),
|
||||
);
|
||||
|
||||
return rows.map((r) => r.ofxFitId).filter((id): id is string => id !== null);
|
||||
}
|
||||
|
||||
export async function importTransactionsAction(
|
||||
input: ImportInput,
|
||||
): Promise<ImportResult> {
|
||||
const userId = await getUserId();
|
||||
const parsed = importSchema.safeParse(input);
|
||||
|
||||
if (!parsed.success) {
|
||||
return { success: false, error: parsed.error.issues[0]?.message ?? "Dados inválidos." };
|
||||
}
|
||||
|
||||
const { rows, payerId, accountId, cardId, paymentMethod, invoicePeriod } = parsed.data;
|
||||
|
||||
// Valida ownership
|
||||
const [payerOk, accountOk, cardOk] = await Promise.all([
|
||||
validatePagadorOwnership(userId, payerId),
|
||||
validateContaOwnership(userId, accountId),
|
||||
validateCartaoOwnership(userId, cardId),
|
||||
]);
|
||||
|
||||
if (!payerOk) return { success: false, error: "Pagador não encontrado." };
|
||||
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 importBatchId = crypto.randomUUID();
|
||||
|
||||
// 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) => {
|
||||
const purchaseDate = parseLocalDateString(row.date);
|
||||
const period = invoicePeriod ?? `${purchaseDate.getFullYear()}-${String(purchaseDate.getMonth() + 1).padStart(2, "0")}`;
|
||||
|
||||
return {
|
||||
name: row.description,
|
||||
transactionType: row.transactionType === "income" ? "Receita" : "Despesa",
|
||||
condition: "À vista" as const,
|
||||
paymentMethod,
|
||||
amount: (row.transactionType === "expense" ? -row.amount : row.amount).toFixed(2),
|
||||
purchaseDate,
|
||||
period,
|
||||
isSettled,
|
||||
userId,
|
||||
payerId: payerId ?? null,
|
||||
accountId: accountId ?? null,
|
||||
cardId: cardId ?? null,
|
||||
categoryId: row.categoryId ?? null,
|
||||
ofxFitId: row.externalId,
|
||||
importBatchId,
|
||||
};
|
||||
});
|
||||
|
||||
// onConflictDoNothing usa o uniqueIndex (userId, ofxFitId) WHERE ofxFitId IS NOT NULL
|
||||
// eliminando o SELECT prévio de checkDuplicateFitIds
|
||||
const inserted = await db
|
||||
.insert(transactions)
|
||||
.values(records)
|
||||
.onConflictDoNothing()
|
||||
.returning({ id: transactions.id });
|
||||
|
||||
await revalidateForEntity("transactions", userId);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
imported: inserted.length,
|
||||
skipped: records.length - inserted.length,
|
||||
importBatchId,
|
||||
};
|
||||
}
|
||||
|
||||
export async function deleteTransactionByFitId(
|
||||
fitId: string,
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
if (!fitId) return { success: false, error: "FITID inválido." };
|
||||
|
||||
const userId = await getUserId();
|
||||
|
||||
await db
|
||||
.delete(transactions)
|
||||
.where(
|
||||
and(
|
||||
eq(transactions.userId, userId),
|
||||
eq(transactions.ofxFitId, fitId),
|
||||
),
|
||||
);
|
||||
|
||||
await revalidateForEntity("transactions", userId);
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function undoImportAction(
|
||||
importBatchId: string,
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
if (!importBatchId) return { success: false, error: "Batch inválido." };
|
||||
|
||||
const userId = await getUserId();
|
||||
|
||||
await db
|
||||
.delete(transactions)
|
||||
.where(
|
||||
and(
|
||||
eq(transactions.userId, userId),
|
||||
eq(transactions.importBatchId, importBatchId),
|
||||
),
|
||||
);
|
||||
|
||||
await revalidateForEntity("transactions", userId);
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
Reference in New Issue
Block a user