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,202 +1,209 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1762993507299,
"tag": "0000_flashy_manta",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1765199006435,
"tag": "0001_young_mister_fear",
"breakpoints": true
},
{
"idx": 2,
"version": "7",
"when": 1765200545692,
"tag": "0002_slimy_flatman",
"breakpoints": true
},
{
"idx": 3,
"version": "7",
"when": 1767102605526,
"tag": "0003_green_korg",
"breakpoints": true
},
{
"idx": 4,
"version": "7",
"when": 1767104066872,
"tag": "0004_acoustic_mach_iv",
"breakpoints": true
},
{
"idx": 5,
"version": "7",
"when": 1767106121811,
"tag": "0005_adorable_bruce_banner",
"breakpoints": true
},
{
"idx": 6,
"version": "7",
"when": 1767107487318,
"tag": "0006_youthful_mister_fear",
"breakpoints": true
},
{
"idx": 7,
"version": "7",
"when": 1767118780033,
"tag": "0007_sturdy_kate_bishop",
"breakpoints": true
},
{
"idx": 8,
"version": "7",
"when": 1767125796314,
"tag": "0008_fat_stick",
"breakpoints": true
},
{
"idx": 9,
"version": "7",
"when": 1768925100873,
"tag": "0009_add_dashboard_widgets",
"breakpoints": true
},
{
"idx": 10,
"version": "7",
"when": 1769369834242,
"tag": "0010_lame_psynapse",
"breakpoints": true
},
{
"idx": 11,
"version": "7",
"when": 1769447087678,
"tag": "0011_remove_unused_inbox_columns",
"breakpoints": true
},
{
"idx": 12,
"version": "7",
"when": 1769533200000,
"tag": "0012_rename_tables_to_portuguese",
"breakpoints": true
},
{
"idx": 13,
"version": "7",
"when": 1769523352777,
"tag": "0013_fancy_rick_jones",
"breakpoints": true
},
{
"idx": 14,
"version": "7",
"when": 1769619226903,
"tag": "0014_yielding_jack_flag",
"breakpoints": true
},
{
"idx": 15,
"version": "7",
"when": 1770332054481,
"tag": "0015_concerned_kat_farrell",
"breakpoints": true
},
{
"idx": 16,
"version": "7",
"when": 1771166328908,
"tag": "0016_complete_randall",
"breakpoints": true
},
{
"idx": 17,
"version": "7",
"when": 1772400510326,
"tag": "0017_previous_warstar",
"breakpoints": true
},
{
"idx": 18,
"version": "7",
"when": 1773020417482,
"tag": "0018_rainy_epoch",
"breakpoints": true
},
{
"idx": 19,
"version": "7",
"when": 1773699152928,
"tag": "0019_ordinary_wild_pack",
"breakpoints": true
},
{
"idx": 20,
"version": "7",
"when": 1773841892114,
"tag": "0020_add-budget-invoice-unique-constraints",
"breakpoints": true
},
{
"idx": 21,
"version": "7",
"when": 1774033320053,
"tag": "0021_careful_malcolm_colcord",
"breakpoints": true
},
{
"idx": 22,
"version": "7",
"when": 1748000000000,
"tag": "0022_import-category-mappings",
"breakpoints": true
},
{
"idx": 23,
"version": "7",
"when": 1774529878374,
"tag": "0023_sturdy_wolfpack",
"breakpoints": true
},
{
"idx": 24,
"version": "7",
"when": 1774891206703,
"tag": "0024_petite_lucky_pierre",
"breakpoints": true
},
{
"idx": 25,
"version": "7",
"when": 1776351838548,
"tag": "0025_burly_colonel_america",
"breakpoints": true
},
{
"idx": 26,
"version": "7",
"when": 1777042423451,
"tag": "0026_bored_eternity",
"breakpoints": true
},
{
"idx": 28,
"version": "7",
"when": 1777153372633,
"tag": "0028_fancy_reaper",
"breakpoints": true
}
]
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1762993507299,
"tag": "0000_flashy_manta",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1765199006435,
"tag": "0001_young_mister_fear",
"breakpoints": true
},
{
"idx": 2,
"version": "7",
"when": 1765200545692,
"tag": "0002_slimy_flatman",
"breakpoints": true
},
{
"idx": 3,
"version": "7",
"when": 1767102605526,
"tag": "0003_green_korg",
"breakpoints": true
},
{
"idx": 4,
"version": "7",
"when": 1767104066872,
"tag": "0004_acoustic_mach_iv",
"breakpoints": true
},
{
"idx": 5,
"version": "7",
"when": 1767106121811,
"tag": "0005_adorable_bruce_banner",
"breakpoints": true
},
{
"idx": 6,
"version": "7",
"when": 1767107487318,
"tag": "0006_youthful_mister_fear",
"breakpoints": true
},
{
"idx": 7,
"version": "7",
"when": 1767118780033,
"tag": "0007_sturdy_kate_bishop",
"breakpoints": true
},
{
"idx": 8,
"version": "7",
"when": 1767125796314,
"tag": "0008_fat_stick",
"breakpoints": true
},
{
"idx": 9,
"version": "7",
"when": 1768925100873,
"tag": "0009_add_dashboard_widgets",
"breakpoints": true
},
{
"idx": 10,
"version": "7",
"when": 1769369834242,
"tag": "0010_lame_psynapse",
"breakpoints": true
},
{
"idx": 11,
"version": "7",
"when": 1769447087678,
"tag": "0011_remove_unused_inbox_columns",
"breakpoints": true
},
{
"idx": 12,
"version": "7",
"when": 1769533200000,
"tag": "0012_rename_tables_to_portuguese",
"breakpoints": true
},
{
"idx": 13,
"version": "7",
"when": 1769523352777,
"tag": "0013_fancy_rick_jones",
"breakpoints": true
},
{
"idx": 14,
"version": "7",
"when": 1769619226903,
"tag": "0014_yielding_jack_flag",
"breakpoints": true
},
{
"idx": 15,
"version": "7",
"when": 1770332054481,
"tag": "0015_concerned_kat_farrell",
"breakpoints": true
},
{
"idx": 16,
"version": "7",
"when": 1771166328908,
"tag": "0016_complete_randall",
"breakpoints": true
},
{
"idx": 17,
"version": "7",
"when": 1772400510326,
"tag": "0017_previous_warstar",
"breakpoints": true
},
{
"idx": 18,
"version": "7",
"when": 1773020417482,
"tag": "0018_rainy_epoch",
"breakpoints": true
},
{
"idx": 19,
"version": "7",
"when": 1773699152928,
"tag": "0019_ordinary_wild_pack",
"breakpoints": true
},
{
"idx": 20,
"version": "7",
"when": 1773841892114,
"tag": "0020_add-budget-invoice-unique-constraints",
"breakpoints": true
},
{
"idx": 21,
"version": "7",
"when": 1774033320053,
"tag": "0021_careful_malcolm_colcord",
"breakpoints": true
},
{
"idx": 22,
"version": "7",
"when": 1748000000000,
"tag": "0022_import-category-mappings",
"breakpoints": true
},
{
"idx": 23,
"version": "7",
"when": 1774529878374,
"tag": "0023_sturdy_wolfpack",
"breakpoints": true
},
{
"idx": 24,
"version": "7",
"when": 1774891206703,
"tag": "0024_petite_lucky_pierre",
"breakpoints": true
},
{
"idx": 25,
"version": "7",
"when": 1776351838548,
"tag": "0025_burly_colonel_america",
"breakpoints": true
},
{
"idx": 26,
"version": "7",
"when": 1777042423451,
"tag": "0026_bored_eternity",
"breakpoints": true
},
{
"idx": 28,
"version": "7",
"when": 1777153372633,
"tag": "0028_fancy_reaper",
"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,
)?.name ?? "Conta";
const limitAmount = Number(card.limit);
const cardDialogData: Card = {
id: card.id,
name: card.name,
@@ -127,19 +129,14 @@ export default async function Page({ params, searchParams }: PageProps) {
dueDay: card.dueDay,
note: card.note ?? null,
logo: card.logo,
limit:
card.limit !== null && card.limit !== undefined
? Number(card.limit)
: null,
limit: limitAmount,
accountId: card.accountId,
accountName,
limitInUse: 0,
limitAvailable: null,
limitAvailable: limitAmount,
};
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(
1,
@@ -163,6 +160,12 @@ export default async function Page({ params, searchParams }: PageProps) {
limitAmount={limitAmount}
invoiceStatus={invoiceStatus}
paymentDate={paymentDate}
defaultPaymentAccountId={card.accountId}
paymentAccountOptions={accountOptions.map((option) => ({
value: option.value,
label: option.label,
logo: option.logo ?? null,
}))}
logo={card.logo}
actions={
<CardDialog

View File

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

View File

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

View File

@@ -154,13 +154,21 @@ export function CardDialog({
}
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 = {
name: formState.name.trim(),
brand: formState.brand,
status: formState.status,
closingDay: formState.closingDay,
dueDay: formState.dueDay,
limit: rawLimit ? Number(rawLimit) : null,
limit: limitValue,
note: formState.note.trim() || null,
logo: formState.logo,
accountId: formState.accountId,

View File

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

View File

@@ -30,9 +30,9 @@ interface CardItemProps {
status: string;
closingDay: string;
dueDay: string;
limit: number | null;
limitInUse?: number | null;
limitAvailable?: number | null;
limit: number;
limitInUse?: number;
limitAvailable?: number;
accountName: string;
logo?: string | null;
note?: string | null;
@@ -61,30 +61,18 @@ export function CardItem({
}: CardItemProps) {
void _accountName;
const limitTotal = limit ?? null;
const used =
limitInUse ??
(limitTotal !== null && limitAvailable != null
? Math.max(limitTotal - limitAvailable, 0)
: limitTotal !== null
? 0
: null);
(limitAvailable !== undefined ? Math.max(limit - limitAvailable, 0) : 0);
const available =
limitAvailable ??
(limitTotal !== null && used !== null
? Math.max(limitTotal - used, 0)
: null);
const available = limitAvailable ?? Math.max(limit - used, 0);
const usagePercent =
limitTotal && limitTotal > 0 && used !== null
? Math.min(Math.max((used / limitTotal) * 100, 0), 100)
: 0;
limit > 0 ? Math.min(Math.max((used / limit) * 100, 0), 100) : 0;
const logoPath = resolveLogoSrc(logo);
const brandAsset = resolveCardBrandAsset(brand);
const isInactive = status?.toLowerCase() === "inativo";
const hasMetrics = limitTotal !== null && used !== null && available !== null;
return (
<Card className="flex flex-col p-6 w-full">
@@ -174,54 +162,45 @@ export function CardItem({
</CardHeader>
<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">
<span className="text-xs text-muted-foreground">Disponível</span>
<MoneyValues
amount={available}
className="text-xl font-semibold text-success"
/>
</div>
<div className="flex flex-col gap-0.5">
<span className="text-xs text-muted-foreground">
Limite disponível
</span>
<MoneyValues
amount={available}
className="text-xl font-semibold text-success"
/>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="flex flex-col gap-0.5">
<span className="text-xs text-muted-foreground">
Limite total
</span>
<MoneyValues
amount={limitTotal}
className="text-sm font-semibold text-foreground"
/>
</div>
<div className="flex flex-col gap-0.5">
<span className="text-xs text-muted-foreground">Em uso</span>
<MoneyValues
amount={used}
className="text-sm font-semibold text-primary"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="flex flex-col gap-0.5">
<span className="text-xs text-muted-foreground">Limite total</span>
<MoneyValues
amount={limit}
className="text-sm font-semibold text-foreground"
/>
</div>
<div className="flex flex-col gap-0.5">
<span className="text-xs text-muted-foreground">
Limite utilizado
</span>
<MoneyValues
amount={used}
className="text-sm font-semibold text-destructive"
/>
</div>
</div>
<div className="flex flex-col gap-2">
<Progress
value={usagePercent}
className="h-2.5"
aria-label={`${usagePercent.toFixed(0)}% do limite utilizado`}
/>
<span className="text-xs text-muted-foreground">
{usagePercent.toFixed(1)}% utilizado
</span>
</div>
</>
) : (
<p className="text-sm text-muted-foreground">
Ainda não limite registrado para este cartão.
</p>
)}
<div className="flex flex-col gap-2">
<Progress
value={usagePercent}
className="h-2.5"
aria-label={`${usagePercent.toFixed(0)}% do limite utilizado`}
/>
<span className="text-xs text-muted-foreground">
{usagePercent.toFixed(1)}% utilizado
</span>
</div>
</CardContent>
<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;
note: string | null;
logo: string | null;
limit: number | null;
limit: number;
accountId: string;
accountName: string;
limitInUse: number;
limitAvailable: number | null;
limitAvailable: number;
};
export type CardFormValues = {

View File

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

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>;

View File

@@ -31,6 +31,38 @@ export const optionalDecimalSchema = z.union([
.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)
*/