diff --git a/src/app/(dashboard)/accounts/[accountId]/statement/page.tsx b/src/app/(dashboard)/accounts/[accountId]/statement/page.tsx index 00e1a14..d0fee88 100644 --- a/src/app/(dashboard)/accounts/[accountId]/statement/page.tsx +++ b/src/app/(dashboard)/accounts/[accountId]/statement/page.tsx @@ -3,6 +3,7 @@ import { notFound } from "next/navigation"; import { connection } from "next/server"; import { AccountDialog } from "@/features/accounts/components/account-dialog"; import { AccountStatementCard } from "@/features/accounts/components/account-statement-card"; +import { AdjustBalanceDialog } from "@/features/accounts/components/adjust-balance-dialog"; import type { Account } from "@/features/accounts/components/types"; import { fetchAccountData, @@ -141,6 +142,13 @@ export default async function Page({ params, searchParams }: PageProps) { totalIncomes={totalIncomes} totalExpenses={totalExpenses} logo={account.logo} + balanceAdjustment={ + + } actions={ ; + +export async function adjustAccountBalanceAction( + input: AdjustAccountBalanceInput, +): Promise { + try { + const user = await getUser(); + const data = adjustAccountBalanceSchema.parse(input); + const adminPayerId = await getAdminPayerId(user.id); + + if (!adminPayerId) { + throw new Error( + "Pessoa com papel administrador não encontrada. Crie uma pessoa admin antes de ajustar o saldo.", + ); + } + + let message = "Ajuste de saldo registrado."; + + await db.transaction(async (tx: typeof db) => { + const account = await tx.query.financialAccounts.findFirst({ + columns: { id: true }, + where: and( + eq(financialAccounts.id, data.accountId), + eq(financialAccounts.userId, user.id), + ), + }); + + if (!account) { + throw new Error("Conta não encontrada."); + } + + const existing = await tx.query.transactions.findFirst({ + columns: { id: true, amount: true }, + where: and( + eq(transactions.userId, user.id), + eq(transactions.accountId, data.accountId), + eq(transactions.period, data.period), + eq(transactions.name, ACCOUNT_BALANCE_ADJUSTMENT_NAME), + ), + }); + + const existingAmount = Number(existing?.amount ?? 0); + const baseBalance = data.currentBalance - existingAmount; + const adjustmentAmount = + Math.round((data.targetBalance - baseBalance) * 100) / 100; + + if (adjustmentAmount === 0) { + if (existing) { + await tx.delete(transactions).where(eq(transactions.id, existing.id)); + message = "Ajuste de saldo removido."; + } else { + message = "Nada a ajustar — o saldo já está correto."; + } + return; + } + + const isExpense = adjustmentAmount < 0; + const categoryName = isExpense ? "Outras despesas" : "Outras receitas"; + + const category = await tx.query.categories.findFirst({ + columns: { id: true }, + where: and( + eq(categories.userId, user.id), + eq(categories.name, categoryName), + ), + }); + + const amount = formatDecimalForDbRequired(adjustmentAmount); + const note = `O saldo era ${formatCurrency(baseBalance)} mas o correto é ${formatCurrency(data.targetBalance)}.`; + + const payload = { + condition: INITIAL_BALANCE_CONDITION, + name: ACCOUNT_BALANCE_ADJUSTMENT_NAME, + paymentMethod: INITIAL_BALANCE_PAYMENT_METHOD, + note, + amount, + purchaseDate: getBusinessTodayDate(), + transactionType: isExpense + ? ("Despesa" as const) + : ("Receita" as const), + period: data.period, + isSettled: true, + userId: user.id, + accountId: data.accountId, + cardId: null, + categoryId: category?.id ?? null, + payerId: adminPayerId, + }; + + if (existing) { + await tx + .update(transactions) + .set(payload) + .where(eq(transactions.id, existing.id)); + } else { + await tx.insert(transactions).values(payload); + } + }); + + revalidateForEntity("accounts", user.id); + revalidateForEntity("transactions", user.id); + + return { success: true, message }; + } catch (error) { + return handleActionError(error); + } +} diff --git a/src/features/accounts/components/account-statement-card.tsx b/src/features/accounts/components/account-statement-card.tsx index ca322a4..a9a5005 100644 --- a/src/features/accounts/components/account-statement-card.tsx +++ b/src/features/accounts/components/account-statement-card.tsx @@ -26,6 +26,7 @@ type AccountStatementCardProps = { totalExpenses: number; logo?: string | null; actions?: React.ReactNode; + balanceAdjustment?: React.ReactNode; }; const getAccountStatusBadgeVariant = ( @@ -45,6 +46,7 @@ export function AccountStatementCard({ totalExpenses, logo, actions, + balanceAdjustment, }: AccountStatementCardProps) { const logoPath = resolveLogoSrc(logo); const resultado = totalIncomes - totalExpenses; @@ -84,10 +86,13 @@ export function AccountStatementCard({

Saldo ao final do período

- +
+ + {balanceAdjustment} +
0 then ${transactions.amount} else 0 end ), @@ -86,7 +91,9 @@ export async function fetchAccountSummary( sum( case when ${transactions.note} = ${INITIAL_BALANCE_NOTE} then 0 + when ${transactions.note} ilike ${`${REFUND_NOTE_PREFIX}%`} then abs(${transactions.amount}) when ${transactions.transactionType} = 'Despesa' then ${transactions.amount} + when ${transactions.transactionType} = 'Transferência' and ${transactions.amount} < 0 then ${transactions.amount} else 0 end ), @@ -135,7 +142,8 @@ export async function fetchAccountSummary( const openingBalance = initialBalance + previousMovements; const netAmount = Number(periodSummary?.netAmount ?? 0); const totalIncomes = Number(periodSummary?.incomes ?? 0); - const totalExpenses = Math.abs(Number(periodSummary?.expenses ?? 0)); + const expenseNet = Number(periodSummary?.expenses ?? 0); + const totalExpenses = Math.max(0, -expenseNet); const currentBalance = openingBalance + netAmount; return { diff --git a/src/features/invoices/actions.ts b/src/features/invoices/actions.ts index e862999..4c1febe 100644 --- a/src/features/invoices/actions.ts +++ b/src/features/invoices/actions.ts @@ -2,8 +2,17 @@ import { and, eq, sql } from "drizzle-orm"; import { z } from "zod"; -import { cards, categories, invoices, transactions } from "@/db/schema"; -import { buildInvoicePaymentNote } from "@/shared/lib/accounts/constants"; +import { + cards, + categories, + financialAccounts, + invoices, + transactions, +} from "@/db/schema"; +import { + buildInvoicePaymentNote, + INVOICE_ADJUSTMENT_NAME, +} from "@/shared/lib/accounts/constants"; import { revalidateForEntity } from "@/shared/lib/actions/helpers"; import { getUser } from "@/shared/lib/auth/server"; import { db } from "@/shared/lib/db"; @@ -14,6 +23,10 @@ import { PERIOD_FORMAT_REGEX, } from "@/shared/lib/invoices"; import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id"; +import { + formatCurrency, + formatDecimalForDbRequired, +} from "@/shared/utils/currency"; import { getBusinessTodayDate, parseLocalDateString, @@ -36,6 +49,11 @@ const updateInvoicePaymentStatusSchema = z.object({ .refine((value) => !value || isValidPaymentDate(value), { message: "Data de pagamento inválida.", }), + paymentAccountId: z + .string({ message: "Conta inválida." }) + .uuid("Conta inválida.") + .nullable() + .optional(), }); type UpdateInvoicePaymentStatusInput = z.infer< @@ -51,9 +69,6 @@ const successMessageByStatus: Record = { [INVOICE_PAYMENT_STATUS.PENDING]: "Pagamento da fatura foi revertido.", }; -const formatDecimal = (value: number) => - (Math.round(value * 100) / 100).toFixed(2); - export async function updateInvoicePaymentStatusAction( input: UpdateInvoicePaymentStatusInput, ): Promise { @@ -121,8 +136,25 @@ export async function updateInvoicePaymentStatusAction( const adminShare = Number(adminShareRow?.total ?? 0); const adminPayableAmount = Math.abs(Math.min(adminShare, 0)); + const paymentAccountId = data.paymentAccountId ?? card.accountId; + + if (adminPayerId) { + if (!paymentAccountId) { + throw new Error("Selecione uma conta para pagar a fatura."); + } + + const paymentAccount = await tx.query.financialAccounts.findFirst({ + columns: { id: true }, + where: and( + eq(financialAccounts.id, paymentAccountId), + eq(financialAccounts.userId, user.id), + ), + }); + + if (!paymentAccount) { + throw new Error("Conta de pagamento não encontrada."); + } - if (card.accountId && adminPayerId) { const paymentCategory = await tx.query.categories.findFirst({ columns: { id: true }, where: and( @@ -131,12 +163,11 @@ export async function updateInvoicePaymentStatusAction( ), }); - // Usar a data customizada ou a data atual como data de pagamento const invoiceDate = data.paymentDate ? parseLocalDateString(data.paymentDate) : getBusinessTodayDate(); - const amount = `-${formatDecimal(adminPayableAmount)}`; + const amount = `-${formatDecimalForDbRequired(adminPayableAmount)}`; const payload = { condition: "À vista", name: `Pagamento fatura - ${card.name}`, @@ -148,7 +179,7 @@ export async function updateInvoicePaymentStatusAction( period: data.period, isSettled: true, userId: user.id, - accountId: card.accountId, + accountId: paymentAccountId, categoryId: paymentCategory?.id ?? null, payerId: adminPayerId, }; @@ -270,3 +301,123 @@ export async function updatePaymentDateAction( }; } } + +const adjustInvoiceSchema = z.object({ + cardId: z.string({ message: "Cartão inválido." }).uuid("Cartão inválido."), + period: z + .string({ message: "Período inválido." }) + .regex(PERIOD_FORMAT_REGEX, "Período inválido."), + currentTotal: z.number({ message: "Total atual inválido." }), + targetAmount: z + .number({ message: "Valor inválido." }) + .nonnegative("O valor deve ser positivo."), +}); + +type AdjustInvoiceInput = z.infer; + +export async function adjustInvoiceAction( + input: AdjustInvoiceInput, +): Promise { + try { + const user = await getUser(); + const data = adjustInvoiceSchema.parse(input); + const adminPayerId = await getAdminPayerId(user.id); + + let message = "Ajuste de fatura registrado."; + + await db.transaction(async (tx: typeof db) => { + const card = await tx.query.cards.findFirst({ + columns: { id: true }, + where: and(eq(cards.id, data.cardId), eq(cards.userId, user.id)), + }); + + if (!card) { + throw new Error("Cartão não encontrado."); + } + + const existing = await tx.query.transactions.findFirst({ + columns: { id: true, amount: true }, + where: and( + eq(transactions.userId, user.id), + eq(transactions.cardId, data.cardId), + eq(transactions.period, data.period), + eq(transactions.name, INVOICE_ADJUSTMENT_NAME), + ), + }); + + const existingAmount = Number(existing?.amount ?? 0); + const baseTotal = data.currentTotal - existingAmount; + const targetTotal = -data.targetAmount; + const adjustmentAmount = + Math.round((targetTotal - baseTotal) * 100) / 100; + + if (adjustmentAmount === 0) { + if (existing) { + await tx.delete(transactions).where(eq(transactions.id, existing.id)); + message = "Ajuste de fatura removido."; + } else { + message = "Nada a ajustar — o valor já está correto."; + } + return; + } + + const isExpense = adjustmentAmount < 0; + const categoryName = isExpense ? "Outras despesas" : "Outras receitas"; + + const category = await tx.query.categories.findFirst({ + columns: { id: true }, + where: and( + eq(categories.userId, user.id), + eq(categories.name, categoryName), + ), + }); + + const amount = formatDecimalForDbRequired(adjustmentAmount); + + const note = `O valor era ${formatCurrency(Math.abs(baseTotal))} mas o correto é ${formatCurrency(data.targetAmount)}.`; + + const payload = { + condition: "À vista", + name: INVOICE_ADJUSTMENT_NAME, + paymentMethod: "Cartão de crédito", + note, + amount, + purchaseDate: getBusinessTodayDate(), + transactionType: isExpense + ? ("Despesa" as const) + : ("Receita" as const), + period: data.period, + userId: user.id, + cardId: data.cardId, + accountId: null, + categoryId: category?.id ?? null, + payerId: adminPayerId, + }; + + if (existing) { + await tx + .update(transactions) + .set(payload) + .where(eq(transactions.id, existing.id)); + } else { + await tx.insert(transactions).values(payload); + } + }); + + revalidateForEntity("cards", user.id); + + return { success: true, message }; + } catch (error) { + if (error instanceof z.ZodError) { + return { + success: false, + error: error.issues[0]?.message ?? "Dados inválidos.", + }; + } + + return { + success: false, + error: error instanceof Error ? error.message : "Erro inesperado.", + }; + } +} diff --git a/src/features/invoices/components/invoice-summary-card.tsx b/src/features/invoices/components/invoice-summary-card.tsx index 5c967b6..96f9266 100644 --- a/src/features/invoices/components/invoice-summary-card.tsx +++ b/src/features/invoices/components/invoice-summary-card.tsx @@ -1,6 +1,6 @@ "use client"; -import { RiEditLine } from "@remixicon/react"; +import { RiEditLine, RiEqualizerLine } from "@remixicon/react"; import Image from "next/image"; import { useRouter } from "next/navigation"; import type { ReactNode } from "react"; @@ -10,11 +10,30 @@ import { updateInvoicePaymentStatusAction, updatePaymentDateAction, } from "@/features/invoices/actions"; +import { AccountCardSelectContent } from "@/features/transactions/components/select-items"; import MoneyValues from "@/shared/components/money-values"; import StatusDot from "@/shared/components/status-dot"; import { Badge } from "@/shared/components/ui/badge"; import { Button } from "@/shared/components/ui/button"; import { Card, CardContent } from "@/shared/components/ui/card"; +import { DatePicker } from "@/shared/components/ui/date-picker"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/shared/components/ui/dialog"; +import { Label } from "@/shared/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/shared/components/ui/select"; import { resolveCardBrandAsset } from "@/shared/lib/cards/brand-assets"; import { INVOICE_PAYMENT_STATUS, @@ -27,8 +46,15 @@ import { resolveLogoSrc } from "@/shared/lib/logo"; import { formatCurrency } from "@/shared/utils/currency"; import { formatDateOnly } from "@/shared/utils/date"; import { cn } from "@/shared/utils/ui"; +import { AdjustInvoiceDialog } from "./adjust-invoice-dialog"; import { EditPaymentDateDialog } from "./edit-payment-date-dialog"; +type PaymentAccountOption = { + value: string; + label: string; + logo?: string | null; +}; + type InvoiceSummaryCardProps = { cardId: string; period: string; @@ -42,6 +68,8 @@ type InvoiceSummaryCardProps = { limitAmount: number | null; invoiceStatus: InvoicePaymentStatus; paymentDate: Date | null; + defaultPaymentAccountId: string | null; + paymentAccountOptions: PaymentAccountOption[]; logo?: string | null; actions?: React.ReactNode; }; @@ -87,6 +115,8 @@ export function InvoiceSummaryCard({ limitAmount, invoiceStatus, paymentDate: initialPaymentDate, + defaultPaymentAccountId, + paymentAccountOptions, logo, actions, }: InvoiceSummaryCardProps) { @@ -95,11 +125,21 @@ export function InvoiceSummaryCard({ const [paymentDate, setPaymentDate] = useState( initialPaymentDate ?? new Date(), ); + const [paymentAccountId, setPaymentAccountId] = useState( + defaultPaymentAccountId ?? paymentAccountOptions[0]?.value ?? "", + ); + const [paymentDialogOpen, setPaymentDialogOpen] = useState(false); useEffect(() => { setPaymentDate(initialPaymentDate ?? new Date()); }, [initialPaymentDate]); + useEffect(() => { + setPaymentAccountId( + defaultPaymentAccountId ?? paymentAccountOptions[0]?.value ?? "", + ); + }, [defaultPaymentAccountId, paymentAccountOptions]); + const logoPath = resolveLogoSrc(logo); const brandAsset = resolveCardBrandAsset(cardBrand); const isPaid = invoiceStatus === INVOICE_PAYMENT_STATUS.PAID; @@ -112,7 +152,7 @@ export function InvoiceSummaryCard({ ? INVOICE_PAYMENT_STATUS.PENDING : INVOICE_PAYMENT_STATUS.PAID; - const handleAction = () => { + const handleAction = (accountId?: string) => { startTransition(async () => { const result = await updateInvoicePaymentStatusAction({ cardId, @@ -122,10 +162,13 @@ export function InvoiceSummaryCard({ targetStatus === INVOICE_PAYMENT_STATUS.PAID ? paymentDate.toISOString().split("T")[0] : undefined, + paymentAccountId: + targetStatus === INVOICE_PAYMENT_STATUS.PAID ? accountId : undefined, }); if (result.success) { toast.success(result.message); + setPaymentDialogOpen(false); router.refresh(); return; } @@ -134,6 +177,15 @@ export function InvoiceSummaryCard({ }); }; + const handlePaymentConfirm = () => { + if (!paymentAccountId) { + toast.error("Selecione uma conta para pagar a fatura."); + return; + } + + handleAction(paymentAccountId); + }; + const handleDateChange = (newDate: Date) => { setPaymentDate(newDate); startTransition(async () => { @@ -190,13 +242,31 @@ export function InvoiceSummaryCard({ {/* Linha 2 — valor da fatura (hero) */}

Valor da fatura

- +
+ + + + + } + /> +
{typeof limitAmount === "number" ? ( - + {formatCurrency(limitAmount)} @@ -263,16 +333,45 @@ export function InvoiceSummaryCard({

- + {isPaid ? ( + + ) : ( + + {isPending + ? "Salvando..." + : actionLabelByStatus[invoiceStatus]} + + } + /> + )} {isPaid ? ( ); } + +type PayInvoiceDialogProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + isPending: boolean; + paymentDate: Date; + onPaymentDateChange: (date: Date) => void; + accountId: string; + onAccountChange: (accountId: string) => void; + accountOptions: PaymentAccountOption[]; + onConfirm: () => void; + trigger: ReactNode; +}; + +function PayInvoiceDialog({ + open, + onOpenChange, + isPending, + paymentDate, + onPaymentDateChange, + accountId, + onAccountChange, + accountOptions, + onConfirm, + trigger, +}: PayInvoiceDialogProps) { + const paymentDateValue = paymentDate.toISOString().split("T")[0] ?? ""; + const selectedAccount = accountOptions.find( + (option) => option.value === accountId, + ); + + return ( + + {trigger} + + + Confirmar pagamento + + Escolha a conta de origem e a data em que a fatura foi paga. + + + +
+
+ + +
+ +
+ + { + if (value) { + onPaymentDateChange(new Date(`${value}T00:00:00`)); + } + }} + disabled={isPending} + /> +
+
+ + + + + +
+
+ ); +} diff --git a/src/features/transactions/actions/single-actions.ts b/src/features/transactions/actions/single-actions.ts index abd0286..452e26d 100644 --- a/src/features/transactions/actions/single-actions.ts +++ b/src/features/transactions/actions/single-actions.ts @@ -42,6 +42,7 @@ import { type UpdateInput, updateSchema, validateAllOwnership, + validateCardLimit, } from "./core"; export async function createTransactionAction( @@ -132,6 +133,20 @@ export async function createTransactionAction( )} já estão pagas. Desfaça o pagamento antes de adicionar este lançamento.`, } as ActionResult<{ ids: string[] }>; } + + if (data.transactionType === "Despesa") { + const limitCheck = await validateCardLimit({ + userId: user.id, + cardId: data.cardId, + addAmount: Math.abs(data.amount), + }); + if (!limitCheck.ok) { + return { + success: false, + error: limitCheck.error, + } as ActionResult<{ ids: string[] }>; + } + } } const inserted = await db @@ -287,6 +302,22 @@ export async function updateTransactionAction( } } + if ( + data.paymentMethod === "Cartão de crédito" && + data.cardId && + data.transactionType === "Despesa" + ) { + const limitCheck = await validateCardLimit({ + userId: user.id, + cardId: data.cardId, + addAmount: Math.abs(data.amount), + excludeTransactionIds: [data.id], + }); + if (!limitCheck.ok) { + return { success: false, error: limitCheck.error }; + } + } + await db .update(transactions) .set({ @@ -582,7 +613,7 @@ export async function toggleTransactionSettlementAction( const data = toggleSettlementSchema.parse(input); const existing = await db.query.transactions.findFirst({ - columns: { id: true, paymentMethod: true }, + columns: { id: true, paymentMethod: true, accountId: true }, where: and( eq(transactions.id, data.id), eq(transactions.userId, user.id), @@ -601,18 +632,52 @@ export async function toggleTransactionSettlementAction( } const isBoleto = existing.paymentMethod === "Boleto"; + const customPaymentDate = + isBoleto && data.value && data.paymentDate + ? parseLocalDateString(data.paymentDate) + : null; const boletoPaymentDate = isBoleto ? data.value - ? getBusinessTodayDate() + ? (customPaymentDate ?? getBusinessTodayDate()) : null : null; + const shouldUpdateAccount = + isBoleto && data.value && data.paymentAccountId !== undefined; + + if (shouldUpdateAccount && data.paymentAccountId) { + const paymentAccount = await db.query.financialAccounts.findFirst({ + columns: { id: true }, + where: and( + eq(financialAccounts.id, data.paymentAccountId), + eq(financialAccounts.userId, user.id), + ), + }); + + if (!paymentAccount) { + return { + success: false, + error: "Conta de pagamento não encontrada.", + }; + } + } + + const updatePayload: { + isSettled: boolean; + boletoPaymentDate: Date | null; + accountId?: string | null; + } = { + isSettled: data.value, + boletoPaymentDate, + }; + + if (shouldUpdateAccount) { + updatePayload.accountId = data.paymentAccountId ?? null; + } + await db .update(transactions) - .set({ - isSettled: data.value, - boletoPaymentDate, - }) + .set(updatePayload) .where( and(eq(transactions.id, data.id), eq(transactions.userId, user.id)), ); diff --git a/src/features/transactions/components/dialogs/transaction-details-dialog.tsx b/src/features/transactions/components/dialogs/transaction-details-dialog.tsx index 8170f6f..2a755d2 100644 --- a/src/features/transactions/components/dialogs/transaction-details-dialog.tsx +++ b/src/features/transactions/components/dialogs/transaction-details-dialog.tsx @@ -64,6 +64,9 @@ export function TransactionDetailsDialog({ : 0; const isBoleto = transaction.paymentMethod === "Boleto"; + const shortTransactionId = `…${ + transaction.id.split("-").at(-1) ?? transaction.id + }`; const handleEdit = () => { onOpenChange(false); @@ -120,6 +123,12 @@ export function TransactionDetailsDialog({ Detalhes
    + + {label} - {value} + + {value} + ); } diff --git a/src/shared/lib/accounts/constants.ts b/src/shared/lib/accounts/constants.ts index eb209f8..923a135 100644 --- a/src/shared/lib/accounts/constants.ts +++ b/src/shared/lib/accounts/constants.ts @@ -19,3 +19,15 @@ export const ACCOUNT_AUTO_INVOICE_NOTE_PREFIX = "AUTO_FATURA:"; export const buildInvoicePaymentNote = (cardId: string, period: string) => `${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}${cardId}:${period}`; + +export const INVOICE_ADJUSTMENT_NAME = "Ajuste de fatura"; + +export const ACCOUNT_BALANCE_ADJUSTMENT_NAME = "Ajuste de saldo"; + +export const REFUND_NOTE_PREFIX = "AUTO_REEMBOLSO:"; + +export const buildRefundNote = (originalTransactionId: string) => + `${REFUND_NOTE_PREFIX}${originalTransactionId}`; + +export const isRefundNote = (note: string | null | undefined) => + note?.startsWith(REFUND_NOTE_PREFIX) ?? false;