feat(cartoes): exigir limite e bloquear lançamentos acima do disponível

- campo limite passa a ser NOT NULL DEFAULT 0 no schema (migration 0029)
- validação Zod com requiredDecimalSchema garante valor positivo no formulário
- validateCardLimit() em transactions/actions/core.ts bloqueia criação e edição
  de despesas em cartão que ultrapassem o limite disponível, retornando mensagem
  com o valor exato restante
- tipos Card.limit e Card.limitAvailable deixam de ser nullable
- branch "sem limite registrado" removido de card-item.tsx

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Felipe Coutinho
2026-05-02 22:07:56 +00:00
parent 19b5aa00ee
commit 8389752172
11 changed files with 400 additions and 290 deletions

View File

@@ -1,5 +1,5 @@
import { randomUUID } from "node:crypto";
import { and, eq, inArray } from "drizzle-orm";
import { and, eq, inArray, isNull, ne, not, or, sql } from "drizzle-orm";
import { z } from "zod";
import {
cards,
@@ -7,7 +7,7 @@ import {
financialAccounts,
invoices,
payers,
type transactions,
transactions,
} from "@/db/schema";
import {
PAYMENT_METHODS,
@@ -203,6 +203,82 @@ export async function validateAllOwnership(
return null;
}
// ============================================================================
// Card Limit Validation
// ============================================================================
const formatBRL = (value: number) =>
new Intl.NumberFormat("pt-BR", {
style: "currency",
currency: "BRL",
}).format(value);
export async function validateCardLimit({
userId,
cardId,
addAmount,
excludeTransactionIds = [],
}: {
userId: string;
cardId: string;
addAmount: number;
excludeTransactionIds?: string[];
}): Promise<{ ok: true } | { ok: false; error: string }> {
if (addAmount <= 0) {
return { ok: true };
}
const card = await db.query.cards.findFirst({
columns: { limit: true },
where: and(eq(cards.id, cardId), eq(cards.userId, userId)),
});
if (!card) {
return { ok: false, error: "Cartão não encontrado." };
}
const limit = Number(card.limit);
if (!Number.isFinite(limit) || limit <= 0) {
return { ok: true };
}
const conditions = [
eq(transactions.userId, userId),
eq(transactions.cardId, cardId),
or(isNull(transactions.isSettled), eq(transactions.isSettled, false)),
or(
ne(transactions.condition, "Recorrente"),
sql`${transactions.purchaseDate} <= current_date`,
),
];
if (excludeTransactionIds.length > 0) {
conditions.push(not(inArray(transactions.id, excludeTransactionIds)));
}
const [row] = await db
.select({
total: sql<number>`coalesce(sum(${transactions.amount}), 0)`,
})
.from(transactions)
.where(and(...conditions));
const sumAmount = Number(row?.total ?? 0);
const inUse = sumAmount < 0 ? Math.abs(sumAmount) : 0;
const available = Math.max(limit - inUse, 0);
if (addAmount > available + 0.005) {
return {
ok: false,
error: `Lançamento de ${formatBRL(addAmount)} excede o limite disponível do cartão (${formatBRL(
available,
)}).`,
};
}
return { ok: true };
}
// ============================================================================
// Utility Functions
// ============================================================================
@@ -415,6 +491,11 @@ export const toggleSettlementSchema = z.object({
value: z.boolean({
message: "Informe o status de pagamento.",
}),
paymentAccountId: uuidSchema("Conta de pagamento").nullable().optional(),
paymentDate: z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/u, "Data de pagamento inválida.")
.optional(),
});
export type BaseInput = z.infer<typeof baseFields>;