refactor: pagina transações e modulariza ações

This commit is contained in:
Felipe Coutinho
2026-03-20 18:39:49 +00:00
parent 41fd8226cb
commit 3c31ee5d90
20 changed files with 4261 additions and 1782 deletions

View File

@@ -5,7 +5,7 @@ import { AccountStatementCard } from "@/features/accounts/components/account-sta
import type { Account } from "@/features/accounts/components/types";
import {
fetchAccountData,
fetchAccountLancamentos,
fetchAccountLancamentosPage,
fetchAccountSummary,
} from "@/features/accounts/statement-queries";
import { fetchUserPreferences } from "@/features/settings/queries";
@@ -19,6 +19,7 @@ import {
getSingleParam,
mapTransactionsData,
type ResolvedSearchParams,
resolveTransactionPagination,
} from "@/features/transactions/page-helpers";
import {
fetchRecentEstablishments,
@@ -53,6 +54,7 @@ export default async function Page({ params, searchParams }: PageProps) {
} = parsePeriodParam(periodoParamRaw);
const searchFilters = extractTransactionSearchFilters(resolvedSearchParams);
const pagination = resolveTransactionPagination(resolvedSearchParams);
const account = await fetchAccountData(userId, accountId);
@@ -84,9 +86,12 @@ export default async function Page({ params, searchParams }: PageProps) {
accountId: account.id,
});
const transactionRows = await fetchAccountLancamentos(filters);
const transactionsPage = await fetchAccountLancamentosPage(
filters,
pagination,
);
const transactionData = mapTransactionsData(transactionRows);
const transactionData = mapTransactionsData(transactionsPage.rows);
const { openingBalance, currentBalance, totalIncomes, totalExpenses } =
accountSummary;
@@ -169,6 +174,19 @@ export default async function Page({ params, searchParams }: PageProps) {
accountCardFilterOptions={accountCardFilterOptions}
selectedPeriod={selectedPeriod}
estabelecimentos={estabelecimentos}
pagination={{
page: transactionsPage.page,
pageSize: transactionsPage.pageSize,
totalItems: transactionsPage.totalItems,
totalPages: transactionsPage.totalPages,
}}
exportContext={{
source: "account-statement",
period: selectedPeriod,
filters: searchFilters,
accountId: account.id,
settledOnly: true,
}}
allowCreate={false}
noteAsColumn={userPreferences?.statementNoteAsColumn ?? false}
columnOrder={userPreferences?.transactionsColumnOrder ?? null}

View File

@@ -9,11 +9,12 @@ import {
getSingleParam,
mapTransactionsData,
type ResolvedSearchParams,
resolveTransactionPagination,
} from "@/features/transactions/page-helpers";
import {
fetchRecentEstablishments,
fetchTransactionFilterSources,
fetchTransactions,
fetchTransactionsPage,
} from "@/features/transactions/queries";
import MonthNavigation from "@/shared/components/month-picker/month-navigation";
import { getUserId } from "@/shared/lib/auth/server";
@@ -33,6 +34,7 @@ export default async function Page({ searchParams }: PageProps) {
const { period: selectedPeriod } = parsePeriodParam(periodoParamRaw);
const searchFilters = extractTransactionSearchFilters(resolvedSearchParams);
const pagination = resolveTransactionPagination(resolvedSearchParams);
const [filterSources, userPreferences] = await Promise.all([
fetchTransactionFilterSources(userId),
@@ -49,11 +51,11 @@ export default async function Page({ searchParams }: PageProps) {
slugMaps,
});
const [transactionRows, estabelecimentos] = await Promise.all([
fetchTransactions(filters),
const [transactionsPage, estabelecimentos] = await Promise.all([
fetchTransactionsPage(filters, pagination),
fetchRecentEstablishments(userId),
]);
const transactionData = mapTransactionsData(transactionRows);
const transactionData = mapTransactionsData(transactionsPage.rows);
const {
payerOptions,
@@ -87,6 +89,17 @@ export default async function Page({ searchParams }: PageProps) {
accountCardFilterOptions={accountCardFilterOptions}
selectedPeriod={selectedPeriod}
estabelecimentos={estabelecimentos}
pagination={{
page: transactionsPage.page,
pageSize: transactionsPage.pageSize,
totalItems: transactionsPage.totalItems,
totalPages: transactionsPage.totalPages,
}}
exportContext={{
source: "transactions",
period: selectedPeriod,
filters: searchFilters,
}}
noteAsColumn={userPreferences?.statementNoteAsColumn ?? false}
columnOrder={userPreferences?.transactionsColumnOrder ?? null}
/>

View File

@@ -1,8 +1,12 @@
import { and, desc, eq, lt, type SQL, sql } from "drizzle-orm";
import { financialAccounts, payers, transactions } from "@/db/schema";
import { and, eq, lt, type SQL, sql } from "drizzle-orm";
import { financialAccounts, transactions } from "@/db/schema";
import {
fetchTransactionsPageWithRelations,
fetchTransactionsWithRelations,
} from "@/features/transactions/queries";
import { INITIAL_BALANCE_NOTE } from "@/shared/lib/accounts/constants";
import { db } from "@/shared/lib/db";
import { PAYER_ROLE_ADMIN } from "@/shared/lib/payers/constants";
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
export type AccountSummaryData = {
openingBalance: number;
@@ -36,6 +40,22 @@ export async function fetchAccountSummary(
accountId: string,
selectedPeriod: string,
): Promise<AccountSummaryData> {
const account = await fetchAccountData(userId, accountId);
if (!account) {
throw new Error("Account not found");
}
const adminPayerId = await getAdminPayerId(userId);
if (!adminPayerId) {
const initialBalance = Number(account.initialBalance ?? 0);
return {
openingBalance: initialBalance,
currentBalance: initialBalance,
totalIncomes: 0,
totalExpenses: 0,
};
}
const [periodSummary] = await db
.select({
netAmount: sql<number>`
@@ -75,14 +95,13 @@ export async function fetchAccountSummary(
`,
})
.from(transactions)
.innerJoin(payers, eq(transactions.payerId, payers.id))
.where(
and(
eq(transactions.userId, userId),
eq(transactions.accountId, accountId),
eq(transactions.period, selectedPeriod),
eq(transactions.isSettled, true),
eq(payers.role, PAYER_ROLE_ADMIN),
eq(transactions.payerId, adminPayerId),
),
);
@@ -101,22 +120,16 @@ export async function fetchAccountSummary(
`,
})
.from(transactions)
.innerJoin(payers, eq(transactions.payerId, payers.id))
.where(
and(
eq(transactions.userId, userId),
eq(transactions.accountId, accountId),
lt(transactions.period, selectedPeriod),
eq(transactions.isSettled, true),
eq(payers.role, PAYER_ROLE_ADMIN),
eq(transactions.payerId, adminPayerId),
),
);
const account = await fetchAccountData(userId, accountId);
if (!account) {
throw new Error("Account not found");
}
const initialBalance = Number(account.initialBalance ?? 0);
const previousMovements = Number(previousRow?.previousMovements ?? 0);
const openingBalance = initialBalance + previousMovements;
@@ -137,18 +150,33 @@ export async function fetchAccountLancamentos(
filters: SQL[],
settledOnly = true,
) {
const allFilters = settledOnly
? [...filters, eq(transactions.isSettled, true)]
: filters;
const extraFilters = settledOnly ? [eq(transactions.isSettled, true)] : [];
return db.query.transactions.findMany({
where: and(...allFilters),
with: {
payer: true,
financialAccount: true,
card: true,
category: true,
},
orderBy: desc(transactions.purchaseDate),
return fetchTransactionsWithRelations({
filters,
extraFilters,
excludeInitialBalanceFromIncome: false,
});
}
export async function fetchAccountLancamentosPage(
filters: SQL[],
{
page,
pageSize,
}: {
page: number;
pageSize: number;
},
settledOnly = true,
) {
const extraFilters = settledOnly ? [eq(transactions.isSettled, true)] : [];
return fetchTransactionsPageWithRelations({
filters,
extraFilters,
excludeInitialBalanceFromIncome: false,
page,
pageSize,
});
}

View File

@@ -6,11 +6,8 @@ import {
RiFilePdfLine,
RiFileTextLine,
} from "@remixicon/react";
import jsPDF from "jspdf";
import autoTable from "jspdf-autotable";
import { useState } from "react";
import { toast } from "sonner";
import * as XLSX from "xlsx";
import {
formatPercentageChange,
formatPeriodLabel,
@@ -36,6 +33,17 @@ interface CategoryReportExportProps {
filters: FilterState;
}
const loadXlsx = () => import("xlsx");
const loadPdfDeps = async () => {
const [{ default: jsPDF }, { default: autoTable }] = await Promise.all([
import("jspdf"),
import("jspdf-autotable"),
]);
return { jsPDF, autoTable };
};
export function CategoryReportExport({
data,
filters,
@@ -123,9 +131,10 @@ export function CategoryReportExport({
}
};
const exportToExcel = () => {
const exportToExcel = async () => {
try {
setIsExporting(true);
const XLSX = await loadXlsx();
// Build data array
const headers = [
@@ -197,6 +206,7 @@ export function CategoryReportExport({
const exportToPDF = async () => {
try {
setIsExporting(true);
const { jsPDF, autoTable } = await loadPdfDeps();
// Create PDF
const doc = new jsPDF({ orientation: "landscape" });

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,624 @@
"use server";
import { and, asc, eq, inArray, sql } from "drizzle-orm";
import { transactions } from "@/db/schema";
import {
PAYMENT_METHODS,
TRANSACTION_CONDITIONS,
TRANSACTION_TYPES,
} from "@/features/transactions/constants";
import { handleActionError } from "@/shared/lib/actions/helpers";
import { getUser } from "@/shared/lib/auth/server";
import { db } from "@/shared/lib/db";
import {
buildEntriesByPayer,
sendPayerAutoEmails,
} from "@/shared/lib/payers/notifications";
import type { ActionResult } from "@/shared/lib/types/actions";
import { addMonthsToDate, parseLocalDateString } from "@/shared/utils/date";
import {
centsToDecimalString,
type DeleteBulkInput,
type DeleteMultipleInput,
deleteBulkSchema,
deleteMultipleSchema,
fetchOwnedAccountIds,
fetchOwnedCardIds,
fetchOwnedCategoryIds,
fetchOwnedPayerIds,
type MassAddInput,
massAddSchema,
resolvePeriod,
resolveUserLabel,
revalidate,
type TransactionInsert,
type UpdateBulkInput,
updateBulkSchema,
validateAllOwnership,
} from "./core";
export async function deleteTransactionBulkAction(
input: DeleteBulkInput,
): Promise<ActionResult> {
try {
const user = await getUser();
const data = deleteBulkSchema.parse(input);
const existing = await db.query.transactions.findFirst({
columns: {
id: true,
name: true,
seriesId: true,
period: true,
condition: true,
},
where: and(
eq(transactions.id, data.id),
eq(transactions.userId, user.id),
),
});
if (!existing) {
return { success: false, error: "Lançamento não encontrado." };
}
if (!existing.seriesId) {
return {
success: false,
error: "Este lançamento não faz parte de uma série.",
};
}
if (data.scope === "current") {
await db
.delete(transactions)
.where(
and(eq(transactions.id, data.id), eq(transactions.userId, user.id)),
);
revalidate(user.id);
return { success: true, message: "Lançamento removido com sucesso." };
}
if (data.scope === "future") {
await db
.delete(transactions)
.where(
and(
eq(transactions.seriesId, existing.seriesId),
eq(transactions.userId, user.id),
sql`${transactions.period} >= ${existing.period}`,
),
);
revalidate(user.id);
return {
success: true,
message: "Lançamentos removidos com sucesso.",
};
}
if (data.scope === "all") {
await db
.delete(transactions)
.where(
and(
eq(transactions.seriesId, existing.seriesId),
eq(transactions.userId, user.id),
),
);
revalidate(user.id);
return {
success: true,
message: "Todos os lançamentos da série foram removidos.",
};
}
return { success: false, error: "Escopo de ação inválido." };
} catch (error) {
return handleActionError(error);
}
}
export async function updateTransactionBulkAction(
input: UpdateBulkInput,
): Promise<ActionResult> {
try {
const user = await getUser();
const data = updateBulkSchema.parse(input);
const ownershipError = await validateAllOwnership(user.id, {
payerId: data.payerId,
categoryId: data.categoryId,
accountId: data.accountId,
cardId: data.cardId,
});
if (ownershipError) {
return { success: false, error: ownershipError };
}
const existing = await db.query.transactions.findFirst({
columns: {
id: true,
name: true,
seriesId: true,
period: true,
condition: true,
transactionType: true,
purchaseDate: true,
},
where: and(
eq(transactions.id, data.id),
eq(transactions.userId, user.id),
),
});
if (!existing) {
return { success: false, error: "Lançamento não encontrado." };
}
if (!existing.seriesId) {
return {
success: false,
error: "Este lançamento não faz parte de uma série.",
};
}
const baseUpdatePayload: Record<string, unknown> = {
name: data.name,
categoryId: data.categoryId ?? null,
note: data.note ?? null,
payerId: data.payerId ?? null,
accountId: data.accountId ?? null,
cardId: data.cardId ?? null,
};
if (data.amount !== undefined) {
const amountSign: 1 | -1 =
existing.transactionType === "Despesa" ? -1 : 1;
const amountCents = Math.round(Math.abs(data.amount) * 100);
baseUpdatePayload.amount = centsToDecimalString(amountCents * amountSign);
}
const hasDueDateUpdate = data.dueDate !== undefined;
const hasBoletoPaymentDateUpdate = data.boletoPaymentDate !== undefined;
const baseDueDate =
hasDueDateUpdate && data.dueDate
? parseLocalDateString(data.dueDate)
: hasDueDateUpdate
? null
: undefined;
const baseBoletoPaymentDate =
hasBoletoPaymentDateUpdate && data.boletoPaymentDate
? parseLocalDateString(data.boletoPaymentDate)
: hasBoletoPaymentDateUpdate
? null
: undefined;
const basePurchaseDate = existing.purchaseDate ?? null;
const buildDueDateForRecord = (recordPurchaseDate: Date | null) => {
if (!hasDueDateUpdate) {
return undefined;
}
if (!baseDueDate) {
return null;
}
if (!basePurchaseDate || !recordPurchaseDate) {
return baseDueDate;
}
const monthDiff =
(recordPurchaseDate.getFullYear() - basePurchaseDate.getFullYear()) *
12 +
(recordPurchaseDate.getMonth() - basePurchaseDate.getMonth());
return addMonthsToDate(baseDueDate, monthDiff);
};
const serializeDateKey = (value: Date | null | undefined) => {
if (value === undefined) {
return "undefined";
}
if (value === null) {
return "null";
}
return String(value.getTime());
};
const applyUpdates = async (
records: Array<{ id: string; purchaseDate: Date | null }>,
) => {
if (records.length === 0) {
return;
}
const groupedPayloads = new Map<
string,
{
ids: string[];
payload: Record<string, unknown>;
}
>();
for (const record of records) {
const dueDateForRecord = buildDueDateForRecord(record.purchaseDate);
const perRecordPayload: Record<string, unknown> = {
...baseUpdatePayload,
};
if (dueDateForRecord !== undefined) {
perRecordPayload.dueDate = dueDateForRecord;
}
if (hasBoletoPaymentDateUpdate) {
perRecordPayload.boletoPaymentDate = baseBoletoPaymentDate ?? null;
}
const groupKey = [
serializeDateKey(dueDateForRecord),
serializeDateKey(
hasBoletoPaymentDateUpdate
? (baseBoletoPaymentDate ?? null)
: undefined,
),
].join("|");
const group = groupedPayloads.get(groupKey);
if (group) {
group.ids.push(record.id);
continue;
}
groupedPayloads.set(groupKey, {
ids: [record.id],
payload: perRecordPayload,
});
}
await db.transaction(async (tx: typeof db) => {
for (const group of groupedPayloads.values()) {
await tx
.update(transactions)
.set(group.payload)
.where(
and(
inArray(transactions.id, group.ids),
eq(transactions.userId, user.id),
),
);
}
});
};
if (data.scope === "current") {
await applyUpdates([
{
id: data.id,
purchaseDate: existing.purchaseDate ?? null,
},
]);
revalidate(user.id);
return { success: true, message: "Lançamento atualizado com sucesso." };
}
if (data.scope === "future") {
const futureLancamentos = await db.query.transactions.findMany({
columns: {
id: true,
purchaseDate: true,
},
where: and(
eq(transactions.seriesId, existing.seriesId),
eq(transactions.userId, user.id),
sql`${transactions.period} >= ${existing.period}`,
),
orderBy: asc(transactions.purchaseDate),
});
await applyUpdates(
futureLancamentos.map((item: (typeof futureLancamentos)[number]) => ({
id: item.id,
purchaseDate: item.purchaseDate ?? null,
})),
);
revalidate(user.id);
return {
success: true,
message: "Lançamentos atualizados com sucesso.",
};
}
if (data.scope === "all") {
const allLancamentos = await db.query.transactions.findMany({
columns: {
id: true,
purchaseDate: true,
},
where: and(
eq(transactions.seriesId, existing.seriesId),
eq(transactions.userId, user.id),
),
orderBy: asc(transactions.purchaseDate),
});
await applyUpdates(
allLancamentos.map((item: (typeof allLancamentos)[number]) => ({
id: item.id,
purchaseDate: item.purchaseDate ?? null,
})),
);
revalidate(user.id);
return {
success: true,
message: "Todos os lançamentos da série foram atualizados.",
};
}
return { success: false, error: "Escopo de ação inválido." };
} catch (error) {
return handleActionError(error);
}
}
export async function createMassTransactionsAction(
input: MassAddInput,
): Promise<ActionResult> {
try {
const user = await getUser();
const data = massAddSchema.parse(input);
const uniquePayerIds = new Set<string>();
const uniqueCategoryIds = new Set<string>();
for (const transaction of data.transactions) {
if (transaction.payerId) uniquePayerIds.add(transaction.payerId);
if (transaction.categoryId) uniqueCategoryIds.add(transaction.categoryId);
}
const [ownedAccountIds, ownedCardIds, ownedPayerIds, ownedCategoryIds] =
await Promise.all([
fetchOwnedAccountIds(user.id, [data.fixedFields.accountId]),
fetchOwnedCardIds(user.id, [data.fixedFields.cardId]),
fetchOwnedPayerIds(user.id, [...uniquePayerIds]),
fetchOwnedCategoryIds(user.id, [...uniqueCategoryIds]),
]);
if (
data.fixedFields.accountId &&
!ownedAccountIds.has(data.fixedFields.accountId)
) {
return { success: false, error: "Conta não encontrada." };
}
if (data.fixedFields.cardId && !ownedCardIds.has(data.fixedFields.cardId)) {
return { success: false, error: "Cartão não encontrado." };
}
const invalidPayers = new Set(
[...uniquePayerIds].filter((id) => !ownedPayerIds.has(id)),
);
const invalidCategories = new Set(
[...uniqueCategoryIds].filter((id) => !ownedCategoryIds.has(id)),
);
for (let i = 0; i < data.transactions.length; i++) {
const transaction = data.transactions[i];
if (transaction.payerId && invalidPayers.has(transaction.payerId)) {
return {
success: false,
error: `Payer não encontrado na transação ${i + 1}.`,
};
}
if (
transaction.categoryId &&
invalidCategories.has(transaction.categoryId)
) {
return {
success: false,
error: `Category não encontrada na transação ${i + 1}.`,
};
}
}
const defaultTransactionType = TRANSACTION_TYPES[0];
const defaultCondition = TRANSACTION_CONDITIONS[0];
const defaultPaymentMethod = PAYMENT_METHODS[0];
const allRecords: TransactionInsert[] = [];
const notificationData: Array<{
payerId: string | null;
name: string | null;
amount: string | null;
transactionType: string | null;
paymentMethod: string | null;
condition: string | null;
purchaseDate: Date | null;
period: string | null;
note: string | null;
}> = [];
for (const transaction of data.transactions) {
const transactionType =
data.fixedFields.transactionType ?? defaultTransactionType;
const condition = data.fixedFields.condition ?? defaultCondition;
const paymentMethod =
data.fixedFields.paymentMethod ?? defaultPaymentMethod;
const payerId = transaction.payerId ?? null;
const accountId =
paymentMethod === "Cartão de crédito"
? null
: (data.fixedFields.accountId ?? null);
const cardId =
paymentMethod === "Cartão de crédito"
? (data.fixedFields.cardId ?? null)
: null;
const categoryId = transaction.categoryId ?? null;
const period =
data.fixedFields.period ?? resolvePeriod(transaction.purchaseDate);
const purchaseDate = parseLocalDateString(transaction.purchaseDate);
const amountSign: 1 | -1 = transactionType === "Despesa" ? -1 : 1;
const totalCents = Math.round(Math.abs(transaction.amount) * 100);
const amount = centsToDecimalString(totalCents * amountSign);
const isSettled = paymentMethod === "Cartão de crédito" ? null : false;
const record: TransactionInsert = {
name: transaction.name,
purchaseDate,
period,
transactionType,
amount,
condition,
paymentMethod,
payerId,
accountId,
cardId,
categoryId,
note: null,
installmentCount: null,
recurrenceCount: null,
currentInstallment: null,
isSettled,
isDivided: false,
dueDate: null,
boletoPaymentDate: null,
userId: user.id,
seriesId: null,
};
allRecords.push(record);
notificationData.push({
payerId,
name: transaction.name,
amount,
transactionType,
paymentMethod,
condition,
purchaseDate,
period,
note: null,
});
}
if (!allRecords.length) {
throw new Error("Não foi possível criar os lançamentos solicitados.");
}
await db.transaction(async (tx: typeof db) => {
await tx.insert(transactions).values(allRecords);
});
const notificationEntries = buildEntriesByPayer(notificationData);
if (notificationEntries.size > 0) {
await sendPayerAutoEmails({
userLabel: resolveUserLabel(user),
action: "created",
entriesByPagador: notificationEntries,
});
}
revalidate(user.id);
const count = allRecords.length;
return {
success: true,
message: `${count} ${
count === 1 ? "lançamento criado" : "lançamentos criados"
} com sucesso.`,
};
} catch (error) {
return handleActionError(error);
}
}
export async function deleteMultipleTransactionsAction(
input: DeleteMultipleInput,
): Promise<ActionResult> {
try {
const user = await getUser();
const data = deleteMultipleSchema.parse(input);
const existing = await db.query.transactions.findMany({
columns: {
id: true,
name: true,
payerId: true,
amount: true,
transactionType: true,
paymentMethod: true,
condition: true,
purchaseDate: true,
period: true,
note: true,
},
where: and(
inArray(transactions.id, data.ids),
eq(transactions.userId, user.id),
),
});
if (existing.length === 0) {
return { success: false, error: "Nenhum lançamento encontrado." };
}
await db
.delete(transactions)
.where(
and(
inArray(transactions.id, data.ids),
eq(transactions.userId, user.id),
),
);
const notificationData = existing
.filter(
(
item: (typeof existing)[number],
): item is typeof item & {
payerId: NonNullable<typeof item.payerId>;
} => Boolean(item.payerId),
)
.map((item: (typeof existing)[number]) => ({
payerId: item.payerId,
name: item.name ?? null,
amount: item.amount ?? null,
transactionType: item.transactionType ?? null,
paymentMethod: item.paymentMethod ?? null,
condition: item.condition ?? null,
purchaseDate: item.purchaseDate ?? null,
period: item.period ?? null,
note: item.note ?? null,
}));
if (notificationData.length > 0) {
const notificationEntries = buildEntriesByPayer(notificationData);
await sendPayerAutoEmails({
userLabel: resolveUserLabel(user),
action: "deleted",
entriesByPagador: notificationEntries,
});
}
revalidate(user.id);
const count = existing.length;
return {
success: true,
message: `${count} ${
count === 1 ? "lançamento removido" : "lançamentos removidos"
} com sucesso.`,
};
} catch (error) {
return handleActionError(error);
}
}

View File

@@ -0,0 +1,758 @@
import { and, eq, inArray } from "drizzle-orm";
import { z } from "zod";
import {
cards,
categories,
financialAccounts,
payers,
type transactions,
} from "@/db/schema";
import {
PAYMENT_METHODS,
TRANSACTION_CONDITIONS,
TRANSACTION_TYPES,
} from "@/features/transactions/constants";
import {
INITIAL_BALANCE_CONDITION,
INITIAL_BALANCE_NOTE,
INITIAL_BALANCE_PAYMENT_METHOD,
INITIAL_BALANCE_TRANSACTION_TYPE,
} from "@/shared/lib/accounts/constants";
import { revalidateForEntity } from "@/shared/lib/actions/helpers";
import { db } from "@/shared/lib/db";
import { noteSchema, uuidSchema } from "@/shared/lib/schemas/common";
import { addMonthsToDate, parseLocalDateString } from "@/shared/utils/date";
import { addMonthsToPeriod } from "@/shared/utils/period";
// ============================================================================
// Authorization Validation Functions
// ============================================================================
export async function validatePagadorOwnership(
userId: string,
payerId: string | null | undefined,
): Promise<boolean> {
if (!payerId) return true;
const pagador = await db.query.payers.findFirst({
where: and(eq(payers.id, payerId), eq(payers.userId, userId)),
});
return !!pagador;
}
const normalizeIds = (ids: Array<string | null | undefined>) => [
...new Set(ids.filter((id): id is string => Boolean(id))),
];
export async function fetchOwnedPayerIds(
userId: string,
payerIds: Array<string | null | undefined>,
): Promise<Set<string>> {
const ids = normalizeIds(payerIds);
if (ids.length === 0) {
return new Set();
}
const rows = await db
.select({ id: payers.id })
.from(payers)
.where(and(eq(payers.userId, userId), inArray(payers.id, ids)));
return new Set(rows.map((row) => row.id));
}
export async function validateCategoriaOwnership(
userId: string,
categoryId: string | null | undefined,
): Promise<boolean> {
if (!categoryId) return true;
const categoria = await db.query.categories.findFirst({
where: and(eq(categories.id, categoryId), eq(categories.userId, userId)),
});
return !!categoria;
}
export async function fetchOwnedCategoryIds(
userId: string,
categoryIds: Array<string | null | undefined>,
): Promise<Set<string>> {
const ids = normalizeIds(categoryIds);
if (ids.length === 0) {
return new Set();
}
const rows = await db
.select({ id: categories.id })
.from(categories)
.where(and(eq(categories.userId, userId), inArray(categories.id, ids)));
return new Set(rows.map((row) => row.id));
}
export async function validateContaOwnership(
userId: string,
accountId: string | null | undefined,
): Promise<boolean> {
if (!accountId) return true;
const conta = await db.query.financialAccounts.findFirst({
where: and(
eq(financialAccounts.id, accountId),
eq(financialAccounts.userId, userId),
),
});
return !!conta;
}
export async function fetchOwnedAccountIds(
userId: string,
accountIds: Array<string | null | undefined>,
): Promise<Set<string>> {
const ids = normalizeIds(accountIds);
if (ids.length === 0) {
return new Set();
}
const rows = await db
.select({ id: financialAccounts.id })
.from(financialAccounts)
.where(
and(
eq(financialAccounts.userId, userId),
inArray(financialAccounts.id, ids),
),
);
return new Set(rows.map((row) => row.id));
}
export async function validateCartaoOwnership(
userId: string,
cardId: string | null | undefined,
): Promise<boolean> {
if (!cardId) return true;
const cartao = await db.query.cards.findFirst({
where: and(eq(cards.id, cardId), eq(cards.userId, userId)),
});
return !!cartao;
}
export async function fetchOwnedCardIds(
userId: string,
cardIds: Array<string | null | undefined>,
): Promise<Set<string>> {
const ids = normalizeIds(cardIds);
if (ids.length === 0) {
return new Set();
}
const rows = await db
.select({ id: cards.id })
.from(cards)
.where(and(eq(cards.userId, userId), inArray(cards.id, ids)));
return new Set(rows.map((row) => row.id));
}
export async function validateAllOwnership(
userId: string,
fields: {
payerId?: string | null;
secondaryPayerId?: string | null;
categoryId?: string | null;
accountId?: string | null;
cardId?: string | null;
},
): Promise<string | null> {
const [ownedPayerIds, ownedCategoryIds, ownedAccountIds, ownedCardIds] =
await Promise.all([
fetchOwnedPayerIds(userId, [fields.payerId, fields.secondaryPayerId]),
fetchOwnedCategoryIds(userId, [fields.categoryId]),
fetchOwnedAccountIds(userId, [fields.accountId]),
fetchOwnedCardIds(userId, [fields.cardId]),
]);
const checks = [
!fields.payerId || ownedPayerIds.has(fields.payerId),
!fields.secondaryPayerId || ownedPayerIds.has(fields.secondaryPayerId),
!fields.categoryId || ownedCategoryIds.has(fields.categoryId),
!fields.accountId || ownedAccountIds.has(fields.accountId),
!fields.cardId || ownedCardIds.has(fields.cardId),
];
const errors = [
"Pagador não encontrado ou sem permissão.",
"Pagador secundário não encontrado ou sem permissão.",
"Categoria não encontrada.",
"Conta não encontrada.",
"Cartão não encontrado.",
];
for (let i = 0; i < checks.length; i++) {
if (!checks[i]) return errors[i];
}
return null;
}
// ============================================================================
// Utility Functions
// ============================================================================
export const resolvePeriod = (purchaseDate: string, period?: string | null) => {
if (period && /^\d{4}-\d{2}$/.test(period)) {
return period;
}
const date = parseLocalDateString(purchaseDate);
if (Number.isNaN(date.getTime())) {
throw new Error("Data da transação inválida.");
}
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
return `${year}-${month}`;
};
export const isValidDateInput = (value: string) =>
!Number.isNaN(parseLocalDateString(value).getTime());
export const baseFields = z.object({
purchaseDate: z
.string({ message: "Informe a data da transação." })
.trim()
.refine((value) => isValidDateInput(value), {
message: "Data da transação inválida.",
}),
period: z
.string()
.trim()
.regex(/^(\d{4})-(\d{2})$/, {
message: "Selecione um período válido.",
})
.optional(),
name: z
.string({ message: "Informe o estabelecimento." })
.trim()
.min(1, "Informe o estabelecimento."),
transactionType: z
.enum(TRANSACTION_TYPES, {
message: "Selecione um tipo de transação válido.",
})
.default(TRANSACTION_TYPES[0]),
amount: z.coerce
.number({ message: "Informe o valor da transação." })
.min(0, "Informe um valor maior ou igual a zero."),
condition: z.enum(TRANSACTION_CONDITIONS, {
message: "Selecione uma condição válida.",
}),
paymentMethod: z.enum(PAYMENT_METHODS, {
message: "Selecione uma forma de pagamento válida.",
}),
payerId: uuidSchema("Payer").nullable().optional(),
secondaryPayerId: uuidSchema("Payer secundário").optional(),
isSplit: z.boolean().optional().default(false),
primarySplitAmount: z.coerce.number().min(0).optional(),
secondarySplitAmount: z.coerce.number().min(0).optional(),
accountId: uuidSchema("FinancialAccount").nullable().optional(),
cardId: uuidSchema("Cartão").nullable().optional(),
categoryId: uuidSchema("Category").nullable().optional(),
note: noteSchema,
installmentCount: z.coerce
.number()
.int()
.min(1, "Selecione uma quantidade válida.")
.max(60, "Selecione uma quantidade válida.")
.optional(),
recurrenceCount: z.coerce
.number()
.int()
.min(1, "Selecione uma recorrência válida.")
.max(60, "Selecione uma recorrência válida.")
.optional(),
dueDate: z
.string()
.trim()
.refine((value) => !value || isValidDateInput(value), {
message: "Informe uma data de vencimento válida.",
})
.optional(),
boletoPaymentDate: z
.string()
.trim()
.refine((value) => !value || isValidDateInput(value), {
message: "Informe uma data de pagamento válida.",
})
.optional(),
isSettled: z.boolean().nullable().optional(),
});
const refineLancamento = (
data: z.infer<typeof baseFields> & { id?: string },
ctx: z.RefinementCtx,
) => {
if (!data.categoryId) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["categoryId"],
message: "Selecione uma categoria.",
});
}
if (data.paymentMethod === "Cartão de crédito") {
if (!data.cardId) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["cardId"],
message: "Selecione o cartão.",
});
}
} else if (!data.accountId) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["accountId"],
message: "Selecione a conta.",
});
}
if (data.condition === "Recorrente") {
if (!data.recurrenceCount) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["recurrenceCount"],
message: "Informe por quantos meses a recorrência acontecerá.",
});
} else if (data.recurrenceCount < 2) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["recurrenceCount"],
message: "A recorrência deve ter ao menos dois meses.",
});
}
}
if (data.condition === "Parcelado") {
if (!data.installmentCount) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["installmentCount"],
message: "Informe a quantidade de parcelas.",
});
} else if (data.installmentCount < 2) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["installmentCount"],
message: "Selecione pelo menos duas parcelas.",
});
}
}
if (data.isSplit) {
if (!data.payerId) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["payerId"],
message: "Selecione o pagador principal para dividir o lançamento.",
});
}
if (!data.secondaryPayerId) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["secondaryPayerId"],
message: "Selecione o pagador secundário para dividir o lançamento.",
});
} else if (data.payerId && data.secondaryPayerId === data.payerId) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["secondaryPayerId"],
message: "Escolha um pagador diferente para dividir o lançamento.",
});
}
if (
data.primarySplitAmount !== undefined &&
data.secondarySplitAmount !== undefined
) {
const sum = data.primarySplitAmount + data.secondarySplitAmount;
const total = Math.abs(data.amount);
if (Math.abs(sum - total) > 0.01) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["primarySplitAmount"],
message: "A soma das divisões deve ser igual ao valor total.",
});
}
}
}
};
export const createSchema = baseFields.superRefine(refineLancamento);
export const updateSchema = baseFields
.extend({
id: uuidSchema("Lançamento"),
})
.superRefine(refineLancamento);
export const deleteSchema = z.object({
id: uuidSchema("Lançamento"),
});
export const toggleSettlementSchema = z.object({
id: uuidSchema("Lançamento"),
value: z.boolean({
message: "Informe o status de pagamento.",
}),
});
export type BaseInput = z.infer<typeof baseFields>;
export type CreateInput = z.infer<typeof createSchema>;
export type UpdateInput = z.infer<typeof updateSchema>;
export type DeleteInput = z.infer<typeof deleteSchema>;
export type ToggleSettlementInput = z.infer<typeof toggleSettlementSchema>;
export const revalidate = (userId: string) =>
revalidateForEntity("transactions", userId);
export const resolveUserLabel = (user: {
name?: string | null;
email?: string | null;
}) => {
if (user?.name && user.name.trim().length > 0) {
return user.name;
}
if (user?.email && user.email.trim().length > 0) {
return user.email;
}
return "OpenMonetis";
};
type InitialCandidate = {
note: string | null;
transactionType: string | null;
condition: string | null;
paymentMethod: string | null;
};
export const isInitialBalanceLancamento = (record?: InitialCandidate | null) =>
!!record &&
record.note === INITIAL_BALANCE_NOTE &&
record.transactionType === INITIAL_BALANCE_TRANSACTION_TYPE &&
record.condition === INITIAL_BALANCE_CONDITION &&
record.paymentMethod === INITIAL_BALANCE_PAYMENT_METHOD;
export const centsToDecimalString = (value: number) => {
const decimal = value / 100;
const formatted = decimal.toFixed(2);
return Object.is(decimal, -0) ? "0.00" : formatted;
};
const splitAmount = (totalCents: number, parts: number) => {
if (parts <= 0) {
return [];
}
const base = Math.trunc(totalCents / parts);
const remainder = totalCents % parts;
return Array.from(
{ length: parts },
(_, index) => base + (index < remainder ? 1 : 0),
);
};
export type Share = {
payerId: string | null;
amountCents: number;
};
export const buildShares = ({
totalCents,
payerId,
isSplit,
secondaryPayerId,
primarySplitAmountCents,
secondarySplitAmountCents,
}: {
totalCents: number;
payerId: string | null;
isSplit: boolean;
secondaryPayerId?: string;
primarySplitAmountCents?: number;
secondarySplitAmountCents?: number;
}): Share[] => {
if (isSplit) {
if (!payerId || !secondaryPayerId) {
throw new Error("Configuração de divisão inválida para o lançamento.");
}
if (
primarySplitAmountCents !== undefined &&
secondarySplitAmountCents !== undefined
) {
return [
{ payerId, amountCents: primarySplitAmountCents },
{
payerId: secondaryPayerId,
amountCents: secondarySplitAmountCents,
},
];
}
const [primaryAmount, secondaryAmount] = splitAmount(totalCents, 2);
return [
{ payerId, amountCents: primaryAmount },
{ payerId: secondaryPayerId, amountCents: secondaryAmount },
];
}
return [{ payerId, amountCents: totalCents }];
};
type BuildTransactionRecordsParams = {
data: BaseInput;
userId: string;
period: string;
purchaseDate: Date;
dueDate: Date | null;
boletoPaymentDate: Date | null;
shares: Share[];
amountSign: 1 | -1;
shouldNullifySettled: boolean;
seriesId: string | null;
};
export type TransactionInsert = typeof transactions.$inferInsert;
export const buildLancamentoRecords = ({
data,
userId,
period,
purchaseDate,
dueDate,
boletoPaymentDate,
shares,
amountSign,
shouldNullifySettled,
seriesId,
}: BuildTransactionRecordsParams): TransactionInsert[] => {
const records: TransactionInsert[] = [];
const basePayload = {
name: data.name,
transactionType: data.transactionType,
condition: data.condition,
paymentMethod: data.paymentMethod,
note: data.note ?? null,
accountId: data.accountId ?? null,
cardId: data.cardId ?? null,
categoryId: data.categoryId ?? null,
recurrenceCount: null as number | null,
installmentCount: null as number | null,
currentInstallment: null as number | null,
isDivided: data.isSplit ?? false,
userId,
seriesId,
};
const resolveSettledValue = (cycleIndex: number) => {
if (shouldNullifySettled) {
return null;
}
const initialSettled = data.isSettled ?? false;
if (data.condition === "Parcelado" || data.condition === "Recorrente") {
return cycleIndex === 0 ? initialSettled : false;
}
return initialSettled;
};
if (data.condition === "Parcelado") {
const installmentTotal = data.installmentCount ?? 0;
const amountsByShare = shares.map((share) =>
splitAmount(share.amountCents, installmentTotal),
);
for (
let installment = 0;
installment < installmentTotal;
installment += 1
) {
const installmentPeriod = addMonthsToPeriod(period, installment);
const installmentDueDate = dueDate
? addMonthsToDate(dueDate, installment)
: null;
shares.forEach((share, shareIndex) => {
const amountCents = amountsByShare[shareIndex]?.[installment] ?? 0;
const settled = resolveSettledValue(installment);
records.push({
...basePayload,
amount: centsToDecimalString(amountCents * amountSign),
payerId: share.payerId,
purchaseDate,
period: installmentPeriod,
isSettled: settled,
installmentCount: installmentTotal,
currentInstallment: installment + 1,
recurrenceCount: null,
dueDate: installmentDueDate,
boletoPaymentDate:
data.paymentMethod === "Boleto" && settled
? boletoPaymentDate
: null,
});
});
}
return records;
}
if (data.condition === "Recorrente") {
const recurrenceTotal = data.recurrenceCount ?? 0;
for (let index = 0; index < recurrenceTotal; index += 1) {
const recurrencePeriod = addMonthsToPeriod(period, index);
const recurrencePurchaseDate = addMonthsToDate(purchaseDate, index);
const recurrenceDueDate = dueDate
? addMonthsToDate(dueDate, index)
: null;
shares.forEach((share) => {
const settled = resolveSettledValue(index);
records.push({
...basePayload,
amount: centsToDecimalString(share.amountCents * amountSign),
payerId: share.payerId,
purchaseDate: recurrencePurchaseDate,
period: recurrencePeriod,
isSettled: settled,
recurrenceCount: recurrenceTotal,
dueDate: recurrenceDueDate,
boletoPaymentDate:
data.paymentMethod === "Boleto" && settled
? boletoPaymentDate
: null,
});
});
}
return records;
}
shares.forEach((share) => {
const settled = resolveSettledValue(0);
records.push({
...basePayload,
amount: centsToDecimalString(share.amountCents * amountSign),
payerId: share.payerId,
purchaseDate,
period,
isSettled: settled,
dueDate,
boletoPaymentDate:
data.paymentMethod === "Boleto" && settled ? boletoPaymentDate : null,
});
});
return records;
};
export const deleteBulkSchema = z.object({
id: uuidSchema("Lançamento"),
scope: z.enum(["current", "future", "all"], {
message: "Escopo de ação inválido.",
}),
});
export type DeleteBulkInput = z.infer<typeof deleteBulkSchema>;
export const updateBulkSchema = z.object({
id: uuidSchema("Lançamento"),
scope: z.enum(["current", "future", "all"], {
message: "Escopo de ação inválido.",
}),
name: z
.string({ message: "Informe o estabelecimento." })
.trim()
.min(1, "Informe o estabelecimento."),
categoryId: uuidSchema("Category").nullable().optional(),
note: noteSchema,
payerId: uuidSchema("Payer").nullable().optional(),
accountId: uuidSchema("FinancialAccount").nullable().optional(),
cardId: uuidSchema("Cartão").nullable().optional(),
amount: z.coerce
.number({ message: "Informe o valor da transação." })
.min(0, "Informe um valor maior ou igual a zero.")
.optional(),
dueDate: z
.string()
.trim()
.refine((value) => !value || isValidDateInput(value), {
message: "Informe uma data de vencimento válida.",
})
.optional()
.nullable(),
boletoPaymentDate: z
.string()
.trim()
.refine((value) => !value || isValidDateInput(value), {
message: "Informe uma data de pagamento válida.",
})
.optional()
.nullable(),
});
export type UpdateBulkInput = z.infer<typeof updateBulkSchema>;
export const massAddTransactionSchema = z.object({
purchaseDate: z
.string({ message: "Informe a data da transação." })
.trim()
.refine((value) => isValidDateInput(value), {
message: "Data da transação inválida.",
}),
name: z
.string({ message: "Informe o estabelecimento." })
.trim()
.min(1, "Informe o estabelecimento."),
amount: z.coerce
.number({ message: "Informe o valor da transação." })
.min(0, "Informe um valor maior ou igual a zero."),
categoryId: uuidSchema("Category").nullable().optional(),
payerId: uuidSchema("Payer").nullable().optional(),
});
export const massAddSchema = z.object({
fixedFields: z.object({
transactionType: z.enum(TRANSACTION_TYPES).optional(),
paymentMethod: z.enum(PAYMENT_METHODS).optional(),
condition: z.enum(TRANSACTION_CONDITIONS).optional(),
period: z
.string()
.trim()
.regex(/^(\d{4})-(\d{2})$/, {
message: "Selecione um período válido.",
})
.optional(),
accountId: uuidSchema("FinancialAccount").nullable().optional(),
cardId: uuidSchema("Cartão").nullable().optional(),
}),
transactions: z
.array(massAddTransactionSchema)
.min(1, "Adicione pelo menos uma transação."),
});
export type MassAddInput = z.infer<typeof massAddSchema>;
export const deleteMultipleSchema = z.object({
ids: z
.array(uuidSchema("Lançamento"))
.min(1, "Selecione pelo menos um lançamento."),
});
export type DeleteMultipleInput = z.infer<typeof deleteMultipleSchema>;

View File

@@ -0,0 +1,81 @@
"use server";
import { z } from "zod";
import { fetchAccountLancamentos } from "@/features/accounts/statement-queries";
import type { TransactionsExportContext } from "@/features/transactions/export-types";
import {
buildSluggedFilters,
buildSlugMaps,
buildTransactionWhere,
mapTransactionsData,
} from "@/features/transactions/page-helpers";
import {
fetchTransactionFilterSources,
fetchTransactions,
} from "@/features/transactions/queries";
import {
type ActionResult,
handleActionError,
} from "@/shared/lib/actions/helpers";
import { getUserId } from "@/shared/lib/auth/server";
const exportTransactionsSchema: z.ZodType<TransactionsExportContext> = z.object(
{
source: z.enum(["transactions", "account-statement"]),
period: z.string().regex(/^\d{4}-\d{2}$/),
filters: z.object({
transactionFilter: z.string().nullable(),
conditionFilter: z.string().nullable(),
paymentFilter: z.string().nullable(),
payerFilter: z.string().nullable(),
categoryFilter: z.string().nullable(),
accountCardFilter: z.string().nullable(),
searchFilter: z.string().nullable(),
}),
accountId: z.string().min(1).nullable().optional(),
cardId: z.string().min(1).nullable().optional(),
payerId: z.string().min(1).nullable().optional(),
settledOnly: z.boolean().optional(),
},
);
export async function exportTransactionsDataAction(
input: TransactionsExportContext,
): Promise<
ActionResult<{ transactions: ReturnType<typeof mapTransactionsData> }>
> {
try {
const userId = await getUserId();
const validated = exportTransactionsSchema.parse(input);
const filterSources = await fetchTransactionFilterSources(userId);
const sluggedFilters = buildSluggedFilters(filterSources);
const slugMaps = buildSlugMaps(sluggedFilters);
const filters = buildTransactionWhere({
userId,
period: validated.period,
filters: validated.filters,
slugMaps,
accountId: validated.accountId ?? undefined,
cardId: validated.cardId ?? undefined,
payerId: validated.payerId ?? undefined,
});
const rows =
validated.source === "account-statement"
? await fetchAccountLancamentos(filters, validated.settledOnly ?? true)
: await fetchTransactions(filters);
return {
success: true,
message: "Dados carregados para exportação.",
data: {
transactions: mapTransactionsData(rows),
},
};
} catch (error) {
return handleActionError(error) as ActionResult<{
transactions: ReturnType<typeof mapTransactionsData>;
}>;
}
}

View File

@@ -0,0 +1,422 @@
"use server";
import { randomUUID } from "node:crypto";
import { and, eq } from "drizzle-orm";
import { financialAccounts, transactions } from "@/db/schema";
import { handleActionError } from "@/shared/lib/actions/helpers";
import { getUser } from "@/shared/lib/auth/server";
import { db } from "@/shared/lib/db";
import {
buildEntriesByPayer,
sendPayerAutoEmails,
} from "@/shared/lib/payers/notifications";
import type { ActionResult } from "@/shared/lib/types/actions";
import { formatDecimalForDbRequired } from "@/shared/utils/currency";
import {
getBusinessTodayDate,
parseLocalDateString,
} from "@/shared/utils/date";
import {
buildLancamentoRecords,
buildShares,
type CreateInput,
centsToDecimalString,
createSchema,
type DeleteInput,
deleteSchema,
isInitialBalanceLancamento,
resolvePeriod,
resolveUserLabel,
revalidate,
type ToggleSettlementInput,
toggleSettlementSchema,
type UpdateInput,
updateSchema,
validateAllOwnership,
} from "./core";
export async function createTransactionAction(
input: CreateInput,
): Promise<ActionResult> {
try {
const user = await getUser();
const data = createSchema.parse(input);
const ownershipError = await validateAllOwnership(user.id, {
payerId: data.payerId,
secondaryPayerId: data.secondaryPayerId,
categoryId: data.categoryId,
accountId: data.accountId,
cardId: data.cardId,
});
if (ownershipError) {
return { success: false, error: ownershipError };
}
const period = resolvePeriod(data.purchaseDate, data.period);
const purchaseDate = parseLocalDateString(data.purchaseDate);
const dueDate = data.dueDate ? parseLocalDateString(data.dueDate) : null;
const shouldSetBoletoPaymentDate =
data.paymentMethod === "Boleto" && (data.isSettled ?? false);
const boletoPaymentDate = shouldSetBoletoPaymentDate
? data.boletoPaymentDate
? parseLocalDateString(data.boletoPaymentDate)
: getBusinessTodayDate()
: null;
const amountSign: 1 | -1 = data.transactionType === "Despesa" ? -1 : 1;
const totalCents = Math.round(Math.abs(data.amount) * 100);
const shouldNullifySettled = data.paymentMethod === "Cartão de crédito";
const shares = buildShares({
totalCents,
payerId: data.payerId ?? null,
isSplit: data.isSplit ?? false,
secondaryPayerId: data.secondaryPayerId,
primarySplitAmountCents: data.primarySplitAmount
? Math.round(data.primarySplitAmount * 100)
: undefined,
secondarySplitAmountCents: data.secondarySplitAmount
? Math.round(data.secondarySplitAmount * 100)
: undefined,
});
const isSeriesLancamento =
data.condition === "Parcelado" || data.condition === "Recorrente";
const seriesId = isSeriesLancamento ? randomUUID() : null;
const records = buildLancamentoRecords({
data,
userId: user.id,
period,
purchaseDate,
dueDate,
shares,
amountSign,
shouldNullifySettled,
boletoPaymentDate,
seriesId,
});
if (!records.length) {
throw new Error("Não foi possível criar os lançamentos solicitados.");
}
await db.insert(transactions).values(records);
const notificationEntries = buildEntriesByPayer(
records.map((record) => ({
payerId: record.payerId ?? null,
name: record.name ?? null,
amount: record.amount ?? null,
transactionType: record.transactionType ?? null,
paymentMethod: record.paymentMethod ?? null,
condition: record.condition ?? null,
purchaseDate: record.purchaseDate ?? null,
period: record.period ?? null,
note: record.note ?? null,
})),
);
if (notificationEntries.size > 0) {
await sendPayerAutoEmails({
userLabel: resolveUserLabel(user),
action: "created",
entriesByPagador: notificationEntries,
});
}
revalidate(user.id);
return { success: true, message: "Lançamento criado com sucesso." };
} catch (error) {
return handleActionError(error);
}
}
export async function updateTransactionAction(
input: UpdateInput,
): Promise<ActionResult> {
try {
const user = await getUser();
const data = updateSchema.parse(input);
const ownershipError = await validateAllOwnership(user.id, {
payerId: data.payerId,
secondaryPayerId: data.secondaryPayerId,
categoryId: data.categoryId,
accountId: data.accountId,
cardId: data.cardId,
});
if (ownershipError) {
return { success: false, error: ownershipError };
}
const existing = (await db.query.transactions.findFirst({
columns: {
id: true,
note: true,
transactionType: true,
condition: true,
paymentMethod: true,
accountId: true,
categoryId: true,
},
where: and(
eq(transactions.id, data.id),
eq(transactions.userId, user.id),
),
with: {
category: {
columns: {
name: true,
},
},
},
})) as
| {
id: string;
note: string | null;
transactionType: string;
condition: string;
paymentMethod: string;
accountId: string | null;
categoryId: string | null;
category: { name: string } | null;
}
| undefined;
if (!existing) {
return { success: false, error: "Lançamento não encontrado." };
}
const categoriasProtegidasEdicao = ["Saldo inicial", "Pagamentos"];
if (
existing.category?.name &&
categoriasProtegidasEdicao.includes(existing.category.name)
) {
return {
success: false,
error: `Lançamentos com a categoria '${existing.category.name}' não podem ser editados.`,
};
}
const period = resolvePeriod(data.purchaseDate, data.period);
const amountSign: 1 | -1 = data.transactionType === "Despesa" ? -1 : 1;
const amountCents = Math.round(Math.abs(data.amount) * 100);
const normalizedAmount = centsToDecimalString(amountCents * amountSign);
const normalizedSettled =
data.paymentMethod === "Cartão de crédito"
? null
: (data.isSettled ?? false);
const shouldSetBoletoPaymentDate =
data.paymentMethod === "Boleto" && Boolean(normalizedSettled);
const boletoPaymentDateValue = shouldSetBoletoPaymentDate
? data.boletoPaymentDate
? parseLocalDateString(data.boletoPaymentDate)
: getBusinessTodayDate()
: null;
await db
.update(transactions)
.set({
name: data.name,
purchaseDate: parseLocalDateString(data.purchaseDate),
transactionType: data.transactionType,
amount: normalizedAmount,
condition: data.condition,
paymentMethod: data.paymentMethod,
payerId: data.payerId ?? null,
accountId: data.accountId ?? null,
cardId: data.cardId ?? null,
categoryId: data.categoryId ?? null,
note: data.note ?? null,
isSettled: normalizedSettled,
installmentCount: data.installmentCount ?? null,
recurrenceCount: data.recurrenceCount ?? null,
dueDate: data.dueDate ? parseLocalDateString(data.dueDate) : null,
boletoPaymentDate: boletoPaymentDateValue,
period,
})
.where(
and(eq(transactions.id, data.id), eq(transactions.userId, user.id)),
);
if (isInitialBalanceLancamento(existing) && existing.accountId) {
const updatedInitialBalance = formatDecimalForDbRequired(
Math.abs(data.amount ?? 0),
);
await db
.update(financialAccounts)
.set({ initialBalance: updatedInitialBalance })
.where(
and(
eq(financialAccounts.id, existing.accountId),
eq(financialAccounts.userId, user.id),
),
);
}
revalidate(user.id);
return { success: true, message: "Lançamento atualizado com sucesso." };
} catch (error) {
return handleActionError(error);
}
}
export async function deleteTransactionAction(
input: DeleteInput,
): Promise<ActionResult> {
try {
const user = await getUser();
const data = deleteSchema.parse(input);
const existing = (await db.query.transactions.findFirst({
columns: {
id: true,
name: true,
payerId: true,
amount: true,
transactionType: true,
paymentMethod: true,
condition: true,
purchaseDate: true,
period: true,
note: true,
categoryId: true,
},
where: and(
eq(transactions.id, data.id),
eq(transactions.userId, user.id),
),
with: {
category: {
columns: {
name: true,
},
},
},
})) as
| {
id: string;
name: string | null;
payerId: string | null;
amount: string | null;
transactionType: string;
paymentMethod: string;
condition: string;
purchaseDate: Date | null;
period: string;
note: string | null;
categoryId: string | null;
category: { name: string } | null;
}
| undefined;
if (!existing) {
return { success: false, error: "Lançamento não encontrado." };
}
const categoriasProtegidasRemocao = ["Saldo inicial", "Pagamentos"];
if (
existing.category?.name &&
categoriasProtegidasRemocao.includes(existing.category.name)
) {
return {
success: false,
error: `Lançamentos com a categoria '${existing.category.name}' não podem ser removidos.`,
};
}
await db
.delete(transactions)
.where(
and(eq(transactions.id, data.id), eq(transactions.userId, user.id)),
);
if (existing.payerId) {
const notificationEntries = buildEntriesByPayer([
{
payerId: existing.payerId,
name: existing.name ?? null,
amount: existing.amount ?? null,
transactionType: existing.transactionType ?? null,
paymentMethod: existing.paymentMethod ?? null,
condition: existing.condition ?? null,
purchaseDate: existing.purchaseDate ?? null,
period: existing.period ?? null,
note: existing.note ?? null,
},
]);
await sendPayerAutoEmails({
userLabel: resolveUserLabel(user),
action: "deleted",
entriesByPagador: notificationEntries,
});
}
revalidate(user.id);
return { success: true, message: "Lançamento removido com sucesso." };
} catch (error) {
return handleActionError(error);
}
}
export async function toggleTransactionSettlementAction(
input: ToggleSettlementInput,
): Promise<ActionResult> {
try {
const user = await getUser();
const data = toggleSettlementSchema.parse(input);
const existing = await db.query.transactions.findFirst({
columns: { id: true, paymentMethod: true },
where: and(
eq(transactions.id, data.id),
eq(transactions.userId, user.id),
),
});
if (!existing) {
return { success: false, error: "Lançamento não encontrado." };
}
if (existing.paymentMethod === "Cartão de crédito") {
return {
success: false,
error: "Pagamentos com cartão são conciliados automaticamente.",
};
}
const isBoleto = existing.paymentMethod === "Boleto";
const boletoPaymentDate = isBoleto
? data.value
? getBusinessTodayDate()
: null
: null;
await db
.update(transactions)
.set({
isSettled: data.value,
boletoPaymentDate,
})
.where(
and(eq(transactions.id, data.id), eq(transactions.userId, user.id)),
);
revalidate(user.id);
return {
success: true,
message: data.value
? "Lançamento marcado como pago."
: "Pagamento desfeito com sucesso.",
};
} catch (error) {
return handleActionError(error);
}
}

View File

@@ -20,8 +20,8 @@ export const LANCAMENTOS_COLUMN_LABELS: Record<string, string> = {
amount: "Valor",
condition: "Condição",
paymentMethod: "Forma de Pagamento",
categoriaName: "Category",
pagadorName: "Payer",
categoriaName: "Categoria",
pagadorName: "Pagador",
note: "Anotação",
contaCartao: "Conta/Cartão",
};

View File

@@ -11,7 +11,10 @@ import {
updateTransactionBulkAction,
} from "@/features/transactions/actions";
import { ConfirmActionDialog } from "@/shared/components/confirm-action-dialog";
import type {
TransactionsExportContext,
TransactionsPaginationState,
} from "../../export-types";
import { AnticipateInstallmentsDialog } from "../dialogs/anticipate-installments-dialog/anticipate-installments-dialog";
import { AnticipationHistoryDialog } from "../dialogs/anticipate-installments-dialog/anticipation-history-dialog";
import {
@@ -54,6 +57,8 @@ interface TransactionsPageProps {
defaultPaymentMethod?: string | null;
lockCardSelection?: boolean;
lockPaymentMethod?: boolean;
pagination?: TransactionsPaginationState;
exportContext?: TransactionsExportContext;
// Opções específicas para o dialog de importação (quando visualizando dados de outro usuário)
importPayerOptions?: SelectOption[];
importSplitPayerOptions?: SelectOption[];
@@ -84,6 +89,8 @@ export function TransactionsPage({
defaultPaymentMethod,
lockCardSelection,
lockPaymentMethod,
pagination,
exportContext,
importPayerOptions,
importSplitPayerOptions,
importDefaultPayerId,
@@ -393,6 +400,8 @@ export function TransactionsPage({
categoryFilterOptions={categoryFilterOptions}
accountCardFilterOptions={accountCardFilterOptions}
selectedPeriod={selectedPeriod}
pagination={pagination}
exportContext={exportContext}
onCreate={allowCreate ? handleCreate : undefined}
onMassAdd={allowCreate ? handleMassAdd : undefined}
onEdit={handleEdit}

View File

@@ -162,10 +162,13 @@ export function TransactionsFilters({
nextParams.delete(key);
}
nextParams.delete("page");
startTransition(() => {
router.replace(`${pathname}?${nextParams.toString()}`, {
scroll: false,
});
const target = nextParams.toString()
? `${pathname}?${nextParams.toString()}`
: pathname;
router.replace(target, { scroll: false });
});
},
[searchParams, pathname, router],
@@ -193,10 +196,14 @@ export function TransactionsFilters({
const handleReset = () => {
const periodValue = searchParams.get("periodo");
const pageSizeValue = searchParams.get("pageSize");
const nextParams = new URLSearchParams();
if (periodValue) {
nextParams.set("periodo", periodValue);
}
if (pageSizeValue) {
nextParams.set("pageSize", pageSizeValue);
}
setSearchValue("");
setCategoryOpen(false);
startTransition(() => {

View File

@@ -34,8 +34,13 @@ import {
} from "@tanstack/react-table";
import Image from "next/image";
import Link from "next/link";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useMemo, useState } from "react";
import { DEFAULT_LANCAMENTOS_COLUMN_ORDER } from "@/features/transactions/column-order";
import type {
TransactionsExportContext,
TransactionsPaginationState,
} from "@/features/transactions/export-types";
import { EmptyState } from "@/shared/components/empty-state";
import {
CategoryIconBadge,
@@ -289,7 +294,7 @@ const buildColumns = ({
<TooltipContent
side="top"
align="start"
className="max-w-xs whitespace-pre-line text-sm"
className="max-w-xs whitespace-pre-line"
>
{note}
</TooltipContent>
@@ -743,6 +748,8 @@ type LancamentosTableProps = {
categoryFilterOptions?: TransactionFilterOption[];
accountCardFilterOptions?: AccountCardFilterOption[];
selectedPeriod?: string;
pagination?: TransactionsPaginationState;
exportContext?: TransactionsExportContext;
onCreate?: (type: "Despesa" | "Receita") => void;
onMassAdd?: () => void;
onEdit?: (item: TransactionItem) => void;
@@ -769,6 +776,8 @@ export function TransactionsTable({
categoryFilterOptions = [],
accountCardFilterOptions = [],
selectedPeriod,
pagination: serverPagination,
exportContext,
onCreate,
onMassAdd,
onEdit,
@@ -785,6 +794,9 @@ export function TransactionsTable({
showActions = true,
showFilters = true,
}: LancamentosTableProps) {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const [sorting, setSorting] = useState<SortingState>([
{ id: "purchaseDate", desc: true },
]);
@@ -796,6 +808,7 @@ export function TransactionsTable({
pageSize: 30,
});
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
const isServerPaginated = Boolean(serverPagination);
const columns = useMemo(() => {
const built = buildColumns({
@@ -835,30 +848,53 @@ export function TransactionsTable({
const table = useReactTable({
data,
columns,
state: {
sorting,
columnVisibility,
pagination,
rowSelection,
},
state: isServerPaginated
? {
sorting,
columnVisibility,
rowSelection,
}
: {
sorting,
columnVisibility,
pagination,
rowSelection,
},
onSortingChange: setSorting,
onPaginationChange: setPagination,
onPaginationChange: isServerPaginated ? undefined : setPagination,
onRowSelectionChange: setRowSelection,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getPaginationRowModel: isServerPaginated
? undefined
: getPaginationRowModel(),
manualPagination: isServerPaginated,
pageCount: serverPagination?.totalPages,
enableRowSelection: true,
});
const rowModel = table.getRowModel();
const hasRows = rowModel.rows.length > 0;
const totalRows = table.getCoreRowModel().rows.length;
const totalRows = isServerPaginated
? (serverPagination?.totalItems ?? 0)
: table.getCoreRowModel().rows.length;
const selectedRows = table.getFilteredSelectedRowModel().rows;
const selectedCount = selectedRows.length;
const selectedTotal = selectedRows.reduce(
(total, row) => total + (row.original.amount ?? 0),
0,
);
const currentPage = isServerPaginated
? (serverPagination?.page ?? 1)
: table.getState().pagination.pageIndex + 1;
const currentPageSize = isServerPaginated
? (serverPagination?.pageSize ?? pagination.pageSize)
: pagination.pageSize;
const totalPages = isServerPaginated
? Math.max(serverPagination?.totalPages ?? 1, 1)
: Math.max(table.getPageCount(), 1);
const canPreviousPage = currentPage > 1;
const canNextPage = currentPage < totalPages;
// Check if there's any data from other users
const hasOtherUserData = data.some((item) => item.userId !== currentUserId);
@@ -882,6 +918,28 @@ export function TransactionsTable({
const showTopControls =
Boolean(onCreate) || Boolean(onMassAdd) || showFilters;
const navigateToPage = (nextPage: number, nextPageSize = currentPageSize) => {
const nextParams = new URLSearchParams(searchParams.toString());
if (nextPage <= 1) {
nextParams.delete("page");
} else {
nextParams.set("page", nextPage.toString());
}
if (nextPageSize === 30) {
nextParams.delete("pageSize");
} else {
nextParams.set("pageSize", nextPageSize.toString());
}
const target = nextParams.toString()
? `${pathname}?${nextParams.toString()}`
: pathname;
router.replace(target, { scroll: false });
setRowSelection({});
};
return (
<TooltipProvider>
{showTopControls ? (
@@ -943,6 +1001,7 @@ export function TransactionsTable({
<TransactionsExport
lancamentos={data}
period={selectedPeriod}
exportContext={exportContext}
/>
) : null
}
@@ -1073,9 +1132,15 @@ export function TransactionsTable({
{totalRows} lançamentos
</span>
<Select
value={pagination.pageSize.toString()}
value={currentPageSize.toString()}
onValueChange={(value) => {
table.setPageSize(Number(value));
const nextPageSize = Number(value);
if (isServerPaginated) {
navigateToPage(1, nextPageSize);
return;
}
table.setPageSize(nextPageSize);
}}
>
<SelectTrigger className="w-max">
@@ -1094,15 +1159,18 @@ export function TransactionsTable({
</div>
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">
Página {table.getState().pagination.pageIndex + 1} de{" "}
{Math.max(table.getPageCount(), 1)}
Página {currentPage} de {totalPages}
</span>
<div className="flex items-center gap-1">
<Button
variant="outline"
size="icon-sm"
onClick={() => table.setPageIndex(0)}
disabled={!table.getCanPreviousPage()}
onClick={() =>
isServerPaginated
? navigateToPage(1)
: table.setPageIndex(0)
}
disabled={!canPreviousPage}
aria-label="Primeira página"
>
<RiArrowLeftDoubleLine className="size-4" />
@@ -1110,8 +1178,12 @@ export function TransactionsTable({
<Button
variant="outline"
size="icon-sm"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
onClick={() =>
isServerPaginated
? navigateToPage(currentPage - 1)
: table.previousPage()
}
disabled={!canPreviousPage}
aria-label="Página anterior"
>
<RiArrowLeftSLine className="size-4" />
@@ -1119,8 +1191,12 @@ export function TransactionsTable({
<Button
variant="outline"
size="icon-sm"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
onClick={() =>
isServerPaginated
? navigateToPage(currentPage + 1)
: table.nextPage()
}
disabled={!canNextPage}
aria-label="Próxima página"
>
<RiArrowRightSLine className="size-4" />
@@ -1129,9 +1205,11 @@ export function TransactionsTable({
variant="outline"
size="icon-sm"
onClick={() =>
table.setPageIndex(table.getPageCount() - 1)
isServerPaginated
? navigateToPage(totalPages)
: table.setPageIndex(table.getPageCount() - 1)
}
disabled={!table.getCanNextPage()}
disabled={!canNextPage}
aria-label="Última página"
>
<RiArrowRightDoubleLine className="size-4" />

View File

@@ -6,11 +6,10 @@ import {
RiFilePdfLine,
RiFileTextLine,
} from "@remixicon/react";
import jsPDF from "jspdf";
import autoTable from "jspdf-autotable";
import { useState } from "react";
import { toast } from "sonner";
import * as XLSX from "xlsx";
import { exportTransactionsDataAction } from "@/features/transactions/actions";
import type { TransactionsExportContext } from "@/features/transactions/export-types";
import { formatCurrency } from "@/features/transactions/formatting-helpers";
import { Button } from "@/shared/components/ui/button";
import {
@@ -30,11 +29,24 @@ import type { TransactionItem } from "./types";
interface LancamentosExportProps {
lancamentos: TransactionItem[];
period: string;
exportContext?: TransactionsExportContext;
}
const loadXlsx = () => import("xlsx");
const loadPdfDeps = async () => {
const [{ default: jsPDF }, { default: autoTable }] = await Promise.all([
import("jspdf"),
import("jspdf-autotable"),
]);
return { jsPDF, autoTable };
};
export function TransactionsExport({
lancamentos,
period,
exportContext,
}: LancamentosExportProps) {
const [isExporting, setIsExporting] = useState(false);
@@ -69,9 +81,24 @@ export function TransactionsExport({
return `${transaction.name} (${transaction.currentInstallment ?? 1}/${transaction.installmentCount})`;
};
const exportToCSV = () => {
const loadTransactions = async () => {
if (!exportContext) {
return lancamentos;
}
const result = await exportTransactionsDataAction(exportContext);
if (!result.success) {
throw new Error(result.error);
}
return result.data?.transactions ?? [];
};
const exportToCSV = async () => {
try {
setIsExporting(true);
const transactions = await loadTransactions();
const headers = [
"Data",
@@ -86,7 +113,7 @@ export function TransactionsExport({
];
const rows: string[][] = [];
lancamentos.forEach((lancamento) => {
transactions.forEach((lancamento) => {
const row = [
formatDate(lancamento.purchaseDate),
getNameWithInstallment(lancamento),
@@ -127,9 +154,11 @@ export function TransactionsExport({
}
};
const exportToExcel = () => {
const exportToExcel = async () => {
try {
setIsExporting(true);
const transactions = await loadTransactions();
const XLSX = await loadXlsx();
const headers = [
"Data",
@@ -144,7 +173,7 @@ export function TransactionsExport({
];
const rows: (string | number)[][] = [];
lancamentos.forEach((lancamento) => {
transactions.forEach((lancamento) => {
const row = [
formatDate(lancamento.purchaseDate),
getNameWithInstallment(lancamento),
@@ -189,6 +218,8 @@ export function TransactionsExport({
const exportToPDF = async () => {
try {
setIsExporting(true);
const transactions = await loadTransactions();
const { jsPDF, autoTable } = await loadPdfDeps();
const doc = new jsPDF({ orientation: "landscape" });
const primaryColor = getPrimaryPdfColor();
@@ -245,7 +276,7 @@ export function TransactionsExport({
],
];
const body = lancamentos.map((lancamento) => [
const body = transactions.map((lancamento) => [
formatDate(lancamento.purchaseDate),
getNameWithInstallment(lancamento),
lancamento.transactionType,
@@ -285,7 +316,7 @@ export function TransactionsExport({
},
didParseCell: (cellData) => {
if (cellData.section === "body" && cellData.column.index === 5) {
const lancamento = lancamentos[cellData.row.index];
const lancamento = transactions[cellData.row.index];
if (lancamento) {
if (lancamento.transactionType === "Despesa") {
cellData.cell.styles.textColor = [220, 38, 38];

View File

@@ -0,0 +1,26 @@
export type TransactionExportFilters = {
transactionFilter: string | null;
conditionFilter: string | null;
paymentFilter: string | null;
payerFilter: string | null;
categoryFilter: string | null;
accountCardFilter: string | null;
searchFilter: string | null;
};
export type TransactionsExportContext = {
source: "transactions" | "account-statement";
period: string;
filters: TransactionExportFilters;
accountId?: string | null;
cardId?: string | null;
payerId?: string | null;
settledOnly?: boolean;
};
export type TransactionsPaginationState = {
page: number;
pageSize: number;
totalItems: number;
totalPages: number;
};

View File

@@ -29,6 +29,9 @@ export type ResolvedSearchParams =
| Record<string, string | string[] | undefined>
| undefined;
export const TRANSACTIONS_DEFAULT_PAGE_SIZE = 30;
export const TRANSACTIONS_PAGE_SIZE_OPTIONS = [5, 10, 20, 30, 40, 50, 100];
export type TransactionSearchFilters = {
transactionFilter: string | null;
conditionFilter: string | null;
@@ -126,6 +129,26 @@ export const extractTransactionSearchFilters = (
searchFilter: getSingleParam(params, "q"),
});
export const resolveTransactionPagination = (
params: ResolvedSearchParams,
): {
page: number;
pageSize: number;
} => {
const pageParam = Number.parseInt(getSingleParam(params, "page") ?? "", 10);
const pageSizeParam = Number.parseInt(
getSingleParam(params, "pageSize") ?? "",
10,
);
const page = Number.isFinite(pageParam) && pageParam > 0 ? pageParam : 1;
const pageSize = TRANSACTIONS_PAGE_SIZE_OPTIONS.includes(pageSizeParam)
? pageSizeParam
: TRANSACTIONS_DEFAULT_PAGE_SIZE;
return { page, pageSize };
};
const normalizeLabel = (value: string | null | undefined) =>
value?.trim().length ? value.trim() : "Sem descrição";

View File

@@ -1,4 +1,15 @@
import { and, desc, eq, gte, isNull, ne, or, type SQL } from "drizzle-orm";
import {
and,
count,
desc,
eq,
gte,
isNull,
ne,
or,
type SQL,
sql,
} from "drizzle-orm";
import {
cards,
categories,
@@ -9,6 +20,109 @@ import {
import { INITIAL_BALANCE_NOTE } from "@/shared/lib/accounts/constants";
import { db } from "@/shared/lib/db";
type BaseTransactionQueryInput = {
filters: SQL[];
extraFilters?: SQL[];
excludeInitialBalanceFromIncome?: boolean;
};
type TransactionQueryInput = BaseTransactionQueryInput & {
limit?: number;
offset?: number;
};
export type PaginatedTransactionsResult = {
rows: Awaited<ReturnType<typeof fetchTransactions>>;
totalItems: number;
page: number;
pageSize: number;
totalPages: number;
};
const DEFAULT_EXCLUDE_INITIAL_BALANCE = true;
const buildInitialBalanceVisibilityFilter = () =>
or(
ne(transactions.note, INITIAL_BALANCE_NOTE),
isNull(financialAccounts.excludeInitialBalanceFromIncome),
eq(financialAccounts.excludeInitialBalanceFromIncome, false),
);
const buildTransactionsWhere = ({
filters,
extraFilters = [],
excludeInitialBalanceFromIncome = DEFAULT_EXCLUDE_INITIAL_BALANCE,
}: BaseTransactionQueryInput) => {
const whereFilters = [...filters, ...extraFilters];
if (excludeInitialBalanceFromIncome) {
const initialBalanceFilter = buildInitialBalanceVisibilityFilter();
if (initialBalanceFilter) {
whereFilters.push(initialBalanceFilter);
}
}
return and(...whereFilters);
};
const mapTransactionRows = (
transactionRows: {
transaction: typeof transactions.$inferSelect;
payer: typeof payers.$inferSelect | null;
financialAccount: typeof financialAccounts.$inferSelect | null;
card: typeof cards.$inferSelect | null;
category: typeof categories.$inferSelect | null;
}[],
) =>
transactionRows.map((row) => ({
...row.transaction,
payer: row.payer,
financialAccount: row.financialAccount,
card: row.card,
category: row.category,
}));
async function selectTransactionsWithRelations({
filters,
extraFilters = [],
excludeInitialBalanceFromIncome = DEFAULT_EXCLUDE_INITIAL_BALANCE,
limit,
offset,
}: TransactionQueryInput) {
const baseQuery = db
.select({
transaction: transactions,
payer: payers,
financialAccount: financialAccounts,
card: cards,
category: categories,
})
.from(transactions)
.leftJoin(payers, eq(transactions.payerId, payers.id))
.leftJoin(
financialAccounts,
eq(transactions.accountId, financialAccounts.id),
)
.leftJoin(cards, eq(transactions.cardId, cards.id))
.leftJoin(categories, eq(transactions.categoryId, categories.id))
.where(
buildTransactionsWhere({
filters,
extraFilters,
excludeInitialBalanceFromIncome,
}),
)
.orderBy(desc(transactions.purchaseDate), desc(transactions.createdAt));
const transactionRows =
typeof limit === "number"
? await baseQuery.limit(limit).offset(offset ?? 0)
: await baseQuery;
return mapTransactionRows(transactionRows);
}
export async function fetchTransactionFilterSources(userId: string) {
const [payerRows, accountRows, cardRows, categoryRows] = await Promise.all([
db.query.payers.findMany({
@@ -31,44 +145,98 @@ export async function fetchTransactionFilterSources(userId: string) {
return { payerRows, accountRows, cardRows, categoryRows };
}
export async function fetchTransactionsWithRelations(
input: BaseTransactionQueryInput,
) {
return selectTransactionsWithRelations(input);
}
export async function fetchTransactions(filters: SQL[]) {
const transactionRows = await db
.select({
transaction: transactions,
payer: payers,
financialAccount: financialAccounts,
card: cards,
category: categories,
})
return fetchTransactionsWithRelations({ filters });
}
export async function fetchTransactionsPage(
filters: SQL[],
{
page,
pageSize,
}: {
page: number;
pageSize: number;
},
): Promise<PaginatedTransactionsResult> {
const [countRow] = await db
.select({ total: count() })
.from(transactions)
.leftJoin(payers, eq(transactions.payerId, payers.id))
.leftJoin(
financialAccounts,
eq(transactions.accountId, financialAccounts.id),
)
.leftJoin(cards, eq(transactions.cardId, cards.id))
.leftJoin(categories, eq(transactions.categoryId, categories.id))
.where(
and(
...filters,
// Excluir saldos iniciais de financialAccounts que têm excludeInitialBalanceFromIncome = true
or(
ne(transactions.note, INITIAL_BALANCE_NOTE),
isNull(financialAccounts.excludeInitialBalanceFromIncome),
eq(financialAccounts.excludeInitialBalanceFromIncome, false),
),
),
)
.orderBy(desc(transactions.purchaseDate), desc(transactions.createdAt));
.where(buildTransactionsWhere({ filters }));
// Transformar resultado para o formato esperado
return transactionRows.map((row) => ({
...row.transaction,
payer: row.payer,
financialAccount: row.financialAccount,
card: row.card,
category: row.category,
}));
const totalItems = Number(countRow?.total ?? 0);
const totalPages = Math.max(Math.ceil(totalItems / pageSize), 1);
const currentPage = Math.min(page, totalPages);
const rows = await selectTransactionsWithRelations({
filters,
limit: pageSize,
offset: (currentPage - 1) * pageSize,
});
return {
rows,
totalItems,
page: currentPage,
pageSize,
totalPages,
};
}
export async function fetchTransactionsPageWithRelations({
filters,
page,
pageSize,
extraFilters = [],
excludeInitialBalanceFromIncome = DEFAULT_EXCLUDE_INITIAL_BALANCE,
}: BaseTransactionQueryInput & {
page: number;
pageSize: number;
}): Promise<PaginatedTransactionsResult> {
const [countRow] = await db
.select({ total: count() })
.from(transactions)
.leftJoin(
financialAccounts,
eq(transactions.accountId, financialAccounts.id),
)
.leftJoin(cards, eq(transactions.cardId, cards.id))
.where(
buildTransactionsWhere({
filters,
extraFilters,
excludeInitialBalanceFromIncome,
}),
);
const totalItems = Number(countRow?.total ?? 0);
const totalPages = Math.max(Math.ceil(totalItems / pageSize), 1);
const currentPage = Math.min(page, totalPages);
const rows = await selectTransactionsWithRelations({
filters,
extraFilters,
excludeInitialBalanceFromIncome,
limit: pageSize,
offset: (currentPage - 1) * pageSize,
});
return {
rows,
totalItems,
page: currentPage,
pageSize,
totalPages,
};
}
export async function fetchRecentEstablishments(
@@ -84,22 +252,15 @@ export async function fetchRecentEstablishments(
and(
eq(transactions.userId, userId),
gte(transactions.purchaseDate, threeMonthsAgo),
sql`TRIM(${transactions.name}) <> ''`,
sql`LOWER(${transactions.name}) NOT LIKE 'pagamento fatura%'`,
),
)
.orderBy(desc(transactions.purchaseDate));
.groupBy(transactions.name)
.orderBy(sql`MAX(${transactions.purchaseDate}) DESC`)
.limit(100);
const uniqueNames = Array.from(
new Set<string>(
results
.map((row) => row.name)
.filter(
(name: string | null): name is string =>
name != null &&
name.trim().length > 0 &&
!name.toLowerCase().startsWith("pagamento fatura"),
),
),
);
return uniqueNames.slice(0, 100);
return results
.map((row) => row.name)
.filter((name): name is string => name !== null);
}

View File

@@ -22,14 +22,9 @@ export default function MonthNavigation() {
const returnTarget = buildHref(defaultPeriod);
const isDifferentFromCurrent = period !== defaultPeriod;
// Prefetch otimizado: apenas meses adjacentes (M-1, M+1) e mês atual
// Isso melhora a performance da navegação sem sobrecarregar o cliente
useEffect(() => {
// Prefetch do mês anterior e próximo para navegação instantânea
router.prefetch(prevTarget);
router.prefetch(nextTarget);
// Prefetch do mês atual se não estivermos nele
if (isDifferentFromCurrent) {
router.prefetch(returnTarget);
}

View File

@@ -28,6 +28,7 @@ export function useMonthPeriod() {
const buildHref = (targetPeriod: string) => {
const params = new URLSearchParams(searchParams.toString());
params.set(PERIOD_PARAM, formatPeriodForUrl(targetPeriod));
params.delete("page");
return `${pathname}?${params.toString()}`;
};