diff --git a/src/features/transactions/actions/import-action.ts b/src/features/transactions/actions/import-action.ts
index 97b4737..e75202b 100644
--- a/src/features/transactions/actions/import-action.ts
+++ b/src/features/transactions/actions/import-action.ts
@@ -4,9 +4,10 @@ import { and, eq, inArray } from "drizzle-orm";
import { z } from "zod";
import { transactions } from "@/db/schema";
import {
+ fetchOwnedCategoryIds,
+ fetchOwnedPayerIds,
validateCartaoOwnership,
validateContaOwnership,
- validatePayerOwnership,
} from "@/features/transactions/actions/core";
import { revalidateForEntity } from "@/shared/lib/actions/helpers";
import { getUserId } from "@/shared/lib/auth/server";
@@ -21,6 +22,7 @@ const importRowSchema = z.object({
description: z.string().min(1, "Descrição obrigatória."),
transactionType: z.enum(["income", "expense"]),
categoryId: uuidSchema("Category").nullable().optional(),
+ payerId: uuidSchema("Payer").nullable().optional(),
});
const importSchema = z.object({
@@ -76,14 +78,34 @@ export async function importTransactionsAction(
const { rows, payerId, accountId, cardId, paymentMethod, invoicePeriod } =
parsed.data;
- // Valida ownership
- const [payerOk, accountOk, cardOk] = await Promise.all([
- validatePayerOwnership(userId, payerId),
- validateContaOwnership(userId, accountId),
- validateCartaoOwnership(userId, cardId),
- ]);
+ const payerIdsByRow = rows.map((row) => row.payerId ?? payerId ?? null);
+
+ if (payerIdsByRow.some((id) => !id)) {
+ return { success: false, error: "Pessoa obrigatória." };
+ }
+
+ // Valida ownership
+ const [ownedPayerIds, ownedCategoryIds, accountOk, cardOk] =
+ await Promise.all([
+ fetchOwnedPayerIds(userId, payerIdsByRow),
+ fetchOwnedCategoryIds(
+ userId,
+ rows.map((row) => row.categoryId),
+ ),
+ validateContaOwnership(userId, accountId),
+ validateCartaoOwnership(userId, cardId),
+ ]);
+
+ if (payerIdsByRow.some((id) => id && !ownedPayerIds.has(id))) {
+ return { success: false, error: "Pessoa não encontrada." };
+ }
+
+ if (
+ rows.some((row) => row.categoryId && !ownedCategoryIds.has(row.categoryId))
+ ) {
+ return { success: false, error: "Categoria não encontrada." };
+ }
- if (!payerOk) return { success: false, error: "Pessoa não encontrada." };
if (!accountOk) return { success: false, error: "Conta não encontrada." };
if (!cardOk) return { success: false, error: "Cartão não encontrado." };
@@ -96,7 +118,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) => {
+ const records = rows.map((row, index) => {
const purchaseDate = parseLocalDateString(row.date);
const period =
invoicePeriod ??
@@ -115,7 +137,7 @@ export async function importTransactionsAction(
period,
isSettled,
userId,
- payerId: payerId ?? null,
+ payerId: payerIdsByRow[index],
accountId: accountId ?? null,
cardId: cardId ?? null,
categoryId: row.categoryId ?? null,
diff --git a/src/features/transactions/components/import/global-fields.tsx b/src/features/transactions/components/import/global-fields.tsx
index ead1a13..8e25214 100644
--- a/src/features/transactions/components/import/global-fields.tsx
+++ b/src/features/transactions/components/import/global-fields.tsx
@@ -74,16 +74,16 @@ export function GlobalFields({
return (
- Aplicado a todos os lançamentos importados.
+ Aplicado aos lançamentos selecionados.
-
-
+
+
-
+
-
+
{isCard && (
-
+
(null);
const [invoicePeriod, setInvoicePeriod] = useState(null);
- const handleParsed = useCallback(async (stmt: ImportStatement) => {
- setStatement(stmt);
- setIsChecking(true);
+ const categoryGroupById = useMemo(
+ () =>
+ new Map(categoryOptions.map((option) => [option.value, option.group])),
+ [categoryOptions],
+ );
- try {
- const fitIds = stmt.transactions
- .map((t) => t.externalId)
- .filter((id): id is string => id !== null);
+ const isCategoryCompatible = useCallback(
+ (
+ categoryId: string | null,
+ transactionType: ReviewRow["transactionType"],
+ ) =>
+ !categoryId ||
+ categoryGroupById.get(categoryId) ===
+ categoryGroupByTransactionType[transactionType],
+ [categoryGroupById],
+ );
- const [duplicates, categoryMappings] = await Promise.all([
- checkDuplicateFitIds(fitIds).then((ids) => new Set(ids)),
- fetchCategoryMappings(stmt.transactions.map((t) => t.description)),
- ]);
+ const handleParsed = useCallback(
+ async (stmt: ImportStatement) => {
+ setStatement(stmt);
+ setIsChecking(true);
- setRows(
- stmt.transactions.map((t) => ({
- ...t,
- isDuplicate: t.externalId ? duplicates.has(t.externalId) : false,
- selected: t.externalId ? !duplicates.has(t.externalId) : true,
- categoryId:
- categoryMappings[normalizeDescriptionKey(t.description)] ?? null,
- })),
- );
- } finally {
- setIsChecking(false);
- }
- }, []);
+ 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)),
+ fetchCategoryMappings(stmt.transactions.map((t) => t.description)),
+ ]);
+
+ setRows(
+ stmt.transactions.map((t) => {
+ const mappedCategoryId =
+ categoryMappings[normalizeDescriptionKey(t.description)] ?? null;
+
+ return {
+ ...t,
+ isDuplicate: t.externalId ? duplicates.has(t.externalId) : false,
+ selected: t.externalId ? !duplicates.has(t.externalId) : true,
+ payerId,
+ categoryId: isCategoryCompatible(
+ mappedCategoryId,
+ t.transactionType,
+ )
+ ? mappedCategoryId
+ : null,
+ };
+ }),
+ );
+ } finally {
+ setIsChecking(false);
+ }
+ },
+ [isCategoryCompatible, payerId],
+ );
// Pré-seleciona cartão ou conta com base no tipo detectado no OFX
useEffect(() => {
@@ -121,7 +156,17 @@ export function ImportPage({
const handleCategoryChange = (index: number, categoryId: string | null) => {
setRows((prev) =>
- prev.map((r, i) => (i === index ? { ...r, categoryId } : r)),
+ prev.map((r, i) =>
+ i === index && isCategoryCompatible(categoryId, r.transactionType)
+ ? { ...r, categoryId }
+ : r,
+ ),
+ );
+ };
+
+ const handlePayerChange = (index: number, payerId: string | null) => {
+ setRows((prev) =>
+ prev.map((r, i) => (i === index ? { ...r, payerId } : r)),
);
};
@@ -150,17 +195,36 @@ export function ImportPage({
};
const handleBulkCategoryChange = (categoryId: string) => {
- setRows((prev) => prev.map((r) => (r.selected ? { ...r, categoryId } : r)));
+ setRows((prev) =>
+ prev.map((r) =>
+ r.selected && isCategoryCompatible(categoryId, r.transactionType)
+ ? { ...r, categoryId }
+ : r,
+ ),
+ );
+ };
+
+ const handleBulkPayerChange = (nextPayerId: string | null) => {
+ setPayerId(nextPayerId);
+ setRows((prev) =>
+ prev.map((r) => (r.selected ? { ...r, payerId: nextPayerId } : r)),
+ );
};
const isCard = accountCardValue?.startsWith("card:") ?? false;
- const { selectedRows, duplicateCount, uncategorizedCount } = useMemo(() => {
+ const {
+ selectedRows,
+ duplicateCount,
+ uncategorizedCount,
+ withoutPayerCount,
+ } = useMemo(() => {
const selected = rows.filter((r) => r.selected);
return {
selectedRows: selected,
duplicateCount: rows.filter((r) => r.isDuplicate).length,
uncategorizedCount: selected.filter((r) => !r.categoryId).length,
+ withoutPayerCount: selected.filter((r) => !r.payerId).length,
};
}, [rows]);
@@ -168,6 +232,7 @@ export function ImportPage({
selectedRows.length > 0 &&
!!accountCardValue &&
uncategorizedCount === 0 &&
+ withoutPayerCount === 0 &&
(!isCard || !!invoicePeriod) &&
!isPending;
@@ -191,6 +256,7 @@ export function ImportPage({
description: r.description,
transactionType: r.transactionType,
categoryId: r.categoryId,
+ payerId: r.payerId,
})),
payerId,
accountId,
@@ -280,6 +346,7 @@ export function ImportPage({
selected={selectedRows.length}
duplicates={duplicateCount}
uncategorized={uncategorizedCount}
+ withoutPayer={withoutPayerCount}
/>
{/* Sticky footer */}
-
+