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

@@ -197,6 +197,13 @@
"when": 1777153372633, "when": 1777153372633,
"tag": "0028_fancy_reaper", "tag": "0028_fancy_reaper",
"breakpoints": true "breakpoints": true
},
{
"idx": 29,
"version": "7",
"when": 1777648189399,
"tag": "0029_friendly_spitfire",
"breakpoints": true
} }
] ]
} }

View File

@@ -118,6 +118,8 @@ export default async function Page({ params, searchParams }: PageProps) {
financialAccount.id === card.accountId, financialAccount.id === card.accountId,
)?.name ?? "Conta"; )?.name ?? "Conta";
const limitAmount = Number(card.limit);
const cardDialogData: Card = { const cardDialogData: Card = {
id: card.id, id: card.id,
name: card.name, name: card.name,
@@ -127,19 +129,14 @@ export default async function Page({ params, searchParams }: PageProps) {
dueDay: card.dueDay, dueDay: card.dueDay,
note: card.note ?? null, note: card.note ?? null,
logo: card.logo, logo: card.logo,
limit: limit: limitAmount,
card.limit !== null && card.limit !== undefined
? Number(card.limit)
: null,
accountId: card.accountId, accountId: card.accountId,
accountName, accountName,
limitInUse: 0, limitInUse: 0,
limitAvailable: null, limitAvailable: limitAmount,
}; };
const { totalAmount, invoiceStatus, paymentDate } = invoiceData; const { totalAmount, invoiceStatus, paymentDate } = invoiceData;
const limitAmount =
card.limit !== null && card.limit !== undefined ? Number(card.limit) : null;
const periodLabel = `${monthName.charAt(0).toUpperCase()}${monthName.slice( const periodLabel = `${monthName.charAt(0).toUpperCase()}${monthName.slice(
1, 1,
@@ -163,6 +160,12 @@ export default async function Page({ params, searchParams }: PageProps) {
limitAmount={limitAmount} limitAmount={limitAmount}
invoiceStatus={invoiceStatus} invoiceStatus={invoiceStatus}
paymentDate={paymentDate} paymentDate={paymentDate}
defaultPaymentAccountId={card.accountId}
paymentAccountOptions={accountOptions.map((option) => ({
value: option.value,
label: option.label,
logo: option.logo ?? null,
}))}
logo={card.logo} logo={card.logo}
actions={ actions={
<CardDialog <CardDialog

View File

@@ -301,7 +301,9 @@ export const cards = pgTable(
closingDay: text("dt_fechamento").notNull(), closingDay: text("dt_fechamento").notNull(),
dueDay: text("dt_vencimento").notNull(), dueDay: text("dt_vencimento").notNull(),
note: text("anotacao"), note: text("anotacao"),
limit: numeric("limite", { precision: 10, scale: 2 }), limit: numeric("limite", { precision: 10, scale: 2 })
.notNull()
.default("0"),
brand: text("bandeira"), brand: text("bandeira"),
logo: text("logo"), logo: text("logo"),
status: text("status").notNull(), status: text("status").notNull(),

View File

@@ -13,10 +13,10 @@ import { db } from "@/shared/lib/db";
import { import {
dayOfMonthSchema, dayOfMonthSchema,
noteSchema, noteSchema,
optionalDecimalSchema, requiredDecimalSchema,
uuidSchema, uuidSchema,
} from "@/shared/lib/schemas/common"; } from "@/shared/lib/schemas/common";
import { formatDecimalForDb } from "@/shared/utils/currency"; import { formatDecimalForDbRequired } from "@/shared/utils/currency";
import { normalizeFilePath } from "@/shared/utils/string"; import { normalizeFilePath } from "@/shared/utils/string";
const cardBaseSchema = z.object({ const cardBaseSchema = z.object({
@@ -35,7 +35,7 @@ const cardBaseSchema = z.object({
closingDay: dayOfMonthSchema, closingDay: dayOfMonthSchema,
dueDay: dayOfMonthSchema, dueDay: dayOfMonthSchema,
note: noteSchema, note: noteSchema,
limit: optionalDecimalSchema, limit: requiredDecimalSchema("limite"),
logo: z logo: z
.string({ message: "Selecione um logo." }) .string({ message: "Selecione um logo." })
.trim() .trim()
@@ -87,7 +87,7 @@ export async function createCardAction(
closingDay: data.closingDay, closingDay: data.closingDay,
dueDay: data.dueDay, dueDay: data.dueDay,
note: data.note ?? null, note: data.note ?? null,
limit: formatDecimalForDb(data.limit), limit: formatDecimalForDbRequired(data.limit),
logo: logoFile, logo: logoFile,
accountId: data.accountId, accountId: data.accountId,
userId: user.id, userId: user.id,
@@ -121,7 +121,7 @@ export async function updateCardAction(
closingDay: data.closingDay, closingDay: data.closingDay,
dueDay: data.dueDay, dueDay: data.dueDay,
note: data.note ?? null, note: data.note ?? null,
limit: formatDecimalForDb(data.limit), limit: formatDecimalForDbRequired(data.limit),
logo: logoFile, logo: logoFile,
accountId: data.accountId, accountId: data.accountId,
}) })

View File

@@ -154,13 +154,21 @@ export function CardDialog({
} }
const rawLimit = normalizeDecimalInput(formState.limit); const rawLimit = normalizeDecimalInput(formState.limit);
const limitValue = rawLimit ? Number(rawLimit) : 0;
if (!Number.isFinite(limitValue) || limitValue <= 0) {
const message = "Informe um limite maior que zero.";
setErrorMessage(message);
toast.error(message);
return;
}
const payload: CardCreatePayload = { const payload: CardCreatePayload = {
name: formState.name.trim(), name: formState.name.trim(),
brand: formState.brand, brand: formState.brand,
status: formState.status, status: formState.status,
closingDay: formState.closingDay, closingDay: formState.closingDay,
dueDay: formState.dueDay, dueDay: formState.dueDay,
limit: rawLimit ? Number(rawLimit) : null, limit: limitValue,
note: formState.note.trim() || null, note: formState.note.trim() || null,
logo: formState.logo, logo: formState.logo,
accountId: formState.accountId, accountId: formState.accountId,

View File

@@ -112,12 +112,13 @@ export function CardFormFields({
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label htmlFor="card-limit">Limite (R$)</Label> <Label htmlFor="card-limit">Limite</Label>
<CurrencyInput <CurrencyInput
id="card-limit" id="card-limit"
value={values.limit} value={values.limit}
onValueChange={(value) => onChange("limit", value)} onValueChange={(value) => onChange("limit", value)}
placeholder="R$ 0,00" placeholder="R$ 0,00"
required
/> />
</div> </div>

View File

@@ -30,9 +30,9 @@ interface CardItemProps {
status: string; status: string;
closingDay: string; closingDay: string;
dueDay: string; dueDay: string;
limit: number | null; limit: number;
limitInUse?: number | null; limitInUse?: number;
limitAvailable?: number | null; limitAvailable?: number;
accountName: string; accountName: string;
logo?: string | null; logo?: string | null;
note?: string | null; note?: string | null;
@@ -61,30 +61,18 @@ export function CardItem({
}: CardItemProps) { }: CardItemProps) {
void _accountName; void _accountName;
const limitTotal = limit ?? null;
const used = const used =
limitInUse ?? limitInUse ??
(limitTotal !== null && limitAvailable != null (limitAvailable !== undefined ? Math.max(limit - limitAvailable, 0) : 0);
? Math.max(limitTotal - limitAvailable, 0)
: limitTotal !== null
? 0
: null);
const available = const available = limitAvailable ?? Math.max(limit - used, 0);
limitAvailable ??
(limitTotal !== null && used !== null
? Math.max(limitTotal - used, 0)
: null);
const usagePercent = const usagePercent =
limitTotal && limitTotal > 0 && used !== null limit > 0 ? Math.min(Math.max((used / limit) * 100, 0), 100) : 0;
? Math.min(Math.max((used / limitTotal) * 100, 0), 100)
: 0;
const logoPath = resolveLogoSrc(logo); const logoPath = resolveLogoSrc(logo);
const brandAsset = resolveCardBrandAsset(brand); const brandAsset = resolveCardBrandAsset(brand);
const isInactive = status?.toLowerCase() === "inativo"; const isInactive = status?.toLowerCase() === "inativo";
const hasMetrics = limitTotal !== null && used !== null && available !== null;
return ( return (
<Card className="flex flex-col p-6 w-full"> <Card className="flex flex-col p-6 w-full">
@@ -174,13 +162,10 @@ export function CardItem({
</CardHeader> </CardHeader>
<CardContent className="flex flex-1 flex-col gap-4 px-0"> <CardContent className="flex flex-1 flex-col gap-4 px-0">
{hasMetrics &&
available !== null &&
used !== null &&
limitTotal !== null ? (
<>
<div className="flex flex-col gap-0.5"> <div className="flex flex-col gap-0.5">
<span className="text-xs text-muted-foreground">Disponível</span> <span className="text-xs text-muted-foreground">
Limite disponível
</span>
<MoneyValues <MoneyValues
amount={available} amount={available}
className="text-xl font-semibold text-success" className="text-xl font-semibold text-success"
@@ -189,19 +174,19 @@ export function CardItem({
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
<div className="flex flex-col gap-0.5"> <div className="flex flex-col gap-0.5">
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">Limite total</span>
Limite total
</span>
<MoneyValues <MoneyValues
amount={limitTotal} amount={limit}
className="text-sm font-semibold text-foreground" className="text-sm font-semibold text-foreground"
/> />
</div> </div>
<div className="flex flex-col gap-0.5"> <div className="flex flex-col gap-0.5">
<span className="text-xs text-muted-foreground">Em uso</span> <span className="text-xs text-muted-foreground">
Limite utilizado
</span>
<MoneyValues <MoneyValues
amount={used} amount={used}
className="text-sm font-semibold text-primary" className="text-sm font-semibold text-destructive"
/> />
</div> </div>
</div> </div>
@@ -216,12 +201,6 @@ export function CardItem({
{usagePercent.toFixed(1)}% utilizado {usagePercent.toFixed(1)}% utilizado
</span> </span>
</div> </div>
</>
) : (
<p className="text-sm text-muted-foreground">
Ainda não limite registrado para este cartão.
</p>
)}
</CardContent> </CardContent>
<CardFooter className="mt-auto flex flex-wrap gap-4 px-0 pt-2 text-sm"> <CardFooter className="mt-auto flex flex-wrap gap-4 px-0 pt-2 text-sm">

View File

@@ -7,11 +7,11 @@ export type Card = {
dueDay: string; dueDay: string;
note: string | null; note: string | null;
logo: string | null; logo: string | null;
limit: number | null; limit: number;
accountId: string; accountId: string;
accountName: string; accountName: string;
limitInUse: number; limitInUse: number;
limitAvailable: number | null; limitAvailable: number;
}; };
export type CardFormValues = { export type CardFormValues = {

View File

@@ -12,9 +12,9 @@ type CardData = {
dueDay: string; dueDay: string;
note: string | null; note: string | null;
logo: string | null; logo: string | null;
limit: number | null; limit: number;
limitInUse: number; limitInUse: number;
limitAvailable: number | null; limitAvailable: number;
accountId: string; accountId: string;
accountName: string; accountName: string;
}; };
@@ -96,15 +96,12 @@ async function fetchCardsByStatus(
dueDay: card.dueDay, dueDay: card.dueDay,
note: card.note, note: card.note,
logo: card.logo, logo: card.logo,
limit: card.limit ? Number(card.limit) : null, limit: Number(card.limit),
limitInUse: (() => { limitInUse: (() => {
const total = usageMap.get(card.id) ?? 0; const total = usageMap.get(card.id) ?? 0;
return total < 0 ? Math.abs(total) : 0; return total < 0 ? Math.abs(total) : 0;
})(), })(),
limitAvailable: (() => { limitAvailable: (() => {
if (!card.limit) {
return null;
}
const total = usageMap.get(card.id) ?? 0; const total = usageMap.get(card.id) ?? 0;
const inUse = total < 0 ? Math.abs(total) : 0; const inUse = total < 0 ? Math.abs(total) : 0;
return Math.max(Number(card.limit) - inUse, 0); return Math.max(Number(card.limit) - inUse, 0);

View File

@@ -1,5 +1,5 @@
import { randomUUID } from "node:crypto"; 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 { z } from "zod";
import { import {
cards, cards,
@@ -7,7 +7,7 @@ import {
financialAccounts, financialAccounts,
invoices, invoices,
payers, payers,
type transactions, transactions,
} from "@/db/schema"; } from "@/db/schema";
import { import {
PAYMENT_METHODS, PAYMENT_METHODS,
@@ -203,6 +203,82 @@ export async function validateAllOwnership(
return null; 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 // Utility Functions
// ============================================================================ // ============================================================================
@@ -415,6 +491,11 @@ export const toggleSettlementSchema = z.object({
value: z.boolean({ value: z.boolean({
message: "Informe o status de pagamento.", 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>; export type BaseInput = z.infer<typeof baseFields>;

View File

@@ -31,6 +31,38 @@ export const optionalDecimalSchema = z.union([
.transform((value) => (value === null ? null : Number.parseFloat(value))), .transform((value) => (value === null ? null : Number.parseFloat(value))),
]); ]);
/**
* Required positive decimal schema — accepts number or numeric string.
*/
export const requiredDecimalSchema = (fieldName: string = "valor") =>
z
.union([
z.number(),
z
.string()
.trim()
.min(1, `Informe o ${fieldName}.`)
.transform((value) => value.replace(",", ".")),
])
.transform((value, ctx) => {
const parsed = typeof value === "number" ? value : Number.parseFloat(value);
if (Number.isNaN(parsed)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Informe um valor numérico válido.",
});
return z.NEVER;
}
if (parsed <= 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Informe um ${fieldName} maior que zero.`,
});
return z.NEVER;
}
return parsed;
});
/** /**
* Day of month schema (1-31) * Day of month schema (1-31)
*/ */