feat(faturas/extrato): ajuste de fatura, reembolso e ajuste de saldo da conta

- botão "Ajustar fatura" na página da fatura abre dialog com input do valor real
  e preview da diferença; action faz upsert/delete idempotente do lançamento de ajuste
- opção "Reembolso" no dropdown de ações de despesas à vista cria receita espelhada
  no extrato ou fatura correta, vinculada ao lançamento original
- botão "Ajustar saldo" no extrato da conta compara saldo real informado e gera
  lançamento de ajuste por (accountId, period) via upsert/delete idempotente
- constantes INVOICE_ADJUSTMENT_NAME, ACCOUNT_BALANCE_ADJUSTMENT_NAME,
  REFUND_NOTE_PREFIX e buildRefundNote() centralizadas em shared/lib/accounts/constants.ts
- extrato agora contabiliza transferências internas em Entradas e Saídas corretamente

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Felipe Coutinho
2026-05-02 22:08:07 +00:00
parent 8389752172
commit 4bea6330bf
9 changed files with 638 additions and 47 deletions

View File

@@ -3,6 +3,7 @@ import { notFound } from "next/navigation";
import { connection } from "next/server"; import { connection } from "next/server";
import { AccountDialog } from "@/features/accounts/components/account-dialog"; import { AccountDialog } from "@/features/accounts/components/account-dialog";
import { AccountStatementCard } from "@/features/accounts/components/account-statement-card"; 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 type { Account } from "@/features/accounts/components/types";
import { import {
fetchAccountData, fetchAccountData,
@@ -141,6 +142,13 @@ export default async function Page({ params, searchParams }: PageProps) {
totalIncomes={totalIncomes} totalIncomes={totalIncomes}
totalExpenses={totalExpenses} totalExpenses={totalExpenses}
logo={account.logo} logo={account.logo}
balanceAdjustment={
<AdjustBalanceDialog
accountId={account.id}
period={selectedPeriod}
currentBalance={currentBalance}
/>
}
actions={ actions={
<AccountDialog <AccountDialog
mode="update" mode="update"

View File

@@ -4,6 +4,7 @@ import { and, eq } from "drizzle-orm";
import { z } from "zod"; import { z } from "zod";
import { categories, financialAccounts, transactions } from "@/db/schema"; import { categories, financialAccounts, transactions } from "@/db/schema";
import { import {
ACCOUNT_BALANCE_ADJUSTMENT_NAME,
INITIAL_BALANCE_CATEGORY_NAME, INITIAL_BALANCE_CATEGORY_NAME,
INITIAL_BALANCE_CONDITION, INITIAL_BALANCE_CONDITION,
INITIAL_BALANCE_NOTE, INITIAL_BALANCE_NOTE,
@@ -17,6 +18,7 @@ import {
} from "@/shared/lib/actions/helpers"; } from "@/shared/lib/actions/helpers";
import { getUser } from "@/shared/lib/auth/server"; import { getUser } from "@/shared/lib/auth/server";
import { db } from "@/shared/lib/db"; import { db } from "@/shared/lib/db";
import { PERIOD_FORMAT_REGEX } from "@/shared/lib/invoices";
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id"; import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
import { noteSchema, uuidSchema } from "@/shared/lib/schemas/common"; import { noteSchema, uuidSchema } from "@/shared/lib/schemas/common";
import { import {
@@ -26,8 +28,11 @@ import {
TRANSFER_ESTABLISHMENT_SAIDA, TRANSFER_ESTABLISHMENT_SAIDA,
TRANSFER_PAYMENT_METHOD, TRANSFER_PAYMENT_METHOD,
} from "@/shared/lib/transfers/constants"; } from "@/shared/lib/transfers/constants";
import { formatDecimalForDbRequired } from "@/shared/utils/currency"; import {
import { getTodayInfo } from "@/shared/utils/date"; formatCurrency,
formatDecimalForDbRequired,
} from "@/shared/utils/currency";
import { getBusinessTodayDate, getTodayInfo } from "@/shared/utils/date";
import { normalizeFilePath } from "@/shared/utils/string"; import { normalizeFilePath } from "@/shared/utils/string";
const accountBaseSchema = z.object({ const accountBaseSchema = z.object({
@@ -99,7 +104,7 @@ export async function createAccountAction(
if (hasInitialBalance && !adminPayerId) { if (hasInitialBalance && !adminPayerId) {
throw new Error( throw new Error(
"Pessoa com papel administrador não encontrado. Crie um pessoa admin antes de definir um saldo inicial.", "Pessoa com papel administrador não encontrada. Crie uma pessoa admin antes de definir um saldo inicial.",
); );
} }
@@ -299,7 +304,7 @@ export async function transferBetweenAccountsAction(
if (!adminPayerId) { if (!adminPayerId) {
throw new Error( throw new Error(
"Pessoa administrador não encontrado. Por favor, crie um pessoa admin.", "Pessoa administrador não encontrada. Por favor, crie uma pessoa admin.",
); );
} }
@@ -391,3 +396,120 @@ export async function transferBetweenAccountsAction(
return handleActionError(error); return handleActionError(error);
} }
} }
const adjustAccountBalanceSchema = z.object({
accountId: uuidSchema("FinancialAccount"),
period: z
.string({ message: "Período inválido." })
.regex(PERIOD_FORMAT_REGEX, "Período inválido."),
currentBalance: z.number({ message: "Saldo atual inválido." }),
targetBalance: z.number({ message: "Saldo correto inválido." }),
});
type AdjustAccountBalanceInput = z.infer<typeof adjustAccountBalanceSchema>;
export async function adjustAccountBalanceAction(
input: AdjustAccountBalanceInput,
): Promise<ActionResult> {
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);
}
}

View File

@@ -26,6 +26,7 @@ type AccountStatementCardProps = {
totalExpenses: number; totalExpenses: number;
logo?: string | null; logo?: string | null;
actions?: React.ReactNode; actions?: React.ReactNode;
balanceAdjustment?: React.ReactNode;
}; };
const getAccountStatusBadgeVariant = ( const getAccountStatusBadgeVariant = (
@@ -45,6 +46,7 @@ export function AccountStatementCard({
totalExpenses, totalExpenses,
logo, logo,
actions, actions,
balanceAdjustment,
}: AccountStatementCardProps) { }: AccountStatementCardProps) {
const logoPath = resolveLogoSrc(logo); const logoPath = resolveLogoSrc(logo);
const resultado = totalIncomes - totalExpenses; const resultado = totalIncomes - totalExpenses;
@@ -84,10 +86,13 @@ export function AccountStatementCard({
<p className="text-sm text-muted-foreground "> <p className="text-sm text-muted-foreground ">
Saldo ao final do período Saldo ao final do período
</p> </p>
<div className="flex items-center gap-2">
<MoneyValues <MoneyValues
amount={currentBalance} amount={currentBalance}
className="text-3xl leading-none tracking-tighter sm:text-2xl" className="text-3xl leading-none tracking-tighter sm:text-2xl"
/> />
{balanceAdjustment}
</div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Badge <Badge
variant={getAccountStatusBadgeVariant(status)} variant={getAccountStatusBadgeVariant(status)}

View File

@@ -4,7 +4,10 @@ import {
fetchTransactionsPageWithRelations, fetchTransactionsPageWithRelations,
fetchTransactionsWithRelations, fetchTransactionsWithRelations,
} from "@/features/transactions/queries"; } from "@/features/transactions/queries";
import { INITIAL_BALANCE_NOTE } from "@/shared/lib/accounts/constants"; import {
INITIAL_BALANCE_NOTE,
REFUND_NOTE_PREFIX,
} from "@/shared/lib/accounts/constants";
import { db } from "@/shared/lib/db"; import { db } from "@/shared/lib/db";
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id"; import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
@@ -74,7 +77,9 @@ export async function fetchAccountSummary(
sum( sum(
case case
when ${transactions.note} = ${INITIAL_BALANCE_NOTE} then 0 when ${transactions.note} = ${INITIAL_BALANCE_NOTE} then 0
when ${transactions.note} ilike ${`${REFUND_NOTE_PREFIX}%`} then 0
when ${transactions.transactionType} = 'Receita' then ${transactions.amount} when ${transactions.transactionType} = 'Receita' then ${transactions.amount}
when ${transactions.transactionType} = 'Transferência' and ${transactions.amount} > 0 then ${transactions.amount}
else 0 else 0
end end
), ),
@@ -86,7 +91,9 @@ export async function fetchAccountSummary(
sum( sum(
case case
when ${transactions.note} = ${INITIAL_BALANCE_NOTE} then 0 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} = 'Despesa' then ${transactions.amount}
when ${transactions.transactionType} = 'Transferência' and ${transactions.amount} < 0 then ${transactions.amount}
else 0 else 0
end end
), ),
@@ -135,7 +142,8 @@ export async function fetchAccountSummary(
const openingBalance = initialBalance + previousMovements; const openingBalance = initialBalance + previousMovements;
const netAmount = Number(periodSummary?.netAmount ?? 0); const netAmount = Number(periodSummary?.netAmount ?? 0);
const totalIncomes = Number(periodSummary?.incomes ?? 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; const currentBalance = openingBalance + netAmount;
return { return {

View File

@@ -2,8 +2,17 @@
import { and, eq, sql } from "drizzle-orm"; import { and, eq, sql } from "drizzle-orm";
import { z } from "zod"; import { z } from "zod";
import { cards, categories, invoices, transactions } from "@/db/schema"; import {
import { buildInvoicePaymentNote } from "@/shared/lib/accounts/constants"; 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 { revalidateForEntity } from "@/shared/lib/actions/helpers";
import { getUser } from "@/shared/lib/auth/server"; import { getUser } from "@/shared/lib/auth/server";
import { db } from "@/shared/lib/db"; import { db } from "@/shared/lib/db";
@@ -14,6 +23,10 @@ import {
PERIOD_FORMAT_REGEX, PERIOD_FORMAT_REGEX,
} from "@/shared/lib/invoices"; } from "@/shared/lib/invoices";
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id"; import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
import {
formatCurrency,
formatDecimalForDbRequired,
} from "@/shared/utils/currency";
import { import {
getBusinessTodayDate, getBusinessTodayDate,
parseLocalDateString, parseLocalDateString,
@@ -36,6 +49,11 @@ const updateInvoicePaymentStatusSchema = z.object({
.refine((value) => !value || isValidPaymentDate(value), { .refine((value) => !value || isValidPaymentDate(value), {
message: "Data de pagamento inválida.", message: "Data de pagamento inválida.",
}), }),
paymentAccountId: z
.string({ message: "Conta inválida." })
.uuid("Conta inválida.")
.nullable()
.optional(),
}); });
type UpdateInvoicePaymentStatusInput = z.infer< type UpdateInvoicePaymentStatusInput = z.infer<
@@ -51,9 +69,6 @@ const successMessageByStatus: Record<InvoicePaymentStatus, string> = {
[INVOICE_PAYMENT_STATUS.PENDING]: "Pagamento da fatura foi revertido.", [INVOICE_PAYMENT_STATUS.PENDING]: "Pagamento da fatura foi revertido.",
}; };
const formatDecimal = (value: number) =>
(Math.round(value * 100) / 100).toFixed(2);
export async function updateInvoicePaymentStatusAction( export async function updateInvoicePaymentStatusAction(
input: UpdateInvoicePaymentStatusInput, input: UpdateInvoicePaymentStatusInput,
): Promise<ActionResult> { ): Promise<ActionResult> {
@@ -121,8 +136,25 @@ export async function updateInvoicePaymentStatusAction(
const adminShare = Number(adminShareRow?.total ?? 0); const adminShare = Number(adminShareRow?.total ?? 0);
const adminPayableAmount = Math.abs(Math.min(adminShare, 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({ const paymentCategory = await tx.query.categories.findFirst({
columns: { id: true }, columns: { id: true },
where: and( 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 const invoiceDate = data.paymentDate
? parseLocalDateString(data.paymentDate) ? parseLocalDateString(data.paymentDate)
: getBusinessTodayDate(); : getBusinessTodayDate();
const amount = `-${formatDecimal(adminPayableAmount)}`; const amount = `-${formatDecimalForDbRequired(adminPayableAmount)}`;
const payload = { const payload = {
condition: "À vista", condition: "À vista",
name: `Pagamento fatura - ${card.name}`, name: `Pagamento fatura - ${card.name}`,
@@ -148,7 +179,7 @@ export async function updateInvoicePaymentStatusAction(
period: data.period, period: data.period,
isSettled: true, isSettled: true,
userId: user.id, userId: user.id,
accountId: card.accountId, accountId: paymentAccountId,
categoryId: paymentCategory?.id ?? null, categoryId: paymentCategory?.id ?? null,
payerId: adminPayerId, 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<typeof adjustInvoiceSchema>;
export async function adjustInvoiceAction(
input: AdjustInvoiceInput,
): Promise<ActionResult> {
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.",
};
}
}

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import { RiEditLine } from "@remixicon/react"; import { RiEditLine, RiEqualizerLine } from "@remixicon/react";
import Image from "next/image"; import Image from "next/image";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
@@ -10,11 +10,30 @@ import {
updateInvoicePaymentStatusAction, updateInvoicePaymentStatusAction,
updatePaymentDateAction, updatePaymentDateAction,
} from "@/features/invoices/actions"; } from "@/features/invoices/actions";
import { AccountCardSelectContent } from "@/features/transactions/components/select-items";
import MoneyValues from "@/shared/components/money-values"; import MoneyValues from "@/shared/components/money-values";
import StatusDot from "@/shared/components/status-dot"; import StatusDot from "@/shared/components/status-dot";
import { Badge } from "@/shared/components/ui/badge"; import { Badge } from "@/shared/components/ui/badge";
import { Button } from "@/shared/components/ui/button"; import { Button } from "@/shared/components/ui/button";
import { Card, CardContent } from "@/shared/components/ui/card"; 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 { resolveCardBrandAsset } from "@/shared/lib/cards/brand-assets";
import { import {
INVOICE_PAYMENT_STATUS, INVOICE_PAYMENT_STATUS,
@@ -27,8 +46,15 @@ import { resolveLogoSrc } from "@/shared/lib/logo";
import { formatCurrency } from "@/shared/utils/currency"; import { formatCurrency } from "@/shared/utils/currency";
import { formatDateOnly } from "@/shared/utils/date"; import { formatDateOnly } from "@/shared/utils/date";
import { cn } from "@/shared/utils/ui"; import { cn } from "@/shared/utils/ui";
import { AdjustInvoiceDialog } from "./adjust-invoice-dialog";
import { EditPaymentDateDialog } from "./edit-payment-date-dialog"; import { EditPaymentDateDialog } from "./edit-payment-date-dialog";
type PaymentAccountOption = {
value: string;
label: string;
logo?: string | null;
};
type InvoiceSummaryCardProps = { type InvoiceSummaryCardProps = {
cardId: string; cardId: string;
period: string; period: string;
@@ -42,6 +68,8 @@ type InvoiceSummaryCardProps = {
limitAmount: number | null; limitAmount: number | null;
invoiceStatus: InvoicePaymentStatus; invoiceStatus: InvoicePaymentStatus;
paymentDate: Date | null; paymentDate: Date | null;
defaultPaymentAccountId: string | null;
paymentAccountOptions: PaymentAccountOption[];
logo?: string | null; logo?: string | null;
actions?: React.ReactNode; actions?: React.ReactNode;
}; };
@@ -87,6 +115,8 @@ export function InvoiceSummaryCard({
limitAmount, limitAmount,
invoiceStatus, invoiceStatus,
paymentDate: initialPaymentDate, paymentDate: initialPaymentDate,
defaultPaymentAccountId,
paymentAccountOptions,
logo, logo,
actions, actions,
}: InvoiceSummaryCardProps) { }: InvoiceSummaryCardProps) {
@@ -95,11 +125,21 @@ export function InvoiceSummaryCard({
const [paymentDate, setPaymentDate] = useState<Date>( const [paymentDate, setPaymentDate] = useState<Date>(
initialPaymentDate ?? new Date(), initialPaymentDate ?? new Date(),
); );
const [paymentAccountId, setPaymentAccountId] = useState<string>(
defaultPaymentAccountId ?? paymentAccountOptions[0]?.value ?? "",
);
const [paymentDialogOpen, setPaymentDialogOpen] = useState(false);
useEffect(() => { useEffect(() => {
setPaymentDate(initialPaymentDate ?? new Date()); setPaymentDate(initialPaymentDate ?? new Date());
}, [initialPaymentDate]); }, [initialPaymentDate]);
useEffect(() => {
setPaymentAccountId(
defaultPaymentAccountId ?? paymentAccountOptions[0]?.value ?? "",
);
}, [defaultPaymentAccountId, paymentAccountOptions]);
const logoPath = resolveLogoSrc(logo); const logoPath = resolveLogoSrc(logo);
const brandAsset = resolveCardBrandAsset(cardBrand); const brandAsset = resolveCardBrandAsset(cardBrand);
const isPaid = invoiceStatus === INVOICE_PAYMENT_STATUS.PAID; const isPaid = invoiceStatus === INVOICE_PAYMENT_STATUS.PAID;
@@ -112,7 +152,7 @@ export function InvoiceSummaryCard({
? INVOICE_PAYMENT_STATUS.PENDING ? INVOICE_PAYMENT_STATUS.PENDING
: INVOICE_PAYMENT_STATUS.PAID; : INVOICE_PAYMENT_STATUS.PAID;
const handleAction = () => { const handleAction = (accountId?: string) => {
startTransition(async () => { startTransition(async () => {
const result = await updateInvoicePaymentStatusAction({ const result = await updateInvoicePaymentStatusAction({
cardId, cardId,
@@ -122,10 +162,13 @@ export function InvoiceSummaryCard({
targetStatus === INVOICE_PAYMENT_STATUS.PAID targetStatus === INVOICE_PAYMENT_STATUS.PAID
? paymentDate.toISOString().split("T")[0] ? paymentDate.toISOString().split("T")[0]
: undefined, : undefined,
paymentAccountId:
targetStatus === INVOICE_PAYMENT_STATUS.PAID ? accountId : undefined,
}); });
if (result.success) { if (result.success) {
toast.success(result.message); toast.success(result.message);
setPaymentDialogOpen(false);
router.refresh(); router.refresh();
return; 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) => { const handleDateChange = (newDate: Date) => {
setPaymentDate(newDate); setPaymentDate(newDate);
startTransition(async () => { startTransition(async () => {
@@ -190,6 +242,7 @@ export function InvoiceSummaryCard({
{/* Linha 2 — valor da fatura (hero) */} {/* Linha 2 — valor da fatura (hero) */}
<div className="space-y-4"> <div className="space-y-4">
<p className="text-sm text-muted-foreground">Valor da fatura</p> <p className="text-sm text-muted-foreground">Valor da fatura</p>
<div className="flex items-center gap-2">
<MoneyValues <MoneyValues
amount={Math.abs(totalAmount)} amount={Math.abs(totalAmount)}
className={cn( className={cn(
@@ -197,6 +250,23 @@ export function InvoiceSummaryCard({
isPaid ? "text-success" : "text-foreground", isPaid ? "text-success" : "text-foreground",
)} )}
/> />
<AdjustInvoiceDialog
cardId={cardId}
period={period}
currentTotal={totalAmount}
trigger={
<Button
type="button"
variant="ghost"
size="icon-sm"
className="text-muted-foreground hover:text-foreground"
aria-label="Ajustar fatura"
>
<RiEqualizerLine className="size-4" />
</Button>
}
/>
</div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Badge <Badge
variant={INVOICE_STATUS_BADGE_VARIANT[invoiceStatus]} variant={INVOICE_STATUS_BADGE_VARIANT[invoiceStatus]}
@@ -228,7 +298,7 @@ export function InvoiceSummaryCard({
</MetaItem> </MetaItem>
{typeof limitAmount === "number" ? ( {typeof limitAmount === "number" ? (
<MetaItem label="Limite"> <MetaItem label="Limite total">
<span className="text-sm font-medium text-foreground"> <span className="text-sm font-medium text-foreground">
{formatCurrency(limitAmount)} {formatCurrency(limitAmount)}
</span> </span>
@@ -263,16 +333,45 @@ export function InvoiceSummaryCard({
</p> </p>
</div> </div>
<div className="flex shrink-0 items-center gap-1.5"> <div className="flex shrink-0 items-center gap-1.5">
{isPaid ? (
<Button <Button
type="button" type="button"
size="sm" size="sm"
variant={actionVariantByStatus[invoiceStatus]} variant={actionVariantByStatus[invoiceStatus]}
disabled={isPending} disabled={isPending}
onClick={handleAction} onClick={() => handleAction()}
className="min-w-32" className="min-w-32"
> >
{isPending ? "Salvando..." : actionLabelByStatus[invoiceStatus]} {isPending
? "Salvando..."
: actionLabelByStatus[invoiceStatus]}
</Button> </Button>
) : (
<PayInvoiceDialog
open={paymentDialogOpen}
onOpenChange={setPaymentDialogOpen}
isPending={isPending}
paymentDate={paymentDate}
onPaymentDateChange={setPaymentDate}
accountId={paymentAccountId}
onAccountChange={setPaymentAccountId}
accountOptions={paymentAccountOptions}
onConfirm={handlePaymentConfirm}
trigger={
<Button
type="button"
size="sm"
variant={actionVariantByStatus[invoiceStatus]}
disabled={isPending}
className="min-w-32"
>
{isPending
? "Salvando..."
: actionLabelByStatus[invoiceStatus]}
</Button>
}
/>
)}
{isPaid ? ( {isPaid ? (
<EditPaymentDateDialog <EditPaymentDateDialog
trigger={ trigger={
@@ -308,3 +407,112 @@ function MetaItem({ label, children }: { label: string; children: ReactNode }) {
</div> </div>
); );
} }
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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogTrigger asChild>{trigger}</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Confirmar pagamento</DialogTitle>
<DialogDescription>
Escolha a conta de origem e a data em que a fatura foi paga.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="invoice-payment-account">Conta de pagamento</Label>
<Select
value={accountId}
onValueChange={onAccountChange}
disabled={isPending || accountOptions.length === 0}
>
<SelectTrigger id="invoice-payment-account" className="w-full">
<SelectValue placeholder="Selecione uma conta">
{selectedAccount ? (
<AccountCardSelectContent
label={selectedAccount.label}
logo={selectedAccount.logo}
/>
) : null}
</SelectValue>
</SelectTrigger>
<SelectContent>
{accountOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
<AccountCardSelectContent
label={option.label}
logo={option.logo}
/>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="invoice-payment-date">Data do pagamento</Label>
<DatePicker
id="invoice-payment-date"
value={paymentDateValue}
onChange={(value) => {
if (value) {
onPaymentDateChange(new Date(`${value}T00:00:00`));
}
}}
disabled={isPending}
/>
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isPending}
>
Cancelar
</Button>
<Button
type="button"
onClick={onConfirm}
disabled={isPending || accountOptions.length === 0}
>
{isPending ? "Confirmando..." : "Confirmar pagamento"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -42,6 +42,7 @@ import {
type UpdateInput, type UpdateInput,
updateSchema, updateSchema,
validateAllOwnership, validateAllOwnership,
validateCardLimit,
} from "./core"; } from "./core";
export async function createTransactionAction( 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.`, )} já estão pagas. Desfaça o pagamento antes de adicionar este lançamento.`,
} as ActionResult<{ ids: string[] }>; } 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 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 await db
.update(transactions) .update(transactions)
.set({ .set({
@@ -582,7 +613,7 @@ export async function toggleTransactionSettlementAction(
const data = toggleSettlementSchema.parse(input); const data = toggleSettlementSchema.parse(input);
const existing = await db.query.transactions.findFirst({ const existing = await db.query.transactions.findFirst({
columns: { id: true, paymentMethod: true }, columns: { id: true, paymentMethod: true, accountId: true },
where: and( where: and(
eq(transactions.id, data.id), eq(transactions.id, data.id),
eq(transactions.userId, user.id), eq(transactions.userId, user.id),
@@ -601,18 +632,52 @@ export async function toggleTransactionSettlementAction(
} }
const isBoleto = existing.paymentMethod === "Boleto"; const isBoleto = existing.paymentMethod === "Boleto";
const customPaymentDate =
isBoleto && data.value && data.paymentDate
? parseLocalDateString(data.paymentDate)
: null;
const boletoPaymentDate = isBoleto const boletoPaymentDate = isBoleto
? data.value ? data.value
? getBusinessTodayDate() ? (customPaymentDate ?? getBusinessTodayDate())
: null : null
: null; : null;
await db const shouldUpdateAccount =
.update(transactions) isBoleto && data.value && data.paymentAccountId !== undefined;
.set({
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, isSettled: data.value,
boletoPaymentDate, boletoPaymentDate,
}) };
if (shouldUpdateAccount) {
updatePayload.accountId = data.paymentAccountId ?? null;
}
await db
.update(transactions)
.set(updatePayload)
.where( .where(
and(eq(transactions.id, data.id), eq(transactions.userId, user.id)), and(eq(transactions.id, data.id), eq(transactions.userId, user.id)),
); );

View File

@@ -64,6 +64,9 @@ export function TransactionDetailsDialog({
: 0; : 0;
const isBoleto = transaction.paymentMethod === "Boleto"; const isBoleto = transaction.paymentMethod === "Boleto";
const shortTransactionId = `${
transaction.id.split("-").at(-1) ?? transaction.id
}`;
const handleEdit = () => { const handleEdit = () => {
onOpenChange(false); onOpenChange(false);
@@ -120,6 +123,12 @@ export function TransactionDetailsDialog({
Detalhes Detalhes
</h3> </h3>
<ul className="min-w-0 grid gap-2 rounded-lg border p-3"> <ul className="min-w-0 grid gap-2 rounded-lg border p-3">
<DetailRow
label="ID"
value={shortTransactionId}
valueClassName="font-mono"
/>
<DetailRow <DetailRow
label="Período" label="Período"
value={formatPeriod(transaction.period)} value={formatPeriod(transaction.period)}
@@ -253,13 +262,16 @@ export function TransactionDetailsDialog({
interface DetailRowProps { interface DetailRowProps {
label: string; label: string;
value: string; value: string;
valueClassName?: string;
} }
function DetailRow({ label, value }: DetailRowProps) { function DetailRow({ label, value, valueClassName }: DetailRowProps) {
return ( return (
<li className="min-w-0 flex items-center justify-between gap-3"> <li className="min-w-0 flex items-center justify-between gap-3">
<span className="text-muted-foreground">{label}</span> <span className="text-muted-foreground">{label}</span>
<span className="min-w-0 truncate">{value}</span> <span className={`min-w-0 truncate ${valueClassName ?? ""}`}>
{value}
</span>
</li> </li>
); );
} }

View File

@@ -19,3 +19,15 @@ export const ACCOUNT_AUTO_INVOICE_NOTE_PREFIX = "AUTO_FATURA:";
export const buildInvoicePaymentNote = (cardId: string, period: string) => export const buildInvoicePaymentNote = (cardId: string, period: string) =>
`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}${cardId}:${period}`; `${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;