mirror of
https://github.com/felipegcoutinho/openmonetis.git
synced 2026-05-09 02:51:46 +00:00
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:
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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)),
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user