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