From 3c31ee5d9032f0fe92efa3c1e2f68d44fc1152f7 Mon Sep 17 00:00:00 2001 From: Felipe Coutinho Date: Fri, 20 Mar 2026 18:39:49 +0000 Subject: [PATCH] =?UTF-8?q?refactor:=20pagina=20transa=C3=A7=C3=B5es=20e?= =?UTF-8?q?=20modulariza=20a=C3=A7=C3=B5es?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/mock-data.ts | 1812 +++++++++++++++++ .../accounts/[accountId]/statement/page.tsx | 24 +- src/app/(dashboard)/transactions/page.tsx | 21 +- src/features/accounts/statement-queries.ts | 76 +- .../components/category-report-export.tsx | 18 +- src/features/transactions/actions.ts | 1700 +--------------- .../transactions/actions/bulk-actions.ts | 624 ++++++ src/features/transactions/actions/core.ts | 758 +++++++ .../transactions/actions/export-actions.ts | 81 + .../transactions/actions/single-actions.ts | 422 ++++ src/features/transactions/column-order.ts | 4 +- .../components/page/transactions-page.tsx | 11 +- .../components/table/transactions-filters.tsx | 13 +- .../components/table/transactions-table.tsx | 122 +- .../components/transactions-export.tsx | 49 +- src/features/transactions/export-types.ts | 26 + src/features/transactions/page-helpers.ts | 23 + src/features/transactions/queries.ts | 253 ++- .../month-picker/month-navigation.tsx | 5 - .../month-picker/use-month-period.ts | 1 + 20 files changed, 4261 insertions(+), 1782 deletions(-) create mode 100644 scripts/mock-data.ts create mode 100644 src/features/transactions/actions/bulk-actions.ts create mode 100644 src/features/transactions/actions/core.ts create mode 100644 src/features/transactions/actions/export-actions.ts create mode 100644 src/features/transactions/actions/single-actions.ts create mode 100644 src/features/transactions/export-types.ts diff --git a/scripts/mock-data.ts b/scripts/mock-data.ts new file mode 100644 index 0000000..e3eea1c --- /dev/null +++ b/scripts/mock-data.ts @@ -0,0 +1,1812 @@ +#!/usr/bin/env tsx + +import { randomUUID } from "node:crypto"; +import { config } from "dotenv"; +import { and, eq, ne, sql } from "drizzle-orm"; +import { + budgets, + cards, + categories, + financialAccounts, + inboxItems, + invoices, + notes, + payers, + transactions, + user, +} from "@/db/schema"; +import { loadAvatarOptions } from "@/features/payers/lib/avatar-options"; +import type { TransactionInsert } from "@/features/transactions/actions/core"; +import type { + PAYMENT_METHODS, + TRANSACTION_CONDITIONS, + TRANSACTION_TYPES, +} from "@/features/transactions/constants"; +import { + buildInvoicePaymentNote, + INITIAL_BALANCE_CATEGORY_NAME, + INITIAL_BALANCE_CONDITION, + INITIAL_BALANCE_NOTE, + INITIAL_BALANCE_PAYMENT_METHOD, + INITIAL_BALANCE_TRANSACTION_TYPE, +} from "@/shared/lib/accounts/constants"; +import { + DEFAULT_CARD_BRANDS, + DEFAULT_CARD_STATUS, +} from "@/shared/lib/cards/constants"; +import { DEFAULT_CATEGORIES } from "@/shared/lib/categories/defaults"; +import { db } from "@/shared/lib/db"; +import { INVOICE_PAYMENT_STATUS } from "@/shared/lib/invoices"; +import { loadLogoOptions } from "@/shared/lib/logo/options"; +import { + DEFAULT_PAYER_AVATAR, + PAYER_ROLE_ADMIN, + PAYER_ROLE_THIRD_PARTY, + PAYER_STATUS_OPTIONS, +} from "@/shared/lib/payers/constants"; +import { normalizeNameFromEmail } from "@/shared/lib/payers/utils"; +import { + addMonthsToDate, + buildDateOnlyStringFromPeriodDay, + compareDateOnly, + getBusinessTodayInfo, + parseLocalDateString, +} from "@/shared/utils/date"; +import { + addMonthsToPeriod, + comparePeriods, + derivePeriodFromDate, + getNextPeriod, + parsePeriod, +} from "@/shared/utils/period"; + +config(); + +const DEFAULT_MONTHS = 6; +const MIN_MONTHS = 3; +const MAX_MONTHS = 24; + +const ACCOUNT_TYPES = { + CHECKING: "Conta Corrente", + DIGITAL_WALLET: "Carteira Digital", +} as const; + +const ACCOUNT_STATUS = { + ACTIVE: "Ativa", +} as const; + +const CARD_STATUS = DEFAULT_CARD_STATUS[0]; +const CARD_BRANDS = { + MASTERCARD: DEFAULT_CARD_BRANDS[1] ?? "Mastercard", + VISA: DEFAULT_CARD_BRANDS[0] ?? "Visa", +} as const; + +const PAYER_STATUS = PAYER_STATUS_OPTIONS[0]; + +type CliOptions = { + userId: string; + startPeriod: string; + months: number; +}; + +type SeedTransactionInput = { + name: string; + amount: number; + purchaseDate: string; + transactionType: (typeof TRANSACTION_TYPES)[number]; + condition: (typeof TRANSACTION_CONDITIONS)[number]; + paymentMethod: (typeof PAYMENT_METHODS)[number]; + accountId?: string | null; + cardId?: string | null; + categoryId?: string | null; + payerId?: string | null; + secondaryPayerId?: string | null; + isSplit?: boolean; + primarySplitAmount?: number; + secondarySplitAmount?: number; + installmentCount?: number; + recurrenceCount?: number; + dueDate?: string | null; + note?: string | null; + settlementBehavior?: "auto" | "open" | "settled"; + cardMeta?: { + closingDay: string; + dueDay: string; + }; +}; + +type Share = { + payerId: string | null; + amountCents: number; +}; + +type SeedSummary = { + payers: number; + accounts: number; + cards: number; + notes: number; + budgets: number; + transactions: number; + invoices: number; + invoicePayments: number; + inboxItems: number; +}; + +function printUsage() { + console.log(` +Uso: + pnpm seed:empty-account -- --userId= --startPeriod=YYYY-MM [--months=${DEFAULT_MONTHS}] + +Exemplos: + pnpm seed:empty-account -- --userId=user_123 --startPeriod=2026-01 + pnpm seed:empty-account -- --userId=user_123 --startPeriod=2025-10 --months=8 +`); +} + +function parseArgs(argv: string[]): CliOptions { + let userId = ""; + let startPeriod = ""; + let months = DEFAULT_MONTHS; + + for (const arg of argv) { + if (arg === "--help" || arg === "-h") { + printUsage(); + process.exit(0); + } + + if (arg.startsWith("--userId=") || arg.startsWith("--user-id=")) { + userId = arg.split("=")[1] ?? ""; + continue; + } + + if (arg.startsWith("--startPeriod=") || arg.startsWith("--start-period=")) { + startPeriod = arg.split("=")[1] ?? ""; + continue; + } + + if (arg.startsWith("--months=")) { + const rawValue = arg.split("=")[1] ?? ""; + months = Number.parseInt(rawValue, 10); + } + } + + if (!userId.trim()) { + throw new Error("Informe o `--userId` do usuário que receberá os dados."); + } + + if (!startPeriod.trim()) { + throw new Error("Informe o `--startPeriod` no formato `YYYY-MM`."); + } + + parsePeriod(startPeriod); + + if (Number.isNaN(months) || months < MIN_MONTHS || months > MAX_MONTHS) { + throw new Error( + `O parâmetro \`--months\` deve ficar entre ${MIN_MONTHS} e ${MAX_MONTHS}.`, + ); + } + + return { + userId: userId.trim(), + startPeriod: startPeriod.trim(), + months, + }; +} + +function centsToDecimalString(value: number) { + const decimal = value / 100; + const formatted = decimal.toFixed(2); + return Object.is(decimal, -0) ? "0.00" : formatted; +} + +function 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), + ); +} + +function buildShares(input: SeedTransactionInput): Share[] { + const totalCents = Math.round(Math.abs(input.amount) * 100); + + if (!input.isSplit) { + return [{ payerId: input.payerId ?? null, amountCents: totalCents }]; + } + + if (!input.payerId || !input.secondaryPayerId) { + throw new Error(`Divisão inválida para o lançamento "${input.name}".`); + } + + if ( + input.primarySplitAmount !== undefined && + input.secondarySplitAmount !== undefined + ) { + return [ + { + payerId: input.payerId, + amountCents: Math.round(input.primarySplitAmount * 100), + }, + { + payerId: input.secondaryPayerId, + amountCents: Math.round(input.secondarySplitAmount * 100), + }, + ]; + } + + const [primaryAmount, secondaryAmount] = splitAmount(totalCents, 2); + + return [ + { payerId: input.payerId, amountCents: primaryAmount ?? 0 }, + { + payerId: input.secondaryPayerId, + amountCents: secondaryAmount ?? 0, + }, + ]; +} + +function deriveCreditCardPeriod( + purchaseDate: string, + closingDay: string | null | undefined, + dueDay?: string | null | undefined, +) { + 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); + + let period = basePeriod; + + if (purchaseDayNum >= closingDayNum) { + period = getNextPeriod(period); + } + + const dueDayNum = Number.parseInt(dueDay ?? "", 10); + if (!Number.isNaN(dueDayNum) && dueDayNum < closingDayNum) { + period = getNextPeriod(period); + } + + return period; +} + +function pickOption(options: string[], candidates: string[], fallback: string) { + const normalizedOptions = new Set(options); + + for (const candidate of candidates) { + if (normalizedOptions.has(candidate)) { + return candidate; + } + } + + return options[0] ?? fallback; +} + +function dateForPeriodDay(period: string, day: number) { + const value = buildDateOnlyStringFromPeriodDay(period, day); + if (!value) { + throw new Error(`Não foi possível montar a data ${period}/${day}.`); + } + + return value; +} + +function resolveSeedSettlement( + input: SeedTransactionInput, + referenceDate: Date, + todayDate: Date, +) { + if (input.paymentMethod === "Cartão de crédito") { + return null; + } + + if (input.settlementBehavior === "settled") { + return true; + } + + if (input.settlementBehavior === "open") { + return false; + } + + return compareDateOnly(referenceDate, todayDate) <= 0; +} + +function createTransactionRecords( + input: SeedTransactionInput, + userId: string, + todayDate: Date, +): TransactionInsert[] { + const purchaseDate = parseLocalDateString(input.purchaseDate); + if (Number.isNaN(purchaseDate.getTime())) { + throw new Error(`Data inválida no lançamento "${input.name}".`); + } + + const dueDate = input.dueDate ? parseLocalDateString(input.dueDate) : null; + const shares = buildShares(input); + const amountSign: 1 | -1 = input.transactionType === "Despesa" ? -1 : 1; + const isSeries = + input.condition === "Parcelado" || input.condition === "Recorrente"; + const seriesId = isSeries ? randomUUID() : null; + const initialPeriod = + input.cardId && input.cardMeta + ? deriveCreditCardPeriod( + input.purchaseDate, + input.cardMeta.closingDay, + input.cardMeta.dueDay, + ) + : derivePeriodFromDate(input.purchaseDate); + + const basePayload = { + name: input.name, + transactionType: input.transactionType, + condition: input.condition, + paymentMethod: input.paymentMethod, + note: input.note ?? null, + accountId: input.accountId ?? null, + cardId: input.cardId ?? null, + categoryId: input.categoryId ?? null, + isDivided: input.isSplit ?? false, + userId, + seriesId, + }; + + const records: TransactionInsert[] = []; + + if (input.condition === "Parcelado") { + const installmentTotal = input.installmentCount ?? 0; + const amountsByShare = shares.map((share) => + splitAmount(share.amountCents, installmentTotal), + ); + + for ( + let installmentIndex = 0; + installmentIndex < installmentTotal; + installmentIndex += 1 + ) { + const period = addMonthsToPeriod(initialPeriod, installmentIndex); + const installmentDueDate = dueDate + ? addMonthsToDate(dueDate, installmentIndex) + : null; + const settlementReferenceDate = + installmentDueDate ?? addMonthsToDate(purchaseDate, installmentIndex); + + shares.forEach((share, shareIndex) => { + const amountCents = amountsByShare[shareIndex]?.[installmentIndex] ?? 0; + const isSettled = resolveSeedSettlement( + input, + settlementReferenceDate, + todayDate, + ); + + records.push({ + ...basePayload, + amount: centsToDecimalString(amountCents * amountSign), + payerId: share.payerId, + purchaseDate, + period, + isSettled, + installmentCount: installmentTotal, + currentInstallment: installmentIndex + 1, + recurrenceCount: null, + dueDate: installmentDueDate, + boletoPaymentDate: + input.paymentMethod === "Boleto" && isSettled + ? (installmentDueDate ?? settlementReferenceDate) + : null, + }); + }); + } + + return records; + } + + if (input.condition === "Recorrente") { + const recurrenceTotal = input.recurrenceCount ?? 0; + + for (let index = 0; index < recurrenceTotal; index += 1) { + const period = addMonthsToPeriod(initialPeriod, index); + const recurrencePurchaseDate = addMonthsToDate(purchaseDate, index); + const recurrenceDueDate = dueDate + ? addMonthsToDate(dueDate, index) + : null; + const settlementReferenceDate = + recurrenceDueDate ?? recurrencePurchaseDate; + + shares.forEach((share) => { + const isSettled = resolveSeedSettlement( + input, + settlementReferenceDate, + todayDate, + ); + + records.push({ + ...basePayload, + amount: centsToDecimalString(share.amountCents * amountSign), + payerId: share.payerId, + purchaseDate: recurrencePurchaseDate, + period, + isSettled, + installmentCount: null, + currentInstallment: null, + recurrenceCount: recurrenceTotal, + dueDate: recurrenceDueDate, + boletoPaymentDate: + input.paymentMethod === "Boleto" && isSettled + ? (recurrenceDueDate ?? recurrencePurchaseDate) + : null, + }); + }); + } + + return records; + } + + shares.forEach((share) => { + const settlementReferenceDate = dueDate ?? purchaseDate; + const isSettled = resolveSeedSettlement( + input, + settlementReferenceDate, + todayDate, + ); + + records.push({ + ...basePayload, + amount: centsToDecimalString(share.amountCents * amountSign), + payerId: share.payerId, + purchaseDate, + period: initialPeriod, + isSettled, + installmentCount: null, + currentInstallment: null, + recurrenceCount: null, + dueDate, + boletoPaymentDate: + input.paymentMethod === "Boleto" && isSettled + ? (dueDate ?? purchaseDate) + : null, + }); + }); + + return records; +} + +async function ensureCategories(userId: string) { + const existing = await db.query.categories.findMany({ + columns: { id: true, name: true }, + where: eq(categories.userId, userId), + }); + + const existingNames = new Set(existing.map((item) => item.name)); + const missingDefaults = DEFAULT_CATEGORIES.filter( + (category) => !existingNames.has(category.name), + ); + + if (missingDefaults.length > 0) { + await db.insert(categories).values( + missingDefaults.map((category) => ({ + name: category.name, + type: category.type, + icon: category.icon, + userId, + })), + ); + } + + const refreshed = await db.query.categories.findMany({ + columns: { id: true, name: true }, + where: eq(categories.userId, userId), + }); + + return new Map(refreshed.map((category) => [category.name, category.id])); +} + +async function ensureAdminPayer(targetUser: typeof user.$inferSelect) { + const existingAdmin = await db.query.payers.findFirst({ + columns: { id: true, name: true }, + where: and( + eq(payers.userId, targetUser.id), + eq(payers.role, PAYER_ROLE_ADMIN), + ), + }); + + if (existingAdmin) { + return existingAdmin; + } + + const name = + targetUser.name?.trim() || + normalizeNameFromEmail(targetUser.email) || + "Admin"; + + const [created] = await db + .insert(payers) + .values({ + name, + email: targetUser.email ?? null, + avatarUrl: targetUser.image ?? DEFAULT_PAYER_AVATAR, + status: PAYER_STATUS, + note: null, + role: PAYER_ROLE_ADMIN, + isAutoSend: false, + userId: targetUser.id, + }) + .returning({ id: payers.id, name: payers.name }); + + if (!created) { + throw new Error("Não foi possível criar o pagador admin do usuário."); + } + + return created; +} + +async function countByUserId( + table: + | typeof financialAccounts + | typeof cards + | typeof budgets + | typeof notes + | typeof invoices + | typeof transactions + | typeof inboxItems, + userColumn: + | typeof financialAccounts.userId + | typeof cards.userId + | typeof budgets.userId + | typeof notes.userId + | typeof invoices.userId + | typeof transactions.userId + | typeof inboxItems.userId, + userId: string, +) { + const [result] = await db + .select({ count: sql`count(*)` }) + .from(table) + .where(eq(userColumn, userId)); + + return Number(result?.count ?? 0); +} + +async function assertFinancialSpaceIsEmpty(userId: string) { + const [ + accountCount, + cardCount, + budgetCount, + noteCount, + invoiceCount, + transactionCount, + inboxCount, + extraPayerCount, + ] = await Promise.all([ + countByUserId(financialAccounts, financialAccounts.userId, userId), + countByUserId(cards, cards.userId, userId), + countByUserId(budgets, budgets.userId, userId), + countByUserId(notes, notes.userId, userId), + countByUserId(invoices, invoices.userId, userId), + countByUserId(transactions, transactions.userId, userId), + countByUserId(inboxItems, inboxItems.userId, userId), + db + .select({ count: sql`count(*)` }) + .from(payers) + .where(and(eq(payers.userId, userId), ne(payers.role, PAYER_ROLE_ADMIN))) + .then((rows) => Number(rows[0]?.count ?? 0)), + ]); + + const blockers = [ + accountCount > 0 ? `${accountCount} conta(s)` : null, + cardCount > 0 ? `${cardCount} cartao(oes)` : null, + transactionCount > 0 ? `${transactionCount} lancamento(s)` : null, + invoiceCount > 0 ? `${invoiceCount} fatura(s)` : null, + budgetCount > 0 ? `${budgetCount} orcamento(s)` : null, + noteCount > 0 ? `${noteCount} anotacao(oes)` : null, + extraPayerCount > 0 ? `${extraPayerCount} pagador(es) extra(s)` : null, + inboxCount > 0 ? `${inboxCount} pre-lancamento(s)` : null, + ].filter(Boolean); + + if (blockers.length > 0) { + throw new Error( + `O usuário ${userId} não está com a conta zerada. Itens encontrados: ${blockers.join(", ")}.`, + ); + } +} + +async function seedInvoicesForCards(params: { + userId: string; + adminPayerId: string; + cardsByKey: Record< + string, + { + id: string; + name: string; + accountId: string; + dueDay: string; + closingDay: string; + } + >; + paymentCategoryId: string | undefined; + insertedTransactionRecords: TransactionInsert[]; +}) { + const { userId, adminPayerId, cardsByKey, paymentCategoryId } = params; + const todayInfo = getBusinessTodayInfo(); + const cardEntries = Object.entries(cardsByKey); + + let createdInvoices = 0; + let createdInvoicePayments = 0; + + await db.transaction(async (tx) => { + for (const [cardKey, card] of cardEntries) { + const cardPeriods = Array.from( + new Set( + params.insertedTransactionRecords + .filter((record) => record.cardId === card.id) + .map((record) => record.period ?? "") + .filter(Boolean), + ), + ).sort(comparePeriods); + + const historicalPeriods = cardPeriods.filter( + (period) => comparePeriods(period, todayInfo.period) <= 0, + ); + + if (historicalPeriods.length === 0) { + continue; + } + + const latestHistoricalPeriod = + historicalPeriods[historicalPeriods.length - 1]; + + for (const period of historicalPeriods) { + const shouldLeavePending = + cardKey === "ultravioleta" && period === latestHistoricalPeriod; + const status = shouldLeavePending + ? INVOICE_PAYMENT_STATUS.PENDING + : INVOICE_PAYMENT_STATUS.PAID; + + const existingInvoice = await tx.query.invoices.findFirst({ + columns: { id: true }, + where: and( + eq(invoices.userId, userId), + eq(invoices.cardId, card.id), + eq(invoices.period, period), + ), + }); + + if (existingInvoice) { + await tx + .update(invoices) + .set({ + paymentStatus: status, + }) + .where(eq(invoices.id, existingInvoice.id)); + } else { + await tx.insert(invoices).values({ + cardId: card.id, + userId, + period, + paymentStatus: status, + }); + } + + await tx + .update(transactions) + .set({ + isSettled: status === INVOICE_PAYMENT_STATUS.PAID, + }) + .where( + and( + eq(transactions.userId, userId), + eq(transactions.cardId, card.id), + eq(transactions.period, period), + ), + ); + + createdInvoices += 1; + + if (status !== INVOICE_PAYMENT_STATUS.PAID) { + continue; + } + + const [adminShareRow] = await tx + .select({ + total: sql`coalesce(sum(${transactions.amount}), 0)`, + }) + .from(transactions) + .where( + and( + eq(transactions.userId, userId), + eq(transactions.cardId, card.id), + eq(transactions.period, period), + eq(transactions.payerId, adminPayerId), + ), + ); + + const adminShare = Number(adminShareRow?.total ?? 0); + const adminPayableAmount = Math.abs(Math.min(adminShare, 0)); + + if (adminPayableAmount <= 0) { + continue; + } + + const paymentDate = dateForPeriodDay(period, Number(card.dueDay)); + const paymentNote = buildInvoicePaymentNote(card.id, period); + + await tx.insert(transactions).values({ + condition: "À vista", + name: `Pagamento fatura - ${card.name}`, + paymentMethod: "Pix", + note: paymentNote, + amount: `-${adminPayableAmount.toFixed(2)}`, + purchaseDate: parseLocalDateString(paymentDate), + transactionType: "Despesa", + period, + isSettled: true, + userId, + accountId: card.accountId, + categoryId: paymentCategoryId ?? null, + payerId: adminPayerId, + }); + + createdInvoicePayments += 1; + } + } + }); + + return { + createdInvoices, + createdInvoicePayments, + }; +} + +async function main() { + if (!process.env.DATABASE_URL) { + throw new Error("DATABASE_URL não está configurada no ambiente."); + } + + const options = parseArgs(process.argv.slice(2)); + const logoOptions = await loadLogoOptions(); + const avatarOptions = await loadAvatarOptions(); + const businessToday = getBusinessTodayInfo(); + const summary: SeedSummary = { + payers: 0, + accounts: 0, + cards: 0, + notes: 0, + budgets: 0, + transactions: 0, + invoices: 0, + invoicePayments: 0, + inboxItems: 0, + }; + + const targetUser = await db.query.user.findFirst({ + where: eq(user.id, options.userId), + }); + + if (!targetUser) { + throw new Error(`Usuário ${options.userId} não foi encontrado.`); + } + + await ensureCategories(targetUser.id); + const adminPayer = await ensureAdminPayer(targetUser); + await assertFinancialSpaceIsEmpty(targetUser.id); + + const categoriesByName = await ensureCategories(targetUser.id); + + const getCategoryId = (name: string) => { + const categoryId = categoriesByName.get(name); + if (!categoryId) { + throw new Error(`Categoria obrigatória não encontrada: ${name}.`); + } + return categoryId; + }; + + const periods = Array.from({ length: options.months }, (_, index) => + addMonthsToPeriod(options.startPeriod, index), + ); + + const firstPeriod = periods[0]; + const secondPeriod = periods[1] ?? periods[0]; + const thirdPeriod = periods[2] ?? periods[periods.length - 1] ?? periods[0]; + const middlePeriod = periods[Math.floor(periods.length / 2)] ?? periods[0]; + const lastPeriod = periods[periods.length - 1] ?? periods[0]; + const firstDayOfSeed = dateForPeriodDay(firstPeriod, 1); + + const preferredAvatar = (candidates: string[]) => + pickOption(avatarOptions, candidates, DEFAULT_PAYER_AVATAR); + const preferredLogo = (candidates: string[], fallback: string) => + pickOption(logoOptions, candidates, fallback); + + const createdPayers: Record = { + admin: adminPayer.id, + }; + const createdAccounts: Record = {}; + const createdCards: Record< + string, + { + id: string; + name: string; + accountId: string; + closingDay: string; + dueDay: string; + } + > = {}; + + await db.transaction(async (tx) => { + const extraPayerDefinitions = [ + { + key: "marina", + name: "Marina Oliveira", + email: "marina.oliveira@exemplo.com", + avatarUrl: preferredAvatar(["4825038.png", "4825051.png"]), + note: "Divide as despesas da casa e do mercado.", + isAutoSend: true, + }, + { + key: "eduardo", + name: "Eduardo Lima", + email: "eduardo.lima@exemplo.com", + avatarUrl: preferredAvatar(["4825108.png", "4825123.png"]), + note: "Costuma rachar viagens e presentes em familia.", + isAutoSend: false, + }, + ] as const; + + for (const definition of extraPayerDefinitions) { + const [created] = await tx + .insert(payers) + .values({ + name: definition.name, + email: definition.email, + avatarUrl: definition.avatarUrl, + status: PAYER_STATUS, + note: definition.note, + role: PAYER_ROLE_THIRD_PARTY, + isAutoSend: definition.isAutoSend, + userId: targetUser.id, + }) + .returning({ id: payers.id }); + + if (!created) { + throw new Error(`Falha ao criar o pagador ${definition.name}.`); + } + + createdPayers[definition.key] = created.id; + summary.payers += 1; + } + + const accountDefinitions = [ + { + key: "nubank", + name: "Nubank", + accountType: ACCOUNT_TYPES.DIGITAL_WALLET, + status: ACCOUNT_STATUS.ACTIVE, + logo: preferredLogo(["nubank.png"], "nubank.png"), + note: "Conta principal para salario, mercado e despesas do dia a dia.", + initialBalance: "4200.00", + }, + { + key: "itau", + name: "Itaú Personnalité", + accountType: ACCOUNT_TYPES.CHECKING, + status: ACCOUNT_STATUS.ACTIVE, + logo: preferredLogo(["itaupersonnalite.png", "itau.png"], "itau.png"), + note: "Conta de apoio para boletos, investimentos e pagamentos maiores.", + initialBalance: "1850.00", + }, + { + key: "mercado-pago", + name: "Mercado Pago", + accountType: ACCOUNT_TYPES.DIGITAL_WALLET, + status: ACCOUNT_STATUS.ACTIVE, + logo: preferredLogo(["mercadopago.png"], "mercadopago.png"), + note: "Carteira usada para corridas, pequenos gastos e pix instantaneo.", + initialBalance: "350.00", + }, + ] as const; + + for (const definition of accountDefinitions) { + const [created] = await tx + .insert(financialAccounts) + .values({ + name: definition.name, + accountType: definition.accountType, + status: definition.status, + note: definition.note, + logo: definition.logo, + initialBalance: definition.initialBalance, + excludeFromBalance: false, + excludeInitialBalanceFromIncome: true, + userId: targetUser.id, + }) + .returning({ id: financialAccounts.id }); + + if (!created) { + throw new Error(`Falha ao criar a conta ${definition.name}.`); + } + + createdAccounts[definition.key] = created.id; + summary.accounts += 1; + + await tx.insert(transactions).values({ + condition: INITIAL_BALANCE_CONDITION, + name: `Saldo inicial - ${definition.name}`, + paymentMethod: INITIAL_BALANCE_PAYMENT_METHOD, + note: INITIAL_BALANCE_NOTE, + amount: definition.initialBalance, + purchaseDate: parseLocalDateString(firstDayOfSeed), + transactionType: INITIAL_BALANCE_TRANSACTION_TYPE, + period: firstPeriod, + isSettled: true, + userId: targetUser.id, + accountId: created.id, + categoryId: getCategoryId(INITIAL_BALANCE_CATEGORY_NAME), + payerId: adminPayer.id, + }); + + summary.transactions += 1; + } + + const cardDefinitions = [ + { + key: "ultravioleta", + name: "Nubank Ultravioleta", + brand: CARD_BRANDS.MASTERCARD, + status: CARD_STATUS, + closingDay: "25", + dueDay: "03", + note: "Cartao principal para assinaturas, delivery e compras parceladas.", + limit: "12000.00", + logo: preferredLogo( + ["nubank-ultravioleta.png", "nubank.png"], + "nubank.png", + ), + accountId: createdAccounts.nubank, + }, + { + key: "itaucard", + name: "Pão de Açúcar Itaucard", + brand: CARD_BRANDS.VISA, + status: CARD_STATUS, + closingDay: "15", + dueDay: "22", + note: "Cartao usado para mercado, compras maiores e viagens.", + limit: "8500.00", + logo: preferredLogo(["pao-acucar.png", "itau.png"], "itau.png"), + accountId: createdAccounts.itau, + }, + ] as const; + + for (const definition of cardDefinitions) { + const [created] = await tx + .insert(cards) + .values({ + name: definition.name, + brand: definition.brand, + status: definition.status, + closingDay: definition.closingDay, + dueDay: definition.dueDay, + note: definition.note, + limit: definition.limit, + logo: definition.logo, + accountId: definition.accountId, + userId: targetUser.id, + }) + .returning({ id: cards.id }); + + if (!created) { + throw new Error(`Falha ao criar o cartao ${definition.name}.`); + } + + createdCards[definition.key] = { + id: created.id, + name: definition.name, + accountId: definition.accountId, + closingDay: definition.closingDay, + dueDay: definition.dueDay, + }; + summary.cards += 1; + } + + const noteDefinitions = [ + { + title: "Planejar viagem de julho", + type: "nota" as const, + description: + "Separar hospedagem, passagens e gastos previstos da viagem para Salvador. Meta: manter tudo abaixo de R$ 4.500.", + tasks: null, + archived: false, + }, + { + title: "Pendencias do apartamento", + type: "tarefa" as const, + description: null, + tasks: JSON.stringify([ + { + id: randomUUID(), + text: "Revisar reajuste do aluguel", + completed: true, + }, + { + id: randomUUID(), + text: "Separar comprovantes do condominio", + completed: false, + }, + { + id: randomUUID(), + text: "Confirmar vistoria do ar-condicionado", + completed: false, + }, + ]), + archived: false, + }, + { + title: "Renegociar seguro do carro", + type: "nota" as const, + description: + "Pesquisar entre Porto, Azul e Youse antes do vencimento. Prioridade: franquia menor e assistencia 24h.", + tasks: null, + archived: false, + }, + ] as const; + + await tx.insert(notes).values( + noteDefinitions.map((noteItem) => ({ + title: noteItem.title, + type: noteItem.type, + description: noteItem.description, + tasks: noteItem.tasks, + archived: noteItem.archived, + userId: targetUser.id, + })), + ); + summary.notes += noteDefinitions.length; + + const budgetDefinitions = [ + { categoryName: "Mercado", baseAmount: 1500 }, + { categoryName: "Restaurantes", baseAmount: 480 }, + { categoryName: "Transporte", baseAmount: 620 }, + { categoryName: "Moradia", baseAmount: 3200 }, + { categoryName: "Lazer", baseAmount: 420 }, + { categoryName: "Assinaturas", baseAmount: 240 }, + ] as const; + + const budgetRows = periods.flatMap((period, index) => + budgetDefinitions.map((budgetItem) => ({ + amount: (budgetItem.baseAmount + index * 15).toFixed(2), + period, + userId: targetUser.id, + categoryId: getCategoryId(budgetItem.categoryName), + })), + ); + + await tx.insert(budgets).values(budgetRows); + summary.budgets += budgetRows.length; + }); + + const seedTransactionRecords: TransactionInsert[] = []; + + const createRecords = (input: SeedTransactionInput) => { + seedTransactionRecords.push( + ...createTransactionRecords(input, targetUser.id, businessToday.date), + ); + }; + + createRecords({ + name: "Salário - OpenMonetis Labs", + amount: 7800, + purchaseDate: dateForPeriodDay(firstPeriod, 5), + transactionType: "Receita", + condition: "Recorrente", + paymentMethod: "Transferência bancária", + accountId: createdAccounts.nubank, + categoryId: getCategoryId("Salário"), + payerId: adminPayer.id, + recurrenceCount: options.months, + note: "Salario mensal recebido via TED.", + }); + + createRecords({ + name: "Aluguel - Edifício Aurora", + amount: 2800, + purchaseDate: dateForPeriodDay(firstPeriod, 5), + transactionType: "Despesa", + condition: "Recorrente", + paymentMethod: "Pix", + accountId: createdAccounts.nubank, + categoryId: getCategoryId("Moradia"), + payerId: adminPayer.id, + secondaryPayerId: createdPayers.marina, + isSplit: true, + primarySplitAmount: 1400, + secondarySplitAmount: 1400, + recurrenceCount: options.months, + dueDate: dateForPeriodDay(firstPeriod, 8), + note: "Despesa fixa dividida com Marina.", + }); + + createRecords({ + name: "Vivo Fibra", + amount: 129.9, + purchaseDate: dateForPeriodDay(firstPeriod, 2), + transactionType: "Despesa", + condition: "Recorrente", + paymentMethod: "Boleto", + accountId: createdAccounts.itau, + categoryId: getCategoryId("Internet"), + payerId: adminPayer.id, + recurrenceCount: options.months, + dueDate: dateForPeriodDay(firstPeriod, 12), + note: "Internet da casa.", + }); + + createRecords({ + name: "Conta de luz - Enel", + amount: 186.4, + purchaseDate: dateForPeriodDay(firstPeriod, 3), + transactionType: "Despesa", + condition: "Recorrente", + paymentMethod: "Boleto", + accountId: createdAccounts.itau, + categoryId: getCategoryId("Energia e água"), + payerId: adminPayer.id, + recurrenceCount: options.months, + dueDate: dateForPeriodDay(firstPeriod, 18), + note: "Conta mensal de energia.", + }); + + createRecords({ + name: "Netflix", + amount: 55.9, + purchaseDate: dateForPeriodDay(firstPeriod, 9), + transactionType: "Despesa", + condition: "Recorrente", + paymentMethod: "Cartão de crédito", + cardId: createdCards.ultravioleta.id, + categoryId: getCategoryId("Assinaturas"), + payerId: adminPayer.id, + recurrenceCount: options.months, + cardMeta: createdCards.ultravioleta, + note: "Assinatura recorrente.", + }); + + createRecords({ + name: "Smart Fit", + amount: 129.9, + purchaseDate: dateForPeriodDay(firstPeriod, 11), + transactionType: "Despesa", + condition: "Recorrente", + paymentMethod: "Cartão de crédito", + cardId: createdCards.ultravioleta.id, + categoryId: getCategoryId("Saúde"), + payerId: adminPayer.id, + recurrenceCount: options.months, + cardMeta: createdCards.ultravioleta, + note: "Plano black mensal.", + }); + + createRecords({ + name: "Spotify", + amount: 21.9, + purchaseDate: dateForPeriodDay(firstPeriod, 8), + transactionType: "Despesa", + condition: "Recorrente", + paymentMethod: "Cartão de crédito", + cardId: createdCards.ultravioleta.id, + categoryId: getCategoryId("Assinaturas"), + payerId: adminPayer.id, + recurrenceCount: options.months, + cardMeta: createdCards.ultravioleta, + note: "Assinatura premium individual.", + }); + + createRecords({ + name: "Plano de saúde - Amil", + amount: 389, + purchaseDate: dateForPeriodDay(firstPeriod, 4), + transactionType: "Despesa", + condition: "Recorrente", + paymentMethod: "Boleto", + accountId: createdAccounts.itau, + categoryId: getCategoryId("Saúde"), + payerId: adminPayer.id, + recurrenceCount: options.months, + dueDate: dateForPeriodDay(firstPeriod, 10), + note: "Plano coletivo com coparticipação.", + }); + + createRecords({ + name: "Seguro auto - Porto Seguro", + amount: 278.5, + purchaseDate: dateForPeriodDay(firstPeriod, 6), + transactionType: "Despesa", + condition: "Recorrente", + paymentMethod: "Boleto", + accountId: createdAccounts.itau, + categoryId: getCategoryId("Transporte"), + payerId: adminPayer.id, + recurrenceCount: options.months, + dueDate: dateForPeriodDay(firstPeriod, 15), + note: "Parcela mensal do seguro do carro.", + }); + + createRecords({ + name: "Condomínio - Edifício Aurora", + amount: 680, + purchaseDate: dateForPeriodDay(firstPeriod, 1), + transactionType: "Despesa", + condition: "Recorrente", + paymentMethod: "Pix", + accountId: createdAccounts.nubank, + categoryId: getCategoryId("Moradia"), + payerId: adminPayer.id, + recurrenceCount: options.months, + dueDate: dateForPeriodDay(firstPeriod, 10), + note: "Taxa condominial mensal.", + }); + + for (const [index, period] of periods.entries()) { + const marketAmount = 420 + index * 37.5; + const uberAmount = 32 + index * 4.75; + const fuelAmount = 170 + index * 18.2; + const ifoodAmount = 62 + index * 7.3; + const investmentYield = 68 + index * 4.4; + + createRecords({ + name: "Assaí Atacadista", + amount: marketAmount, + purchaseDate: dateForPeriodDay(period, 6), + transactionType: "Despesa", + condition: "À vista", + paymentMethod: "Pix", + accountId: createdAccounts.nubank, + categoryId: getCategoryId("Mercado"), + payerId: adminPayer.id, + secondaryPayerId: createdPayers.marina, + isSplit: true, + primarySplitAmount: marketAmount / 2, + secondarySplitAmount: marketAmount / 2, + note: "Compra grande do mes dividida com Marina.", + }); + + createRecords({ + name: "Uber", + amount: uberAmount, + purchaseDate: dateForPeriodDay(period, 14), + transactionType: "Despesa", + condition: "À vista", + paymentMethod: "Pix", + accountId: createdAccounts["mercado-pago"], + categoryId: getCategoryId("Transporte"), + payerId: adminPayer.id, + note: "Corridas do dia a dia.", + }); + + createRecords({ + name: "Posto Shell", + amount: fuelAmount, + purchaseDate: dateForPeriodDay(period, 18), + transactionType: "Despesa", + condition: "À vista", + paymentMethod: "Cartão de débito", + accountId: createdAccounts.itau, + categoryId: getCategoryId("Transporte"), + payerId: adminPayer.id, + note: "Abastecimento mensal.", + }); + + createRecords({ + name: "iFood", + amount: ifoodAmount, + purchaseDate: dateForPeriodDay(period, 20), + transactionType: "Despesa", + condition: "À vista", + paymentMethod: "Cartão de crédito", + cardId: createdCards.ultravioleta.id, + categoryId: getCategoryId("Delivery"), + payerId: adminPayer.id, + cardMeta: createdCards.ultravioleta, + note: "Pedidos de jantar.", + }); + + createRecords({ + name: "Rendimento CDB Itaú", + amount: investmentYield, + purchaseDate: dateForPeriodDay(period, 27), + transactionType: "Receita", + condition: "À vista", + paymentMethod: "Transferência bancária", + accountId: createdAccounts.itau, + categoryId: getCategoryId("Investimentos"), + payerId: adminPayer.id, + note: "Rendimento liquido do CDB.", + }); + + if (index % 2 === 0) { + createRecords({ + name: "Amazon Brasil", + amount: 148 + index * 12.8, + purchaseDate: dateForPeriodDay(period, 22), + transactionType: "Despesa", + condition: "À vista", + paymentMethod: "Cartão de crédito", + cardId: createdCards.itaucard.id, + categoryId: getCategoryId("Compras"), + payerId: adminPayer.id, + cardMeta: createdCards.itaucard, + note: "Itens de casa e pequenos eletrônicos.", + }); + } + + if (index % 2 === 1) { + createRecords({ + name: "Freela - Clínica Aurora", + amount: 1450 + index * 120, + purchaseDate: dateForPeriodDay(period, 24), + transactionType: "Receita", + condition: "À vista", + paymentMethod: "Pix", + accountId: createdAccounts.nubank, + categoryId: getCategoryId("Freelance"), + payerId: adminPayer.id, + note: "Projeto extra de landing page.", + }); + } + + if (index % 3 === 0) { + createRecords({ + name: "Coco Bambu", + amount: 212 + index * 9.5, + purchaseDate: dateForPeriodDay(period, 25), + transactionType: "Despesa", + condition: "À vista", + paymentMethod: "Cartão de crédito", + cardId: createdCards.itaucard.id, + categoryId: getCategoryId("Restaurantes"), + payerId: adminPayer.id, + cardMeta: createdCards.itaucard, + note: "Jantar de fim de semana.", + }); + } + + if (index % 2 === 0) { + createRecords({ + name: "Drogasil", + amount: 46 + index * 6.25, + purchaseDate: dateForPeriodDay(period, 12), + transactionType: "Despesa", + condition: "À vista", + paymentMethod: "Cartão de débito", + accountId: createdAccounts.nubank, + categoryId: getCategoryId("Saúde"), + payerId: adminPayer.id, + note: "Remédios e itens de farmácia.", + }); + } + + if (index % 2 === 1) { + createRecords({ + name: "Ultrafarma", + amount: 52 + index * 5.5, + purchaseDate: dateForPeriodDay(period, 16), + transactionType: "Despesa", + condition: "À vista", + paymentMethod: "Cartão de débito", + accountId: createdAccounts.nubank, + categoryId: getCategoryId("Saúde"), + payerId: adminPayer.id, + note: "Medicamentos e suplementos.", + }); + } + + createRecords({ + name: "Pão de Açúcar", + amount: 280 + index * 22.4, + purchaseDate: dateForPeriodDay(period, 10), + transactionType: "Despesa", + condition: "À vista", + paymentMethod: "Cartão de crédito", + cardId: createdCards.itaucard.id, + categoryId: getCategoryId("Mercado"), + payerId: adminPayer.id, + cardMeta: createdCards.itaucard, + note: "Compra semanal de hortifruti e frios.", + }); + + createRecords({ + name: "McDonald's", + amount: 42 + index * 3.8, + purchaseDate: dateForPeriodDay(period, 16), + transactionType: "Despesa", + condition: "À vista", + paymentMethod: "Pré-Pago | VR/VA", + accountId: createdAccounts["mercado-pago"], + categoryId: getCategoryId("Alimentação"), + payerId: adminPayer.id, + note: "Almoço rápido no intervalo.", + }); + + if (index % 2 === 0) { + createRecords({ + name: "Cinemark", + amount: 130 + index * 8.5, + purchaseDate: dateForPeriodDay(period, 21), + transactionType: "Despesa", + condition: "À vista", + paymentMethod: "Cartão de crédito", + cardId: createdCards.itaucard.id, + categoryId: getCategoryId("Lazer"), + payerId: adminPayer.id, + cardMeta: createdCards.itaucard, + note: "Ingresso + pipoca pra dois.", + }); + } + } + + createRecords({ + name: "Notebook Dell Inspiron", + amount: 7199.2, + purchaseDate: dateForPeriodDay(firstPeriod, 28), + transactionType: "Despesa", + condition: "Parcelado", + paymentMethod: "Cartão de crédito", + cardId: createdCards.itaucard.id, + categoryId: getCategoryId("Compras"), + payerId: adminPayer.id, + installmentCount: 8, + cardMeta: createdCards.itaucard, + note: "Troca do notebook do home office.", + }); + + createRecords({ + name: "Ar-condicionado Springer Midea", + amount: 2899.8, + purchaseDate: dateForPeriodDay(secondPeriod, 26), + transactionType: "Despesa", + condition: "Parcelado", + paymentMethod: "Cartão de crédito", + cardId: createdCards.ultravioleta.id, + categoryId: getCategoryId("Moradia"), + payerId: adminPayer.id, + installmentCount: 6, + cardMeta: createdCards.ultravioleta, + note: "Compra feita para o quarto do casal.", + }); + + createRecords({ + name: "Passagem LATAM - Salvador", + amount: 2140.5, + purchaseDate: dateForPeriodDay(thirdPeriod, 16), + transactionType: "Despesa", + condition: "Parcelado", + paymentMethod: "Cartão de crédito", + cardId: createdCards.ultravioleta.id, + categoryId: getCategoryId("Viagem"), + payerId: adminPayer.id, + secondaryPayerId: createdPayers.eduardo, + isSplit: true, + primarySplitAmount: 1498.35, + secondarySplitAmount: 642.15, + installmentCount: 5, + cardMeta: createdCards.ultravioleta, + note: "Viagem dividida com Eduardo.", + }); + + createRecords({ + name: "Reembolso plano de saúde", + amount: 185.4, + purchaseDate: dateForPeriodDay(middlePeriod, 26), + transactionType: "Receita", + condition: "À vista", + paymentMethod: "Pix", + accountId: createdAccounts.nubank, + categoryId: getCategoryId("Reembolso"), + payerId: adminPayer.id, + note: "Reembolso de consulta realizada no mes anterior.", + }); + + createRecords({ + name: "Consulta dentista - Dra. Ana", + amount: 240, + purchaseDate: dateForPeriodDay(middlePeriod, 9), + transactionType: "Despesa", + condition: "À vista", + paymentMethod: "Boleto", + accountId: createdAccounts.itau, + categoryId: getCategoryId("Saúde"), + payerId: adminPayer.id, + dueDate: dateForPeriodDay(middlePeriod, 17), + note: "Procedimento odontológico.", + }); + + createRecords({ + name: "IPVA 2026 - parcela única", + amount: 684.32, + purchaseDate: dateForPeriodDay(lastPeriod, 10), + transactionType: "Despesa", + condition: "À vista", + paymentMethod: "Boleto", + accountId: createdAccounts.itau, + categoryId: getCategoryId("Transporte"), + payerId: adminPayer.id, + dueDate: dateForPeriodDay(lastPeriod, 25), + note: "Mantido em aberto para testar lembretes e listagem de boletos.", + settlementBehavior: "open", + }); + + createRecords({ + name: "iPhone 16 Pro", + amount: 9299.1, + purchaseDate: dateForPeriodDay(secondPeriod, 14), + transactionType: "Despesa", + condition: "Parcelado", + paymentMethod: "Cartão de crédito", + cardId: createdCards.itaucard.id, + categoryId: getCategoryId("Compras"), + payerId: adminPayer.id, + installmentCount: 12, + cardMeta: createdCards.itaucard, + note: "Troca do celular, 12x sem juros.", + }); + + createRecords({ + name: "Alura - assinatura anual", + amount: 1499.9, + purchaseDate: dateForPeriodDay(firstPeriod, 20), + transactionType: "Despesa", + condition: "À vista", + paymentMethod: "Cartão de crédito", + cardId: createdCards.ultravioleta.id, + categoryId: getCategoryId("Educação"), + payerId: adminPayer.id, + cardMeta: createdCards.ultravioleta, + note: "Plano anual para desenvolvimento web e mobile.", + }); + + createRecords({ + name: "Presente - aniversário Eduardo", + amount: 189, + purchaseDate: dateForPeriodDay(thirdPeriod, 11), + transactionType: "Despesa", + condition: "À vista", + paymentMethod: "Pix", + accountId: createdAccounts.nubank, + categoryId: getCategoryId("Presentes"), + payerId: adminPayer.id, + note: "Presente de aniversário para o Eduardo.", + }); + + createRecords({ + name: "Zara - jaqueta e calça", + amount: 599.8, + purchaseDate: dateForPeriodDay(middlePeriod, 17), + transactionType: "Despesa", + condition: "À vista", + paymentMethod: "Cartão de crédito", + cardId: createdCards.ultravioleta.id, + categoryId: getCategoryId("Vestuário"), + payerId: adminPayer.id, + cardMeta: createdCards.ultravioleta, + note: "Roupas de inverno.", + }); + + createRecords({ + name: "Show Criolo - Audio Club", + amount: 320, + purchaseDate: dateForPeriodDay(middlePeriod, 8), + transactionType: "Despesa", + condition: "Parcelado", + paymentMethod: "Cartão de crédito", + cardId: createdCards.ultravioleta.id, + categoryId: getCategoryId("Lazer"), + payerId: adminPayer.id, + secondaryPayerId: createdPayers.marina, + isSplit: true, + primarySplitAmount: 160, + secondarySplitAmount: 160, + installmentCount: 2, + cardMeta: createdCards.ultravioleta, + note: "Ingressos divididos com a Marina.", + }); + + await db.insert(transactions).values(seedTransactionRecords); + summary.transactions += seedTransactionRecords.length; + + const { createdInvoices, createdInvoicePayments } = + await seedInvoicesForCards({ + userId: targetUser.id, + adminPayerId: adminPayer.id, + cardsByKey: createdCards, + paymentCategoryId: categoriesByName.get("Pagamentos"), + insertedTransactionRecords: seedTransactionRecords, + }); + + summary.invoices += createdInvoices; + summary.invoicePayments += createdInvoicePayments; + summary.transactions += createdInvoicePayments; + + const inboxItemsData = [ + { + userId: targetUser.id, + sourceApp: "com.nu.production", + sourceAppName: "Nubank", + originalTitle: "Compra aprovada", + originalText: + "Compra de R$ 73,90 aprovada no cartão Ultravioleta em RAPPI*RAPPI BR", + notificationTimestamp: parseLocalDateString( + dateForPeriodDay(lastPeriod, 3), + ), + parsedName: "Rappi", + parsedAmount: "73.90", + status: "pending" as const, + transactionId: null, + processedAt: null, + discardedAt: null, + }, + { + userId: targetUser.id, + sourceApp: "com.nu.production", + sourceAppName: "Nubank", + originalTitle: "Pix enviado", + originalText: "Você enviou R$ 210,00 via Pix para Marina Oliveira.", + notificationTimestamp: parseLocalDateString( + dateForPeriodDay(lastPeriod, 5), + ), + parsedName: "Marina Oliveira", + parsedAmount: "210.00", + status: "pending" as const, + transactionId: null, + processedAt: null, + discardedAt: null, + }, + { + userId: targetUser.id, + sourceApp: "br.com.itau.personnalite", + sourceAppName: "Itaú Personnalité", + originalTitle: "Débito em conta", + originalText: "Débito de R$45,80 realizado. PADARIA NOSSA SENHORA", + notificationTimestamp: parseLocalDateString( + dateForPeriodDay(lastPeriod, 7), + ), + parsedName: "Padaria Nossa Senhora", + parsedAmount: "45.80", + status: "pending" as const, + transactionId: null, + processedAt: null, + discardedAt: null, + }, + { + userId: targetUser.id, + sourceApp: "br.com.itau.personnalite", + sourceAppName: "Itaú Personnalité", + originalTitle: "Compra aprovada", + originalText: + "Compra de R$387,40 aprovada no cartão PÃO DE AÇÚCAR. Limite disponível: R$8.112,60", + notificationTimestamp: parseLocalDateString( + dateForPeriodDay(lastPeriod, 10), + ), + parsedName: "Pão de Açúcar", + parsedAmount: "387.40", + status: "pending" as const, + transactionId: null, + processedAt: null, + discardedAt: null, + }, + { + userId: targetUser.id, + sourceApp: "com.mercadopago.wallet", + sourceAppName: "Mercado Pago", + originalTitle: null, + originalText: "Pagamento de R$38,50 aprovado em 99APP*CORRIDA", + notificationTimestamp: parseLocalDateString( + dateForPeriodDay(lastPeriod, 13), + ), + parsedName: "99App", + parsedAmount: "38.50", + status: "pending" as const, + transactionId: null, + processedAt: null, + discardedAt: null, + }, + { + userId: targetUser.id, + sourceApp: "com.nu.production", + sourceAppName: "Nubank", + originalTitle: "Compra aprovada", + originalText: + "Compra de R$ 124,90 aprovada no cartão Ultravioleta em SHOPEE*SHOPEE BR", + notificationTimestamp: parseLocalDateString( + dateForPeriodDay(lastPeriod, 18), + ), + parsedName: "Shopee", + parsedAmount: "124.90", + status: "pending" as const, + transactionId: null, + processedAt: null, + discardedAt: null, + }, + { + userId: targetUser.id, + sourceApp: "com.nu.production", + sourceAppName: "Nubank", + originalTitle: "Compra aprovada", + originalText: + "Compra de R$ 199,90 aprovada no cartão Ultravioleta em AMERICANAS S.A", + notificationTimestamp: parseLocalDateString( + dateForPeriodDay(lastPeriod, 21), + ), + parsedName: "Americanas", + parsedAmount: "199.90", + status: "pending" as const, + transactionId: null, + processedAt: null, + discardedAt: null, + }, + { + userId: targetUser.id, + sourceApp: "com.mercadopago.wallet", + sourceAppName: "Mercado Pago", + originalTitle: null, + originalText: "Você pagou R$12,50 via Pix para POSTO IPIRANGA", + notificationTimestamp: parseLocalDateString( + dateForPeriodDay(lastPeriod, 22), + ), + parsedName: "Posto Ipiranga", + parsedAmount: "12.50", + status: "pending" as const, + transactionId: null, + processedAt: null, + discardedAt: null, + }, + ]; + + await db.insert(inboxItems).values(inboxItemsData); + summary.inboxItems += inboxItemsData.length; + + const finalPeriods = Array.from( + new Set( + seedTransactionRecords + .map((record) => record.period ?? "") + .filter(Boolean), + ), + ).sort(comparePeriods); + + const seededFrom = finalPeriods[0] ?? options.startPeriod; + const seededTo = finalPeriods[finalPeriods.length - 1] ?? options.startPeriod; + + console.log("Seed concluído com sucesso."); + console.log( + JSON.stringify( + { + userId: targetUser.id, + userName: targetUser.name, + startPeriod: options.startPeriod, + months: options.months, + seededFrom, + seededTo, + todayPeriod: businessToday.period, + summary, + }, + null, + 2, + ), + ); +} + +void main() + .then(() => process.exit(0)) + .catch((error) => { + console.error( + error instanceof Error + ? error.message + : "Erro inesperado ao popular conta.", + ); + process.exit(1); + }); diff --git a/src/app/(dashboard)/accounts/[accountId]/statement/page.tsx b/src/app/(dashboard)/accounts/[accountId]/statement/page.tsx index 17f12e2..9b6aad1 100644 --- a/src/app/(dashboard)/accounts/[accountId]/statement/page.tsx +++ b/src/app/(dashboard)/accounts/[accountId]/statement/page.tsx @@ -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} diff --git a/src/app/(dashboard)/transactions/page.tsx b/src/app/(dashboard)/transactions/page.tsx index fcc788b..e9246fa 100644 --- a/src/app/(dashboard)/transactions/page.tsx +++ b/src/app/(dashboard)/transactions/page.tsx @@ -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} /> diff --git a/src/features/accounts/statement-queries.ts b/src/features/accounts/statement-queries.ts index a944cde..9b5fa76 100644 --- a/src/features/accounts/statement-queries.ts +++ b/src/features/accounts/statement-queries.ts @@ -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 { + 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` @@ -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, }); } diff --git a/src/features/reports/components/category-report-export.tsx b/src/features/reports/components/category-report-export.tsx index 7699f6b..29b6789 100644 --- a/src/features/reports/components/category-report-export.tsx +++ b/src/features/reports/components/category-report-export.tsx @@ -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" }); diff --git a/src/features/transactions/actions.ts b/src/features/transactions/actions.ts index d0e3a93..d14d8e5 100644 --- a/src/features/transactions/actions.ts +++ b/src/features/transactions/actions.ts @@ -1,1687 +1,69 @@ "use server"; -import { randomUUID } from "node:crypto"; -import { and, asc, eq, inArray, sql } from "drizzle-orm"; -import { z } from "zod"; import { - cards, - categories, - financialAccounts, - payers, - transactions, -} from "@/db/schema"; + createMassTransactionsAction as createMassTransactionsActionImpl, + deleteMultipleTransactionsAction as deleteMultipleTransactionsActionImpl, + deleteTransactionBulkAction as deleteTransactionBulkActionImpl, + updateTransactionBulkAction as updateTransactionBulkActionImpl, +} from "./actions/bulk-actions"; +import { exportTransactionsDataAction as exportTransactionsDataActionImpl } from "./actions/export-actions"; 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 { - handleActionError, - revalidateForEntity, -} 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 { noteSchema, uuidSchema } from "@/shared/lib/schemas/common"; -import type { ActionResult } from "@/shared/lib/types/actions"; -import { formatDecimalForDbRequired } from "@/shared/utils/currency"; -import { - getBusinessTodayDate, - parseLocalDateString, -} from "@/shared/utils/date"; -import { addMonthsToPeriod } from "@/shared/utils/period"; - -// ============================================================================ -// Authorization Validation Functions -// ============================================================================ - -async function validatePagadorOwnership( - userId: string, - payerId: string | null | undefined, -): Promise { - if (!payerId) return true; // Se não tem payerId, não precisa validar - - const pagador = await db.query.payers.findFirst({ - where: and(eq(payers.id, payerId), eq(payers.userId, userId)), - }); - - return !!pagador; -} - -async function validateCategoriaOwnership( - userId: string, - categoryId: string | null | undefined, -): Promise { - if (!categoryId) return true; - - const categoria = await db.query.categories.findFirst({ - where: and(eq(categories.id, categoryId), eq(categories.userId, userId)), - }); - - return !!categoria; -} - -async function validateContaOwnership( - userId: string, - accountId: string | null | undefined, -): Promise { - if (!accountId) return true; - - const conta = await db.query.financialAccounts.findFirst({ - where: and( - eq(financialAccounts.id, accountId), - eq(financialAccounts.userId, userId), - ), - }); - - return !!conta; -} - -async function validateCartaoOwnership( - userId: string, - cardId: string | null | undefined, -): Promise { - if (!cardId) return true; - - const cartao = await db.query.cards.findFirst({ - where: and(eq(cards.id, cardId), eq(cards.userId, userId)), - }); - - return !!cartao; -} - -// ============================================================================ -// Utility Functions -// ============================================================================ - -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}`; -}; - -const isValidDateInput = (value: string) => - !Number.isNaN(parseLocalDateString(value).getTime()); - -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 & { 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.", - }); - } - - // Validate custom split amounts sum to total - if ( - data.primarySplitAmount !== undefined && - data.secondarySplitAmount !== undefined - ) { - const sum = data.primarySplitAmount + data.secondarySplitAmount; - const total = Math.abs(data.amount); - // Allow 1 cent tolerance for rounding differences - 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.", - }); - } - } - } -}; - -const createSchema = baseFields.superRefine(refineLancamento); -const updateSchema = baseFields - .extend({ - id: uuidSchema("Lançamento"), - }) - .superRefine(refineLancamento); - -const deleteSchema = z.object({ - id: uuidSchema("Lançamento"), -}); - -const toggleSettlementSchema = z.object({ - id: uuidSchema("Lançamento"), - value: z.boolean({ - message: "Informe o status de pagamento.", - }), -}); - -type BaseInput = z.infer; -type CreateInput = z.infer; -type UpdateInput = z.infer; -type DeleteInput = z.infer; -type ToggleSettlementInput = z.infer; - -const revalidate = () => revalidateForEntity("transactions"); - -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; -}; - -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; - -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), - ); -}; - -const addMonthsToDate = (value: Date, offset: number) => { - const result = new Date(value); - const originalDay = result.getDate(); - - result.setDate(1); - result.setMonth(result.getMonth() + offset); - - const lastDay = new Date( - result.getFullYear(), - result.getMonth() + 1, - 0, - ).getDate(); - - result.setDate(Math.min(originalDay, lastDay)); - return result; -}; - -type Share = { - payerId: string | null; - amountCents: number; -}; - -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."); - } - - // Use custom split amounts if provided - if ( - primarySplitAmountCents !== undefined && - secondarySplitAmountCents !== undefined - ) { - return [ - { payerId, amountCents: primarySplitAmountCents }, - { - payerId: secondaryPayerId, - amountCents: secondarySplitAmountCents, - }, - ]; - } - - // Fallback to equal split - 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; -}; - -type TransactionInsert = typeof transactions.$inferInsert; - -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: 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; -}; + createTransactionAction as createTransactionActionImpl, + deleteTransactionAction as deleteTransactionActionImpl, + toggleTransactionSettlementAction as toggleTransactionSettlementActionImpl, + updateTransactionAction as updateTransactionActionImpl, +} from "./actions/single-actions"; export async function createTransactionAction( - input: CreateInput, -): Promise { - try { - const user = await getUser(); - const data = createSchema.parse(input); - - // Validar propriedade dos recursos referenciados - if (data.payerId) { - const isValid = await validatePagadorOwnership(user.id, data.payerId); - if (!isValid) { - return { - success: false, - error: "Payer não encontrado ou sem permissão.", - }; - } - } - - if (data.secondaryPayerId) { - const isValid = await validatePagadorOwnership( - user.id, - data.secondaryPayerId, - ); - if (!isValid) { - return { - success: false, - error: "Payer secundário não encontrado ou sem permissão.", - }; - } - } - - if (data.categoryId) { - const isValid = await validateCategoriaOwnership( - user.id, - data.categoryId, - ); - if (!isValid) { - return { success: false, error: "Category não encontrada." }; - } - } - - if (data.accountId) { - const isValid = await validateContaOwnership(user.id, data.accountId); - if (!isValid) { - return { success: false, error: "FinancialAccount não encontrada." }; - } - } - - if (data.cardId) { - const isValid = await validateCartaoOwnership(user.id, data.cardId); - if (!isValid) { - return { success: false, error: "Cartão não encontrado." }; - } - } - - 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(); - - return { success: true, message: "Lançamento criado com sucesso." }; - } catch (error) { - return handleActionError(error); - } + ...args: Parameters +): ReturnType { + return createTransactionActionImpl(...args); } export async function updateTransactionAction( - input: UpdateInput, -): Promise { - try { - const user = await getUser(); - const data = updateSchema.parse(input); - - // Validar propriedade dos recursos referenciados - if (data.payerId) { - const isValid = await validatePagadorOwnership(user.id, data.payerId); - if (!isValid) { - return { - success: false, - error: "Payer não encontrado ou sem permissão.", - }; - } - } - - if (data.secondaryPayerId) { - const isValid = await validatePagadorOwnership( - user.id, - data.secondaryPayerId, - ); - if (!isValid) { - return { - success: false, - error: "Payer secundário não encontrado ou sem permissão.", - }; - } - } - - if (data.categoryId) { - const isValid = await validateCategoriaOwnership( - user.id, - data.categoryId, - ); - if (!isValid) { - return { success: false, error: "Category não encontrada." }; - } - } - - if (data.accountId) { - const isValid = await validateContaOwnership(user.id, data.accountId); - if (!isValid) { - return { success: false, error: "FinancialAccount não encontrada." }; - } - } - - if (data.cardId) { - const isValid = await validateCartaoOwnership(user.id, data.cardId); - if (!isValid) { - return { success: false, error: "Cartão não encontrado." }; - } - } - - 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." }; - } - - // Bloquear edição de lançamentos com categories protegidas - // Nota: "Transferência interna" foi removida para permitir correção de valores - 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(); - - return { success: true, message: "Lançamento atualizado com sucesso." }; - } catch (error) { - return handleActionError(error); - } + ...args: Parameters +): ReturnType { + return updateTransactionActionImpl(...args); } export async function deleteTransactionAction( - input: DeleteInput, -): Promise { - 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." }; - } - - // Bloquear remoção de lançamentos com categories protegidas - // Nota: "Transferência interna" foi removida para permitir correção/exclusão - 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(); - - return { success: true, message: "Lançamento removido com sucesso." }; - } catch (error) { - return handleActionError(error); - } + ...args: Parameters +): ReturnType { + return deleteTransactionActionImpl(...args); } export async function toggleTransactionSettlementAction( - input: ToggleSettlementInput, -): Promise { - 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(); - - return { - success: true, - message: data.value - ? "Lançamento marcado como pago." - : "Pagamento desfeito com sucesso.", - }; - } catch (error) { - return handleActionError(error); - } + ...args: Parameters +): ReturnType { + return toggleTransactionSettlementActionImpl(...args); } -const deleteBulkSchema = z.object({ - id: uuidSchema("Lançamento"), - scope: z.enum(["current", "future", "all"], { - message: "Escopo de ação inválido.", - }), -}); - -type DeleteBulkInput = z.infer; - export async function deleteTransactionBulkAction( - input: DeleteBulkInput, -): Promise { - 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(); - 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(); - 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(); - 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); - } + ...args: Parameters +): ReturnType { + return deleteTransactionBulkActionImpl(...args); } -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(), -}); - -type UpdateBulkInput = z.infer; - export async function updateTransactionBulkAction( - input: UpdateBulkInput, -): Promise { - try { - const user = await getUser(); - const data = updateBulkSchema.parse(input); - - 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 = { - 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 applyUpdates = async ( - records: Array<{ id: string; purchaseDate: Date | null }>, - ) => { - if (records.length === 0) { - return; - } - - await db.transaction(async (tx: typeof db) => { - for (const record of records) { - const perRecordPayload: Record = { - ...baseUpdatePayload, - }; - - const dueDateForRecord = buildDueDateForRecord(record.purchaseDate); - if (dueDateForRecord !== undefined) { - perRecordPayload.dueDate = dueDateForRecord; - } - - if (hasBoletoPaymentDateUpdate) { - perRecordPayload.boletoPaymentDate = baseBoletoPaymentDate ?? null; - } - - await tx - .update(transactions) - .set(perRecordPayload) - .where( - and( - eq(transactions.id, record.id), - eq(transactions.userId, user.id), - ), - ); - } - }); - }; - - if (data.scope === "current") { - await applyUpdates([ - { - id: data.id, - purchaseDate: existing.purchaseDate ?? null, - }, - ]); - - revalidate(); - 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(); - 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(); - 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); - } + ...args: Parameters +): ReturnType { + return updateTransactionBulkActionImpl(...args); } -// Mass Add Schema -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(), -}); - -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."), -}); - -type MassAddInput = z.infer; - export async function createMassTransactionsAction( - input: MassAddInput, -): Promise { - try { - const user = await getUser(); - const data = massAddSchema.parse(input); - - // Validar campos fixos - if (data.fixedFields.accountId) { - const isValid = await validateContaOwnership( - user.id, - data.fixedFields.accountId, - ); - if (!isValid) { - return { success: false, error: "FinancialAccount não encontrada." }; - } - } - - if (data.fixedFields.cardId) { - const isValid = await validateCartaoOwnership( - user.id, - data.fixedFields.cardId, - ); - if (!isValid) { - return { success: false, error: "Cartão não encontrado." }; - } - } - - // Validar cada transação individual - for (let i = 0; i < data.transactions.length; i++) { - const transaction = data.transactions[i]; - - if (transaction.payerId) { - const isValid = await validatePagadorOwnership( - user.id, - transaction.payerId, - ); - if (!isValid) { - return { - success: false, - error: `Payer não encontrado na transação ${i + 1}.`, - }; - } - } - - if (transaction.categoryId) { - const isValid = await validateCategoriaOwnership( - user.id, - transaction.categoryId, - ); - if (!isValid) { - return { - success: false, - error: `Category não encontrada na transação ${i + 1}.`, - }; - } - } - } - - // Default values for non-fixed fields - 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; - }> = []; - - // Process each transaction - 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."); - } - - // Insert all records in a single transaction - await db.transaction(async (tx: typeof db) => { - await tx.insert(transactions).values(allRecords); - }); - - // Send notifications - const notificationEntries = buildEntriesByPayer(notificationData); - - if (notificationEntries.size > 0) { - await sendPayerAutoEmails({ - userLabel: resolveUserLabel(user), - action: "created", - entriesByPagador: notificationEntries, - }); - } - - revalidate(); - - const count = allRecords.length; - return { - success: true, - message: `${count} ${ - count === 1 ? "lançamento criado" : "lançamentos criados" - } com sucesso.`, - }; - } catch (error) { - return handleActionError(error); - } + ...args: Parameters +): ReturnType { + return createMassTransactionsActionImpl(...args); } -// Delete multiple transactions at once -const deleteMultipleSchema = z.object({ - ids: z - .array(uuidSchema("Lançamento")) - .min(1, "Selecione pelo menos um lançamento."), -}); - -type DeleteMultipleInput = z.infer; - export async function deleteMultipleTransactionsAction( - input: DeleteMultipleInput, -): Promise { - try { - const user = await getUser(); - const data = deleteMultipleSchema.parse(input); - - // Fetch all transactions to be deleted - 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." }; - } - - // Delete all transactions - await db - .delete(transactions) - .where( - and( - inArray(transactions.id, data.ids), - eq(transactions.userId, user.id), - ), - ); - - // Send notifications - const notificationData = existing - .filter( - ( - item: (typeof existing)[number], - ): item is typeof item & { - payerId: NonNullable; - } => 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(); - - const count = existing.length; - return { - success: true, - message: `${count} ${ - count === 1 ? "lançamento removido" : "lançamentos removidos" - } com sucesso.`, - }; - } catch (error) { - return handleActionError(error); - } + ...args: Parameters +): ReturnType { + return deleteMultipleTransactionsActionImpl(...args); +} + +export async function exportTransactionsDataAction( + ...args: Parameters +): ReturnType { + return exportTransactionsDataActionImpl(...args); } diff --git a/src/features/transactions/actions/bulk-actions.ts b/src/features/transactions/actions/bulk-actions.ts new file mode 100644 index 0000000..ed2ac2a --- /dev/null +++ b/src/features/transactions/actions/bulk-actions.ts @@ -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 { + 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 { + 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 = { + 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; + } + >(); + + for (const record of records) { + const dueDateForRecord = buildDueDateForRecord(record.purchaseDate); + const perRecordPayload: Record = { + ...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 { + try { + const user = await getUser(); + const data = massAddSchema.parse(input); + + const uniquePayerIds = new Set(); + const uniqueCategoryIds = new Set(); + 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 { + 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; + } => 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); + } +} diff --git a/src/features/transactions/actions/core.ts b/src/features/transactions/actions/core.ts new file mode 100644 index 0000000..e160b41 --- /dev/null +++ b/src/features/transactions/actions/core.ts @@ -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 { + 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) => [ + ...new Set(ids.filter((id): id is string => Boolean(id))), +]; + +export async function fetchOwnedPayerIds( + userId: string, + payerIds: Array, +): Promise> { + 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 { + 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, +): Promise> { + 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 { + 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, +): Promise> { + 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 { + 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, +): Promise> { + 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 { + 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 & { 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; +export type CreateInput = z.infer; +export type UpdateInput = z.infer; +export type DeleteInput = z.infer; +export type ToggleSettlementInput = z.infer; + +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; + +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; + +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; + +export const deleteMultipleSchema = z.object({ + ids: z + .array(uuidSchema("Lançamento")) + .min(1, "Selecione pelo menos um lançamento."), +}); + +export type DeleteMultipleInput = z.infer; diff --git a/src/features/transactions/actions/export-actions.ts b/src/features/transactions/actions/export-actions.ts new file mode 100644 index 0000000..e74fe52 --- /dev/null +++ b/src/features/transactions/actions/export-actions.ts @@ -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 = 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 }> +> { + 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; + }>; + } +} diff --git a/src/features/transactions/actions/single-actions.ts b/src/features/transactions/actions/single-actions.ts new file mode 100644 index 0000000..121a821 --- /dev/null +++ b/src/features/transactions/actions/single-actions.ts @@ -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 { + 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 { + 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 { + 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 { + 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); + } +} diff --git a/src/features/transactions/column-order.ts b/src/features/transactions/column-order.ts index fcb65ff..d22b317 100644 --- a/src/features/transactions/column-order.ts +++ b/src/features/transactions/column-order.ts @@ -20,8 +20,8 @@ export const LANCAMENTOS_COLUMN_LABELS: Record = { amount: "Valor", condition: "Condição", paymentMethod: "Forma de Pagamento", - categoriaName: "Category", - pagadorName: "Payer", + categoriaName: "Categoria", + pagadorName: "Pagador", note: "Anotação", contaCartao: "Conta/Cartão", }; diff --git a/src/features/transactions/components/page/transactions-page.tsx b/src/features/transactions/components/page/transactions-page.tsx index 6e6ab03..5b89852 100644 --- a/src/features/transactions/components/page/transactions-page.tsx +++ b/src/features/transactions/components/page/transactions-page.tsx @@ -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} diff --git a/src/features/transactions/components/table/transactions-filters.tsx b/src/features/transactions/components/table/transactions-filters.tsx index 607f6c2..709b171 100644 --- a/src/features/transactions/components/table/transactions-filters.tsx +++ b/src/features/transactions/components/table/transactions-filters.tsx @@ -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(() => { diff --git a/src/features/transactions/components/table/transactions-table.tsx b/src/features/transactions/components/table/transactions-table.tsx index d713641..fa62e0b 100644 --- a/src/features/transactions/components/table/transactions-table.tsx +++ b/src/features/transactions/components/table/transactions-table.tsx @@ -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 = ({ {note} @@ -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([ { id: "purchaseDate", desc: true }, ]); @@ -796,6 +808,7 @@ export function TransactionsTable({ pageSize: 30, }); const [rowSelection, setRowSelection] = useState({}); + 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 ( {showTopControls ? ( @@ -943,6 +1001,7 @@ export function TransactionsTable({ ) : null } @@ -1073,9 +1132,15 @@ export function TransactionsTable({ {totalRows} lançamentos