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 { 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={
<AdjustBalanceDialog
accountId={account.id}
period={selectedPeriod}
currentBalance={currentBalance}
/>
}
actions={
<AccountDialog
mode="update"

View File

@@ -4,6 +4,7 @@ import { and, eq } from "drizzle-orm";
import { z } from "zod";
import { categories, financialAccounts, transactions } from "@/db/schema";
import {
ACCOUNT_BALANCE_ADJUSTMENT_NAME,
INITIAL_BALANCE_CATEGORY_NAME,
INITIAL_BALANCE_CONDITION,
INITIAL_BALANCE_NOTE,
@@ -17,6 +18,7 @@ import {
} from "@/shared/lib/actions/helpers";
import { getUser } from "@/shared/lib/auth/server";
import { db } from "@/shared/lib/db";
import { PERIOD_FORMAT_REGEX } from "@/shared/lib/invoices";
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
import { noteSchema, uuidSchema } from "@/shared/lib/schemas/common";
import {
@@ -26,8 +28,11 @@ import {
TRANSFER_ESTABLISHMENT_SAIDA,
TRANSFER_PAYMENT_METHOD,
} from "@/shared/lib/transfers/constants";
import { formatDecimalForDbRequired } from "@/shared/utils/currency";
import { getTodayInfo } from "@/shared/utils/date";
import {
formatCurrency,
formatDecimalForDbRequired,
} from "@/shared/utils/currency";
import { getBusinessTodayDate, getTodayInfo } from "@/shared/utils/date";
import { normalizeFilePath } from "@/shared/utils/string";
const accountBaseSchema = z.object({
@@ -99,7 +104,7 @@ export async function createAccountAction(
if (hasInitialBalance && !adminPayerId) {
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) {
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);
}
}
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;
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({
<p className="text-sm text-muted-foreground ">
Saldo ao final do período
</p>
<MoneyValues
amount={currentBalance}
className="text-3xl leading-none tracking-tighter sm:text-2xl"
/>
<div className="flex items-center gap-2">
<MoneyValues
amount={currentBalance}
className="text-3xl leading-none tracking-tighter sm:text-2xl"
/>
{balanceAdjustment}
</div>
<div className="flex items-center gap-2">
<Badge
variant={getAccountStatusBadgeVariant(status)}

View File

@@ -4,7 +4,10 @@ import {
fetchTransactionsPageWithRelations,
fetchTransactionsWithRelations,
} 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 { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
@@ -74,7 +77,9 @@ export async function fetchAccountSummary(
sum(
case
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} = 'Transferência' and ${transactions.amount} > 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 {

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>
);
}

View File

@@ -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)),
);

View File

@@ -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
</h3>
<ul className="min-w-0 grid gap-2 rounded-lg border p-3">
<DetailRow
label="ID"
value={shortTransactionId}
valueClassName="font-mono"
/>
<DetailRow
label="Período"
value={formatPeriod(transaction.period)}
@@ -253,13 +262,16 @@ export function TransactionDetailsDialog({
interface DetailRowProps {
label: string;
value: string;
valueClassName?: string;
}
function DetailRow({ label, value }: DetailRowProps) {
function DetailRow({ label, value, valueClassName }: DetailRowProps) {
return (
<li className="min-w-0 flex items-center justify-between gap-3">
<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>
);
}

View File

@@ -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;