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

@@ -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<InvoicePaymentStatus, string> = {
[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<ActionResult> {
@@ -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<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";
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<Date>(
initialPaymentDate ?? new Date(),
);
const [paymentAccountId, setPaymentAccountId] = useState<string>(
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) */}
<div className="space-y-4">
<p className="text-sm text-muted-foreground">Valor da fatura</p>
<MoneyValues
amount={Math.abs(totalAmount)}
className={cn(
"text-3xl tracking-tighter font-semibold",
isPaid ? "text-success" : "text-foreground",
)}
/>
<div className="flex items-center gap-2">
<MoneyValues
amount={Math.abs(totalAmount)}
className={cn(
"text-3xl tracking-tighter font-semibold",
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">
<Badge
variant={INVOICE_STATUS_BADGE_VARIANT[invoiceStatus]}
@@ -228,7 +298,7 @@ export function InvoiceSummaryCard({
</MetaItem>
{typeof limitAmount === "number" ? (
<MetaItem label="Limite">
<MetaItem label="Limite total">
<span className="text-sm font-medium text-foreground">
{formatCurrency(limitAmount)}
</span>
@@ -263,16 +333,45 @@ export function InvoiceSummaryCard({
</p>
</div>
<div className="flex shrink-0 items-center gap-1.5">
<Button
type="button"
size="sm"
variant={actionVariantByStatus[invoiceStatus]}
disabled={isPending}
onClick={handleAction}
className="min-w-32"
>
{isPending ? "Salvando..." : actionLabelByStatus[invoiceStatus]}
</Button>
{isPaid ? (
<Button
type="button"
size="sm"
variant={actionVariantByStatus[invoiceStatus]}
disabled={isPending}
onClick={() => handleAction()}
className="min-w-32"
>
{isPending
? "Salvando..."
: actionLabelByStatus[invoiceStatus]}
</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 ? (
<EditPaymentDateDialog
trigger={
@@ -308,3 +407,112 @@ function MetaItem({ label, children }: { label: string; children: ReactNode }) {
</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>
);
}