refactor: faxina arquitetural — código morto, identificadores em inglês e estrutura padronizada

Refatoração estrutural sem mudanças funcionais. Saldo líquido: −428 linhas.

Removido:
- 14 funções/constantes mortas verificadas via grep no repo todo: validateCategoriaOwnership,
  getInstallmentAnticipationsAction, getAnticipationDetailsAction, formatDecimalForDb,
  currencyFormatterNoCents, optionalDecimalSchema, formatMonthLabel,
  getGoalProgressStatusColorClass, MONTH_PERIOD_PARAM, calculateRemainingInstallments,
  e 5 funções fetch* não usadas em inbox/queries.ts.
- 1 tipo morto (ImportRow) + 2 órfãos consequentes (InstallmentAnticipationWithRelations,
  GoalProgressStatus convertido em interno).
- ~30 export keywords desnecessários (símbolos usados apenas no próprio arquivo).
- Re-exports mortos em barrels: EstablishmentLogoPicker, CategoryReportSkeleton,
  WidgetSkeleton, toNameKey.
- Arquivo features/reports/types.ts (barrel inteiro era órfão).

Padronizado (PT-BR→EN em identificadores expostos):
- 4 constantes globais (LANCAMENTOS_* → TRANSACTIONS_*).
- 12 tipos/interfaces (Lancamento*/Pagador*/Estabelecimento* → equivalentes EN).
- 13 funções/components exportados (fetchPagador*, EstabelecimentoInput, PagadorInfoCard, etc.).
- 5 props cross-file (preLancamentosCount → inboxPendingCount, pagadorAvatarUrl → payerAvatarUrl, etc.).
- Mantidas em PT-BR conforme exceção do CLAUDE.md: variáveis locais (pagador, categoria,
  lancamento), accessor key pagadorName (persistida em preferências), strings de UI.

Reorganizado:
- transactions/: 14 helpers soltos na raiz movidos para lib/; barrel actions.ts reduzido
  de 76 linhas de wrappers para 14 linhas de re-exports puros; anticipation-actions.ts
  movido para actions/anticipation.ts.
- dashboard/: 8 helpers soltos consolidados em dashboard/lib/.
- reports/: 5 query files na raiz consolidados em reports/lib/.
- payers/: detail-actions.ts (21KB) e detail-queries.ts movidos para payers/lib/.
- shared/components/: 9 dos 16 componentes soltos agrupados em brand/, widgets/, feedback/.
- shared/lib/fetch-json.ts movido para shared/utils/fetch-json.ts.

Validação: pnpm exec tsc --noEmit (0 erros), biome check (0 issues), knip (sem unused).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Felipe Coutinho
2026-05-06 18:42:54 +00:00
parent b9b843b9db
commit 7d0781b035
229 changed files with 415 additions and 872 deletions

View File

@@ -0,0 +1,99 @@
import { and, desc, eq } from "drizzle-orm";
import {
categories,
installmentAnticipations,
payers,
transactions,
} from "@/db/schema";
import { db } from "@/shared/lib/db";
import { uuidSchema } from "@/shared/lib/schemas/common";
type InstallmentAnticipationListItem = {
id: string;
anticipationPeriod: string;
anticipationDate: string;
installmentCount: number;
totalAmount: string;
discount: string;
transactionId: string;
note: string | null;
transaction: {
isSettled: boolean | null;
} | null;
payer: {
name: string;
} | null;
category: {
name: string;
} | null;
};
export async function fetchInstallmentAnticipations(
userId: string,
seriesId: string,
): Promise<InstallmentAnticipationListItem[]> {
const validatedSeriesId = uuidSchema("Série").parse(seriesId);
const anticipations = await db
.select({
id: installmentAnticipations.id,
anticipationPeriod: installmentAnticipations.anticipationPeriod,
anticipationDate: installmentAnticipations.anticipationDate,
installmentCount: installmentAnticipations.installmentCount,
totalAmount: installmentAnticipations.totalAmount,
discount: installmentAnticipations.discount,
transactionId: installmentAnticipations.transactionId,
note: installmentAnticipations.note,
transactionRecordId: transactions.id,
transactionIsSettled: transactions.isSettled,
payerName: payers.name,
categoryName: categories.name,
})
.from(installmentAnticipations)
.leftJoin(
transactions,
and(
eq(installmentAnticipations.transactionId, transactions.id),
eq(transactions.userId, userId),
),
)
.leftJoin(
payers,
and(
eq(installmentAnticipations.payerId, payers.id),
eq(payers.userId, userId),
),
)
.leftJoin(
categories,
and(
eq(installmentAnticipations.categoryId, categories.id),
eq(categories.userId, userId),
),
)
.where(
and(
eq(installmentAnticipations.seriesId, validatedSeriesId),
eq(installmentAnticipations.userId, userId),
),
)
.orderBy(desc(installmentAnticipations.createdAt));
return anticipations.map((anticipation) => ({
id: anticipation.id,
anticipationPeriod: anticipation.anticipationPeriod,
anticipationDate: anticipation.anticipationDate.toISOString(),
installmentCount: anticipation.installmentCount,
totalAmount: anticipation.totalAmount,
discount: anticipation.discount,
transactionId: anticipation.transactionId,
note: anticipation.note,
transaction: anticipation.transactionRecordId
? { isSettled: anticipation.transactionIsSettled }
: null,
payer: anticipation.payerName ? { name: anticipation.payerName } : null,
category: anticipation.categoryName
? { name: anticipation.categoryName }
: null,
}));
}

View File

@@ -0,0 +1,107 @@
import { randomUUID } from "node:crypto";
import { CopyObjectCommand } from "@aws-sdk/client-s3";
import { eq } from "drizzle-orm";
import { attachments, transactionAttachments, transactions } from "@/db/schema";
import { db } from "@/shared/lib/db";
import { getPayerAccess } from "@/shared/lib/payers/access";
import { deleteS3Object } from "@/shared/lib/storage/presign";
import { S3_BUCKET, s3 } from "@/shared/lib/storage/s3-client";
const SAFE_EXTENSION = /^[a-z0-9]{1,10}$/i;
function sanitizeExtension(fileKey: string): string {
const ext = fileKey.split(".").pop() ?? "";
return SAFE_EXTENSION.test(ext) ? ext.toLowerCase() : "bin";
}
export async function copyAttachmentsForImport({
sourceTransactionId,
targetTransactionIds,
targetUserId,
}: {
sourceTransactionId: string;
targetTransactionIds: string[];
targetUserId: string;
}): Promise<void> {
if (targetTransactionIds.length === 0) return;
const [source] = await db
.select({
id: transactions.id,
userId: transactions.userId,
payerId: transactions.payerId,
})
.from(transactions)
.where(eq(transactions.id, sourceTransactionId));
if (!source) return;
if (source.userId !== targetUserId) {
if (!source.payerId) return;
const access = await getPayerAccess(targetUserId, source.payerId);
if (!access) return;
}
const sourceAttachments = await db
.select({
fileKey: attachments.fileKey,
fileName: attachments.fileName,
fileSize: attachments.fileSize,
mimeType: attachments.mimeType,
})
.from(transactionAttachments)
.innerJoin(
attachments,
eq(transactionAttachments.attachmentId, attachments.id),
)
.where(eq(transactionAttachments.transactionId, sourceTransactionId));
if (sourceAttachments.length === 0) return;
for (const src of sourceAttachments) {
const newFileKey = `${targetUserId}/${randomUUID()}.${sanitizeExtension(src.fileKey)}`;
try {
await s3.send(
new CopyObjectCommand({
Bucket: S3_BUCKET,
CopySource: `${S3_BUCKET}/${src.fileKey}`,
Key: newFileKey,
ContentType: src.mimeType,
MetadataDirective: "COPY",
}),
);
} catch (error) {
console.error("Falha ao copiar anexo no S3:", error);
continue;
}
try {
const [newAttachment] = await db
.insert(attachments)
.values({
userId: targetUserId,
fileKey: newFileKey,
fileName: src.fileName,
fileSize: src.fileSize,
mimeType: src.mimeType,
})
.returning({ id: attachments.id });
if (!newAttachment) {
await deleteS3Object(newFileKey);
continue;
}
await db.insert(transactionAttachments).values(
targetTransactionIds.map((tid) => ({
transactionId: tid,
attachmentId: newAttachment.id,
})),
);
} catch (error) {
console.error("Falha ao registrar anexo copiado:", error);
await deleteS3Object(newFileKey).catch(() => {});
}
}
}

View File

@@ -0,0 +1,65 @@
import { eq } from "drizzle-orm";
import { attachments, transactionAttachments, transactions } from "@/db/schema";
import { db } from "@/shared/lib/db";
import { getPayerAccess } from "@/shared/lib/payers/access";
import { createPresignedGetUrl } from "@/shared/lib/storage/presign";
export type TransactionAttachmentListItem = {
attachmentId: string;
fileName: string;
fileSize: number;
mimeType: string;
createdAt: string;
url: string;
};
export async function fetchTransactionAttachments(
userId: string,
transactionId: string,
): Promise<TransactionAttachmentListItem[]> {
const [transaction] = await db
.select({
id: transactions.id,
userId: transactions.userId,
payerId: transactions.payerId,
})
.from(transactions)
.where(eq(transactions.id, transactionId));
if (!transaction) {
return [];
}
if (transaction.userId !== userId) {
if (!transaction.payerId) return [];
const access = await getPayerAccess(userId, transaction.payerId);
if (!access) return [];
}
const rows = await db
.select({
attachmentId: transactionAttachments.attachmentId,
fileName: attachments.fileName,
fileSize: attachments.fileSize,
mimeType: attachments.mimeType,
fileKey: attachments.fileKey,
createdAt: attachments.createdAt,
})
.from(transactionAttachments)
.innerJoin(
attachments,
eq(transactionAttachments.attachmentId, attachments.id),
)
.where(eq(transactionAttachments.transactionId, transactionId));
return Promise.all(
rows.map(async (row) => ({
attachmentId: row.attachmentId,
fileName: row.fileName,
fileSize: row.fileSize,
mimeType: row.mimeType,
createdAt: row.createdAt.toISOString(),
url: await createPresignedGetUrl(row.fileKey),
})),
);
}

View File

@@ -0,0 +1,13 @@
export const ALLOWED_MIME_TYPES = [
"application/pdf",
"image/jpeg",
"image/png",
"image/webp",
] as const;
export const DEFAULT_MAX_FILE_SIZE_MB = 50;
export const MAX_FILE_SIZE = DEFAULT_MAX_FILE_SIZE_MB * 1024 * 1024; // 50MB (fallback)
export const ATTACHMENT_SIZE_OPTIONS = [5, 10, 25, 50, 100] as const;
export type AttachmentSizeOption = (typeof ATTACHMENT_SIZE_OPTIONS)[number];

View File

@@ -0,0 +1,73 @@
import type { SelectOption } from "@/features/transactions/components/types";
import { capitalize } from "@/shared/utils/string";
/**
* Group label for category options
*/
type CategoryGroup = {
label: string;
options: SelectOption[];
};
/**
* Normalizes category group labels (Despesa -> Despesas, Receita -> Receitas)
*/
function normalizeCategoryGroupLabel(value: string): string {
const lower = value.toLowerCase();
if (lower === "despesa") {
return "Despesas";
}
if (lower === "receita") {
return "Receitas";
}
return capitalize(value);
}
/**
* Groups and sorts category options by their group property
* @param categoryOptions - Array of category select options
* @returns Array of grouped and sorted category options
*/
export function groupAndSortCategories(
categoryOptions: SelectOption[],
): CategoryGroup[] {
// Group category options by their group property
const groups = categoryOptions.reduce<Record<string, SelectOption[]>>(
(acc, option) => {
const key = option.group ?? "Outros";
if (!acc[key]) {
acc[key] = [];
}
acc[key].push(option);
return acc;
},
{},
);
// Define preferred order (Despesa first, then Receita, then others)
const preferredOrder = ["Despesa", "Receita"];
const orderedKeys = [
...preferredOrder.filter((key) => groups[key]?.length),
...Object.keys(groups).filter((key) => !preferredOrder.includes(key)),
];
// Map to final structure with normalized labels and sorted options
return orderedKeys.map((key) => ({
label: normalizeCategoryGroupLabel(key),
options: groups[key]
.slice()
.sort((a, b) =>
a.label.localeCompare(b.label, "pt-BR", { sensitivity: "base" }),
),
}));
}
/**
* Filters secondary payer options to exclude the primary payer
*/
export function filterSecondaryPayerOptions(
allOptions: SelectOption[],
primaryPayerId?: string,
): SelectOption[] {
return allOptions.filter((option) => option.value !== primaryPayerId);
}

View File

@@ -0,0 +1,31 @@
/**
* Ids das colunas reordenáveis da tabela de lançamentos (extrato).
* select, purchaseDate e actions são fixos (início, oculto, fim).
*/
const TRANSACTIONS_REORDERABLE_COLUMN_IDS = [
"name",
"transactionType",
"amount",
"condition",
"paymentMethod",
"categoriaName",
"pagadorName",
"note",
"contaCartao",
] as const;
export const TRANSACTIONS_COLUMN_LABELS: Record<string, string> = {
name: "Estabelecimento",
transactionType: "Transação",
amount: "Valor",
condition: "Condição",
paymentMethod: "Forma de Pagamento",
categoriaName: "Categoria",
pagadorName: "Pessoa",
note: "Anotação",
contaCartao: "Conta/Cartão",
};
export const DEFAULT_TRANSACTIONS_COLUMN_ORDER: string[] = [
...TRANSACTIONS_REORDERABLE_COLUMN_IDS,
];

View File

@@ -0,0 +1,32 @@
export const TRANSACTION_TYPES = [
"Despesa",
"Receita",
"Transferência",
] as const;
export const TRANSACTION_CONDITIONS = [
"À vista",
"Parcelado",
"Recorrente",
] as const;
export const PAYMENT_METHODS = [
"Cartão de crédito",
"Cartão de débito",
"Pix",
"Dinheiro",
"Boleto",
"Pré-Pago | VR/VA",
"Transferência bancária",
] as const;
export const CREDIT_CARD_PAYMENT_METHOD = "Cartão de crédito" as const;
export const SETTLEABLE_PAYMENT_METHODS = PAYMENT_METHODS.filter(
(method) => method !== CREDIT_CARD_PAYMENT_METHOD,
);
export const SETTLED_FILTER_VALUES = {
PAID: "pago",
UNPAID: "nao-pago",
} as const;

View File

@@ -0,0 +1,29 @@
type TransactionExportFilters = {
transactionFilter: string | null;
conditionFilter: string | null;
paymentFilter: string | null;
payerFilter: string | null;
categoryFilter: string | null;
accountCardFilter: string | null;
searchFilter: string | null;
settledFilter: string | null;
attachmentFilter: string | null;
dividedFilter: 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

@@ -0,0 +1,358 @@
import type { TransactionItem } from "@/features/transactions/components/types";
import { getTodayDateString } from "@/shared/utils/date";
import { derivePeriodFromDate, getNextPeriod } from "@/shared/utils/period";
import {
PAYMENT_METHODS,
TRANSACTION_CONDITIONS,
TRANSACTION_TYPES,
} from "./constants";
/**
* Derives the fatura period for a credit card purchase based on closing day
* and due day. The period represents the month the fatura is due (vencimento).
*
* Steps:
* 1. If purchase day >= closing day → the purchase missed this month's closing,
* so it enters the NEXT month's billing cycle (+1 month from purchase).
* 2. Then, if dueDay < closingDay, the due date falls in the month AFTER the
* closing month (e.g., closes 22nd, due 1st → closes Mar/22, due Apr/1),
* so we add another +1 month.
*
* @example
* // Card closes day 22, due day 1 (dueDay < closingDay → +1 extra)
* deriveCreditCardPeriod("2026-02-25", "22", "1") // "2026-04" (missed Feb closing → Mar cycle → due Apr)
* deriveCreditCardPeriod("2026-02-15", "22", "1") // "2026-03" (in Feb cycle → due Mar)
*
* // Card closes day 5, due day 15 (dueDay >= closingDay → no extra)
* deriveCreditCardPeriod("2026-02-10", "5", "15") // "2026-03" (missed Feb closing → Mar cycle → due Mar)
* deriveCreditCardPeriod("2026-02-05", "5", "15") // "2026-03" (closing day itself already goes to next cycle)
* deriveCreditCardPeriod("2026-02-03", "5", "15") // "2026-02" (in Feb cycle → due Feb)
*/
export function deriveCreditCardPeriod(
purchaseDate: string,
closingDay: string | null | undefined,
dueDay?: string | null | undefined,
): string {
const basePeriod = derivePeriodFromDate(purchaseDate);
if (!closingDay) return basePeriod;
const closingDayNum = Number.parseInt(closingDay, 10);
if (Number.isNaN(closingDayNum)) return basePeriod;
const dayPart = purchaseDate.split("-")[2];
const purchaseDayNum = Number.parseInt(dayPart ?? "1", 10);
// Start with the purchase month as the billing cycle
let period = basePeriod;
// If purchase is on/after closing day, it enters the next billing cycle
if (purchaseDayNum >= closingDayNum) {
period = getNextPeriod(period);
}
// If due day < closing day, the due date falls in the month after closing
// (e.g., closes 22nd, due 1st → closing in March means due in April)
const dueDayNum = Number.parseInt(dueDay ?? "", 10);
if (!Number.isNaN(dueDayNum) && dueDayNum < closingDayNum) {
period = getNextPeriod(period);
}
return period;
}
/**
* Form state type for lancamento dialog
*/
export type TransactionFormState = {
purchaseDate: string;
period: string;
name: string;
transactionType: string;
amount: string;
condition: string;
paymentMethod: string;
payerId: string | undefined;
secondaryPayerId: string | undefined;
isSplit: boolean;
primarySplitAmount: string;
secondarySplitAmount: string;
accountId: string | undefined;
cardId: string | undefined;
categoryId: string | undefined;
installmentCount: string;
recurrenceCount: string;
dueDate: string;
boletoPaymentDate: string;
note: string;
isSettled: boolean | null;
};
/**
* Initial state overrides for lancamento form
*/
type TransactionFormOverrides = {
defaultCardId?: string | null;
defaultPaymentMethod?: string | null;
defaultPurchaseDate?: string | null;
defaultName?: string | null;
defaultAmount?: string | null;
defaultTransactionType?: "Despesa" | "Receita";
isImporting?: boolean;
};
/**
* Builds initial form state from lancamento data and defaults
*/
export function buildTransactionInitialState(
transaction?: TransactionItem,
defaultPayerId?: string | null,
preferredPeriod?: string,
overrides?: TransactionFormOverrides,
): TransactionFormState {
const purchaseDate = transaction?.purchaseDate
? transaction.purchaseDate.slice(0, 10)
: (overrides?.defaultPurchaseDate ?? getTodayDateString());
const paymentMethod =
transaction?.paymentMethod ??
overrides?.defaultPaymentMethod ??
PAYMENT_METHODS[0];
const derivedPeriod = derivePeriodFromDate(purchaseDate);
const fallbackPeriod =
preferredPeriod && /^\d{4}-\d{2}$/.test(preferredPeriod)
? preferredPeriod
: derivedPeriod;
// Quando importando, usar valores padrão do usuário logado ao invés dos valores do lançamento original
const isImporting = overrides?.isImporting ?? false;
const fallbackPayerId = isImporting
? (defaultPayerId ?? null)
: (transaction?.payerId ?? defaultPayerId ?? null);
const boletoPaymentDate =
transaction?.boletoPaymentDate ??
(paymentMethod === "Boleto" && (transaction?.isSettled ?? false)
? getTodayDateString()
: "");
// Calcular o valor correto para importação de parcelados
let amountValue = overrides?.defaultAmount ?? "";
if (!amountValue && typeof transaction?.amount === "number") {
let baseAmount = Math.abs(transaction.amount);
// Se está importando e é parcelado, usar o valor total (parcela * quantidade)
if (
isImporting &&
transaction.condition === "Parcelado" &&
transaction.installmentCount
) {
baseAmount = baseAmount * transaction.installmentCount;
}
amountValue = (Math.round(baseAmount * 100) / 100).toFixed(2);
}
return {
purchaseDate,
period:
transaction?.period && /^\d{4}-\d{2}$/.test(transaction.period)
? transaction.period
: fallbackPeriod,
name: transaction?.name ?? overrides?.defaultName ?? "",
transactionType:
transaction?.transactionType ??
overrides?.defaultTransactionType ??
TRANSACTION_TYPES[0],
amount: amountValue,
condition: transaction?.condition ?? TRANSACTION_CONDITIONS[0],
paymentMethod,
payerId: fallbackPayerId ?? undefined,
secondaryPayerId: undefined,
isSplit: false,
primarySplitAmount: "",
secondarySplitAmount: "",
accountId:
paymentMethod === "Cartão de crédito"
? undefined
: isImporting
? undefined
: (transaction?.accountId ?? undefined),
cardId:
paymentMethod === "Cartão de crédito"
? isImporting
? (overrides?.defaultCardId ?? undefined)
: (transaction?.cardId ?? overrides?.defaultCardId ?? undefined)
: undefined,
categoryId: isImporting
? undefined
: (transaction?.categoryId ?? undefined),
installmentCount: transaction?.installmentCount
? String(transaction.installmentCount)
: "",
recurrenceCount: transaction?.recurrenceCount
? String(transaction.recurrenceCount)
: "",
dueDate: transaction?.dueDate ?? "",
boletoPaymentDate,
note: transaction?.note ?? "",
isSettled:
paymentMethod === "Cartão de crédito"
? null
: (transaction?.isSettled ?? true),
};
}
/**
* Applies field dependencies when form state changes
* This function encapsulates the business logic for field interdependencies
*/
export function applyFieldDependencies(
key: keyof TransactionFormState,
value: TransactionFormState[keyof TransactionFormState],
currentState: TransactionFormState,
cardInfo?: { closingDay: string | null; dueDay: string | null } | null,
): Partial<TransactionFormState> {
const updates: Partial<TransactionFormState> = {};
// Auto-derive period from purchaseDate
if (key === "purchaseDate" && typeof value === "string" && value) {
const method = currentState.paymentMethod;
if (method === "Cartão de crédito") {
updates.period = deriveCreditCardPeriod(
value,
cardInfo?.closingDay,
cardInfo?.dueDay,
);
} else if (method !== "Boleto") {
updates.period = derivePeriodFromDate(value);
}
}
// Auto-derive period from dueDate when payment method is boleto
if (key === "dueDate" && typeof value === "string" && value) {
if (currentState.paymentMethod === "Boleto") {
updates.period = derivePeriodFromDate(value);
}
}
// Auto-derive period when cardId changes (credit card selected)
if (key === "cardId" && currentState.paymentMethod === "Cartão de crédito") {
if (typeof value === "string" && value && currentState.purchaseDate) {
updates.period = deriveCreditCardPeriod(
currentState.purchaseDate,
cardInfo?.closingDay,
cardInfo?.dueDay,
);
}
}
// When condition changes, clear irrelevant fields
if (key === "condition" && typeof value === "string") {
if (value !== "Parcelado") {
updates.installmentCount = "";
}
if (value !== "Recorrente") {
updates.recurrenceCount = "";
}
}
// When payment method changes, adjust related fields
if (key === "paymentMethod" && typeof value === "string") {
if (value === "Cartão de crédito") {
updates.accountId = undefined;
updates.isSettled = null;
} else {
updates.cardId = undefined;
updates.isSettled = currentState.isSettled ?? true;
}
// Re-derive period based on new payment method
if (value === "Cartão de crédito") {
if (
currentState.purchaseDate &&
currentState.cardId &&
cardInfo?.closingDay
) {
updates.period = deriveCreditCardPeriod(
currentState.purchaseDate,
cardInfo.closingDay,
cardInfo.dueDay,
);
} else if (currentState.purchaseDate) {
updates.period = derivePeriodFromDate(currentState.purchaseDate);
}
} else if (value === "Boleto" && currentState.dueDate) {
updates.period = derivePeriodFromDate(currentState.dueDate);
} else if (currentState.purchaseDate) {
updates.period = derivePeriodFromDate(currentState.purchaseDate);
}
// Clear boleto-specific fields if not boleto
if (value !== "Boleto") {
updates.dueDate = "";
updates.boletoPaymentDate = "";
} else if (
currentState.isSettled ||
(updates.isSettled !== null && updates.isSettled !== undefined)
) {
// Set today's date for boleto payment if settled
const settled = updates.isSettled ?? currentState.isSettled;
if (settled) {
updates.boletoPaymentDate =
currentState.boletoPaymentDate || getTodayDateString();
}
}
}
// When split is disabled, clear secondary pagador and split fields
if (key === "isSplit" && value === false) {
updates.secondaryPayerId = undefined;
updates.primarySplitAmount = "";
updates.secondarySplitAmount = "";
}
// When split is enabled and amount exists, calculate initial split amounts
if (key === "isSplit" && value === true) {
const totalAmount = Number.parseFloat(currentState.amount) || 0;
if (totalAmount > 0) {
const half = (totalAmount / 2).toFixed(2);
updates.primarySplitAmount = half;
updates.secondarySplitAmount = half;
}
}
// When amount changes and split is enabled, recalculate split amounts
if (key === "amount" && typeof value === "string" && currentState.isSplit) {
const totalAmount = Number.parseFloat(value) || 0;
if (totalAmount > 0) {
const half = (totalAmount / 2).toFixed(2);
updates.primarySplitAmount = half;
updates.secondarySplitAmount = half;
} else {
updates.primarySplitAmount = "";
updates.secondarySplitAmount = "";
}
}
// When primary pagador changes, clear secondary if it matches
if (key === "payerId" && typeof value === "string") {
const secondaryValue = currentState.secondaryPayerId;
if (secondaryValue && secondaryValue === value) {
updates.secondaryPayerId = undefined;
}
}
// When isSettled changes and payment method is Boleto
if (key === "isSettled" && currentState.paymentMethod === "Boleto") {
if (value === true) {
updates.boletoPaymentDate =
currentState.boletoPaymentDate || getTodayDateString();
} else if (value === false) {
updates.boletoPaymentDate = "";
}
}
return updates;
}

View File

@@ -0,0 +1,66 @@
/**
* Formatting helpers for displaying lancamento data
*/
import {
currencyFormatter,
formatCurrency as formatCurrencyValue,
} from "@/shared/utils/currency";
import { formatDateOnly } from "@/shared/utils/date";
import { formatMonthYearLabel } from "@/shared/utils/period";
import { capitalize } from "@/shared/utils/string";
export { currencyFormatter };
/**
* Formats a date string to localized format
* @param value - ISO date string or null
* @returns Formatted date string or "—"
* @example formatDate("2024-01-15") => "15/01/2024"
*/
export function formatDate(value?: string | null): string {
if (!value) return "—";
return (
formatDateOnly(value, {
day: "2-digit",
month: "2-digit",
year: "numeric",
}) ?? "—"
);
}
/**
* Formats a period (YYYY-MM) to localized month label
* @param value - Period string (YYYY-MM) or null
* @returns Formatted period string or "—"
* @example formatPeriod("2024-01") => "Janeiro 2024"
*/
export function formatPeriod(value?: string | null): string {
if (!value) return "—";
try {
return formatMonthYearLabel(value);
} catch {
return value;
}
}
/**
* Formats a condition string with proper capitalization
* @param value - Condition string or null
* @returns Formatted condition string or "—"
* @example formatCondition("vista") => "À vista"
*/
export function formatCondition(value?: string | null): string {
if (!value) return "—";
if (value.toLowerCase() === "vista") return "À vista";
return capitalize(value);
}
/**
* Formats currency value
* @param value - Numeric value
* @returns Formatted currency string
* @example formatCurrency(1234.56) => "R$ 1.234,56"
*/
export function formatCurrency(value: number): string {
return formatCurrencyValue(value);
}

View File

@@ -0,0 +1,601 @@
import type { SQL } from "drizzle-orm";
import { and, eq, ilike, isNotNull, or, sql } from "drizzle-orm";
import {
cards,
type categories,
financialAccounts,
type payers,
transactionAttachments,
transactions,
} from "@/db/schema";
import type { SelectOption } from "@/features/transactions/components/types";
import {
PAYMENT_METHODS,
SETTLED_FILTER_VALUES,
TRANSACTION_CONDITIONS,
TRANSACTION_TYPES,
} from "@/features/transactions/lib/constants";
import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/shared/lib/accounts/constants";
import {
PAYER_ROLE_ADMIN,
PAYER_ROLE_THIRD_PARTY,
} from "@/shared/lib/payers/constants";
import { toDateOnlyString } from "@/shared/utils/date";
import { slugify } from "@/shared/utils/string";
type PayerRow = typeof payers.$inferSelect;
type AccountRow = typeof financialAccounts.$inferSelect;
type CardRow = typeof cards.$inferSelect;
type CategoryRow = typeof categories.$inferSelect;
export type ResolvedSearchParams =
| Record<string, string | string[] | undefined>
| undefined;
const TRANSACTIONS_DEFAULT_PAGE_SIZE = 30;
const TRANSACTIONS_PAGE_SIZE_OPTIONS = [5, 10, 20, 30, 40, 50, 100];
export type TransactionSearchFilters = {
transactionFilter: string | null;
conditionFilter: string | null;
paymentFilter: string | null;
payerFilter: string | null;
categoryFilter: string | null;
accountCardFilter: string | null;
searchFilter: string | null;
settledFilter: string | null;
attachmentFilter: string | null;
dividedFilter: string | null;
};
type BaseSluggedOption = {
id: string;
label: string;
slug: string;
};
type PayerSluggedOption = BaseSluggedOption & {
role: string | null;
avatarUrl: string | null;
};
type CategorySluggedOption = BaseSluggedOption & {
type: string | null;
icon: string | null;
};
type AccountSluggedOption = BaseSluggedOption & {
kind: "conta";
logo: string | null;
accountType: string | null;
};
type CardSluggedOption = BaseSluggedOption & {
kind: "cartao";
logo: string | null;
closingDay: string | null;
dueDay: string | null;
};
export type SluggedFilters = {
payerFiltersRaw: PayerSluggedOption[];
categoryFiltersRaw: CategorySluggedOption[];
accountFiltersRaw: AccountSluggedOption[];
cardFiltersRaw: CardSluggedOption[];
};
export type SlugMaps = {
payer: Map<string, string>;
category: Map<string, string>;
financialAccount: Map<string, string>;
card: Map<string, string>;
};
type FilterOption = {
slug: string;
label: string;
};
type AccountCardFilterOption = FilterOption & {
kind: "conta" | "cartao";
};
type TransactionOptionSets = {
payerOptions: SelectOption[];
splitPayerOptions: SelectOption[];
defaultPayerId: string | null;
accountOptions: SelectOption[];
cardOptions: SelectOption[];
categoryOptions: SelectOption[];
payerFilterOptions: FilterOption[];
categoryFilterOptions: FilterOption[];
accountCardFilterOptions: AccountCardFilterOption[];
};
export const getSingleParam = (
params: ResolvedSearchParams,
key: string,
): string | null => {
const value = params?.[key];
if (!value) {
return null;
}
return Array.isArray(value) ? (value[0] ?? null) : value;
};
export const extractTransactionSearchFilters = (
params: ResolvedSearchParams,
): TransactionSearchFilters => ({
transactionFilter: getSingleParam(params, "type"),
conditionFilter: getSingleParam(params, "condition"),
paymentFilter: getSingleParam(params, "payment"),
payerFilter: getSingleParam(params, "payer"),
categoryFilter: getSingleParam(params, "category"),
accountCardFilter: getSingleParam(params, "accountCard"),
searchFilter: getSingleParam(params, "q"),
settledFilter: getSingleParam(params, "settled"),
attachmentFilter: getSingleParam(params, "hasAttachment"),
dividedFilter: getSingleParam(params, "isDivided"),
});
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";
const typeSlugToValue = Object.fromEntries(
TRANSACTION_TYPES.map((t) => [slugify(t), t]),
) as Record<string, (typeof TRANSACTION_TYPES)[number]>;
const conditionSlugToValue = Object.fromEntries(
TRANSACTION_CONDITIONS.map((c) => [slugify(c), c]),
) as Record<string, (typeof TRANSACTION_CONDITIONS)[number]>;
const paymentSlugToValue = Object.fromEntries(
PAYMENT_METHODS.map((m) => [slugify(m), m]),
) as Record<string, (typeof PAYMENT_METHODS)[number]>;
const createSlugGenerator = () => {
const seen = new Map<string, number>();
return (label: string) => {
const base = slugify(label);
const count = seen.get(base) ?? 0;
seen.set(base, count + 1);
if (count === 0) {
return base;
}
return `${base}-${count + 1}`;
};
};
const toOption = (
value: string,
label: string | null | undefined,
role?: string | null,
group?: string | null,
slug?: string | null,
avatarUrl?: string | null,
logo?: string | null,
icon?: string | null,
accountType?: string | null,
closingDay?: string | null,
dueDay?: string | null,
): SelectOption => ({
value,
label: normalizeLabel(label),
role: role ?? null,
group: group ?? null,
slug: slug ?? null,
avatarUrl: avatarUrl ?? null,
logo: logo ?? null,
icon: icon ?? null,
accountType: accountType ?? null,
closingDay: closingDay ?? null,
dueDay: dueDay ?? null,
});
export const buildSluggedFilters = ({
payerRows,
categoryRows,
accountRows,
cardRows,
}: {
payerRows: PayerRow[];
categoryRows: CategoryRow[];
accountRows: AccountRow[];
cardRows: CardRow[];
}): SluggedFilters => {
const payerSlugger = createSlugGenerator();
const categorySlugger = createSlugGenerator();
const accountCardSlugger = createSlugGenerator();
const payerFiltersRaw = payerRows.map((payer) => {
const label = normalizeLabel(payer.name);
return {
id: payer.id,
label,
slug: payerSlugger(label),
role: payer.role ?? null,
avatarUrl: payer.avatarUrl ?? null,
};
});
const categoryFiltersRaw = categoryRows.map((category) => {
const label = normalizeLabel(category.name);
return {
id: category.id,
label,
slug: categorySlugger(label),
type: category.type ?? null,
icon: category.icon ?? null,
};
});
const accountFiltersRaw = accountRows.map((account) => {
const label = normalizeLabel(account.name);
return {
id: account.id,
label,
slug: accountCardSlugger(label),
kind: "conta" as const,
logo: account.logo ?? null,
accountType: account.accountType ?? null,
};
});
const cardFiltersRaw = cardRows.map((card) => {
const label = normalizeLabel(card.name);
return {
id: card.id,
label,
slug: accountCardSlugger(label),
kind: "cartao" as const,
logo: card.logo ?? null,
closingDay: card.closingDay ?? null,
dueDay: card.dueDay ?? null,
};
});
return {
payerFiltersRaw,
categoryFiltersRaw,
accountFiltersRaw,
cardFiltersRaw,
};
};
export const buildSlugMaps = ({
payerFiltersRaw,
categoryFiltersRaw,
accountFiltersRaw,
cardFiltersRaw,
}: SluggedFilters): SlugMaps => ({
payer: new Map(payerFiltersRaw.map(({ slug, id }) => [slug, id])),
category: new Map(categoryFiltersRaw.map(({ slug, id }) => [slug, id])),
financialAccount: new Map(
accountFiltersRaw.map(({ slug, id }) => [slug, id]),
),
card: new Map(cardFiltersRaw.map(({ slug, id }) => [slug, id])),
});
const isValidTransaction = (
value: string | null,
): value is (typeof TRANSACTION_TYPES)[number] =>
!!value && (TRANSACTION_TYPES as readonly string[]).includes(value ?? "");
const isValidCondition = (
value: string | null,
): value is (typeof TRANSACTION_CONDITIONS)[number] =>
!!value &&
(TRANSACTION_CONDITIONS as readonly string[]).includes(value ?? "");
const isValidPaymentMethod = (
value: string | null,
): value is (typeof PAYMENT_METHODS)[number] =>
!!value && (PAYMENT_METHODS as readonly string[]).includes(value ?? "");
const buildSearchPattern = (value: string | null) =>
value ? `%${value.trim().replace(/\s+/g, "%")}%` : null;
export const buildTransactionWhere = ({
userId,
period,
filters,
slugMaps,
cardId,
accountId,
payerId,
}: {
userId: string;
period: string;
filters: TransactionSearchFilters;
slugMaps: SlugMaps;
cardId?: string;
accountId?: string;
payerId?: string;
}): SQL[] => {
const where: SQL[] = [
eq(transactions.userId, userId),
eq(transactions.period, period),
];
if (payerId) {
where.push(eq(transactions.payerId, payerId));
}
if (cardId) {
where.push(eq(transactions.cardId, cardId));
}
if (accountId) {
where.push(eq(transactions.accountId, accountId));
}
const typeValue = typeSlugToValue[filters.transactionFilter ?? ""] ?? null;
if (isValidTransaction(typeValue)) {
where.push(eq(transactions.transactionType, typeValue));
}
const conditionValue =
conditionSlugToValue[filters.conditionFilter ?? ""] ?? null;
if (isValidCondition(conditionValue)) {
where.push(eq(transactions.condition, conditionValue));
}
const paymentValue = paymentSlugToValue[filters.paymentFilter ?? ""] ?? null;
if (isValidPaymentMethod(paymentValue)) {
where.push(eq(transactions.paymentMethod, paymentValue));
}
if (!payerId && filters.payerFilter) {
const id = slugMaps.payer.get(filters.payerFilter);
if (id) {
where.push(eq(transactions.payerId, id));
}
}
if (filters.categoryFilter) {
const id = slugMaps.category.get(filters.categoryFilter);
if (id) {
where.push(eq(transactions.categoryId, id));
}
}
if (filters.accountCardFilter) {
const accountId = slugMaps.financialAccount.get(filters.accountCardFilter);
const relatedCardId = accountId
? null
: slugMaps.card.get(filters.accountCardFilter);
if (accountId) {
where.push(eq(transactions.accountId, accountId));
}
if (!accountId && relatedCardId) {
where.push(eq(transactions.cardId, relatedCardId));
}
}
if (filters.settledFilter === SETTLED_FILTER_VALUES.PAID) {
where.push(eq(transactions.isSettled, true));
} else if (filters.settledFilter === SETTLED_FILTER_VALUES.UNPAID) {
where.push(eq(transactions.isSettled, false));
}
if (filters.attachmentFilter === "true") {
where.push(
sql`EXISTS (SELECT 1 FROM ${transactionAttachments} WHERE ${transactionAttachments.transactionId} = ${transactions.id})`,
);
}
if (filters.dividedFilter === "true") {
where.push(eq(transactions.isDivided, true));
}
const searchPattern = buildSearchPattern(filters.searchFilter);
if (searchPattern) {
where.push(
or(
ilike(transactions.name, searchPattern),
ilike(transactions.note, searchPattern),
ilike(transactions.paymentMethod, searchPattern),
ilike(transactions.condition, searchPattern),
and(
isNotNull(financialAccounts.name),
ilike(financialAccounts.name, searchPattern),
),
and(isNotNull(cards.name), ilike(cards.name, searchPattern)),
) as SQL,
);
}
return where;
};
type TransactionRowWithRelations = Partial<typeof transactions.$inferSelect> & {
payer?: PayerRow | null;
financialAccount?: AccountRow | null;
card?: CardRow | null;
category?: CategoryRow | null;
hasAttachments?: boolean;
};
export const mapTransactionsData = (rows: TransactionRowWithRelations[]) =>
rows.map((item) => ({
id: item.id ?? "",
userId: item.userId ?? "",
name: item.name ?? "",
purchaseDate: toDateOnlyString(item.purchaseDate) ?? "",
period: item.period ?? "",
transactionType: item.transactionType ?? "",
amount: Number(item.amount ?? 0),
condition: item.condition ?? "",
paymentMethod: item.paymentMethod ?? "",
payerId: item.payerId ?? null,
pagadorName: item.payer?.name ?? null,
pagadorAvatar: item.payer?.avatarUrl ?? null,
pagadorRole: item.payer?.role ?? null,
accountId: item.accountId ?? null,
contaName: item.financialAccount?.name ?? null,
contaLogo: item.financialAccount?.logo ?? null,
cardId: item.cardId ?? null,
cartaoName: item.card?.name ?? null,
cartaoLogo: item.card?.logo ?? null,
categoryId: item.categoryId ?? null,
categoriaName: item.category?.name ?? null,
categoriaType: item.category?.type ?? null,
categoriaIcon: item.category?.icon ?? null,
installmentCount: item.installmentCount ?? null,
recurrenceCount: item.recurrenceCount ?? null,
currentInstallment: item.currentInstallment ?? null,
dueDate: item.dueDate ? item.dueDate.toISOString().slice(0, 10) : null,
boletoPaymentDate: item.boletoPaymentDate
? item.boletoPaymentDate.toISOString().slice(0, 10)
: null,
note: item.note ?? null,
isSettled: item.isSettled ?? null,
isDivided: item.isDivided ?? false,
isAnticipated: item.isAnticipated ?? false,
anticipationId: item.anticipationId ?? null,
seriesId: item.seriesId ?? null,
splitGroupId: item.splitGroupId ?? null,
hasAttachments: item.hasAttachments ?? false,
readonly:
Boolean(item.note?.startsWith(ACCOUNT_AUTO_INVOICE_NOTE_PREFIX)) ||
item.category?.name === "Saldo inicial" ||
item.category?.name === "Pagamentos",
}));
const sortByLabel = <T extends { label: string }>(items: T[]) =>
items.sort((a, b) =>
a.label.localeCompare(b.label, "pt-BR", { sensitivity: "base" }),
);
export const buildOptionSets = ({
payerFiltersRaw,
categoryFiltersRaw,
accountFiltersRaw,
cardFiltersRaw,
payerRows,
limitCartaoId,
limitContaId,
}: SluggedFilters & {
payerRows: PayerRow[];
limitCartaoId?: string;
limitContaId?: string;
}): TransactionOptionSets => {
const payerOptions = sortByLabel(
payerFiltersRaw.map(({ id, label, role, slug, avatarUrl }) =>
toOption(id, label, role, undefined, slug, avatarUrl),
),
);
const payerFilterOptions = sortByLabel(
payerFiltersRaw.map(({ slug, label, avatarUrl }) => ({
slug,
label,
avatarUrl,
})),
);
const defaultPayerId =
payerRows.find((payer) => payer.role === PAYER_ROLE_ADMIN)?.id ?? null;
const splitPayerOptions = payerOptions.filter(
(option) => option.role === PAYER_ROLE_THIRD_PARTY,
);
const contaOptionsSource = limitContaId
? accountFiltersRaw.filter((conta) => conta.id === limitContaId)
: accountFiltersRaw;
const accountOptions = sortByLabel(
contaOptionsSource.map(({ id, label, slug, logo, accountType }) =>
toOption(
id,
label,
undefined,
undefined,
slug,
undefined,
logo,
undefined,
accountType,
),
),
);
const cartaoOptionsSource = limitCartaoId
? cardFiltersRaw.filter((cartao) => cartao.id === limitCartaoId)
: cardFiltersRaw;
const cardOptions = sortByLabel(
cartaoOptionsSource.map(({ id, label, slug, logo, closingDay, dueDay }) =>
toOption(
id,
label,
undefined,
undefined,
slug,
undefined,
logo,
undefined,
undefined,
closingDay,
dueDay,
),
),
);
const categoryOptions = sortByLabel(
categoryFiltersRaw.map(({ id, label, type, slug, icon }) =>
toOption(id, label, undefined, type, slug, undefined, undefined, icon),
),
);
const categoryFilterOptions = sortByLabel(
categoryFiltersRaw.map(({ slug, label, icon }) => ({ slug, label, icon })),
);
const accountCardFilterOptions = sortByLabel(
[...accountFiltersRaw, ...cardFiltersRaw]
.filter(
(option) =>
(limitCartaoId && option.kind === "cartao"
? option.id === limitCartaoId
: true) &&
(limitContaId && option.kind === "conta"
? option.id === limitContaId
: true),
)
.map(({ slug, label, kind, logo }) => ({ slug, label, kind, logo })),
);
return {
payerOptions,
splitPayerOptions,
defaultPayerId,
accountOptions,
cardOptions,
categoryOptions,
payerFilterOptions,
categoryFilterOptions,
accountCardFilterOptions,
};
};