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

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