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", "version": "7",
"dialect": "postgresql", "dialect": "postgresql",
"entries": [ "entries": [
{ {
"idx": 0, "idx": 0,
"version": "7", "version": "7",
"when": 1762993507299, "when": 1762993507299,
"tag": "0000_flashy_manta", "tag": "0000_flashy_manta",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 1, "idx": 1,
"version": "7", "version": "7",
"when": 1765199006435, "when": 1765199006435,
"tag": "0001_young_mister_fear", "tag": "0001_young_mister_fear",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 2, "idx": 2,
"version": "7", "version": "7",
"when": 1765200545692, "when": 1765200545692,
"tag": "0002_slimy_flatman", "tag": "0002_slimy_flatman",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 3, "idx": 3,
"version": "7", "version": "7",
"when": 1767102605526, "when": 1767102605526,
"tag": "0003_green_korg", "tag": "0003_green_korg",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 4, "idx": 4,
"version": "7", "version": "7",
"when": 1767104066872, "when": 1767104066872,
"tag": "0004_acoustic_mach_iv", "tag": "0004_acoustic_mach_iv",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 5, "idx": 5,
"version": "7", "version": "7",
"when": 1767106121811, "when": 1767106121811,
"tag": "0005_adorable_bruce_banner", "tag": "0005_adorable_bruce_banner",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 6, "idx": 6,
"version": "7", "version": "7",
"when": 1767107487318, "when": 1767107487318,
"tag": "0006_youthful_mister_fear", "tag": "0006_youthful_mister_fear",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 7, "idx": 7,
"version": "7", "version": "7",
"when": 1767118780033, "when": 1767118780033,
"tag": "0007_sturdy_kate_bishop", "tag": "0007_sturdy_kate_bishop",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 8, "idx": 8,
"version": "7", "version": "7",
"when": 1767125796314, "when": 1767125796314,
"tag": "0008_fat_stick", "tag": "0008_fat_stick",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 9, "idx": 9,
"version": "7", "version": "7",
"when": 1768925100873, "when": 1768925100873,
"tag": "0009_add_dashboard_widgets", "tag": "0009_add_dashboard_widgets",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 10, "idx": 10,
"version": "7", "version": "7",
"when": 1769369834242, "when": 1769369834242,
"tag": "0010_lame_psynapse", "tag": "0010_lame_psynapse",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 11, "idx": 11,
"version": "7", "version": "7",
"when": 1769447087678, "when": 1769447087678,
"tag": "0011_remove_unused_inbox_columns", "tag": "0011_remove_unused_inbox_columns",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 12, "idx": 12,
"version": "7", "version": "7",
"when": 1769533200000, "when": 1769533200000,
"tag": "0012_rename_tables_to_portuguese", "tag": "0012_rename_tables_to_portuguese",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 13, "idx": 13,
"version": "7", "version": "7",
"when": 1769523352777, "when": 1769523352777,
"tag": "0013_fancy_rick_jones", "tag": "0013_fancy_rick_jones",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 14, "idx": 14,
"version": "7", "version": "7",
"when": 1769619226903, "when": 1769619226903,
"tag": "0014_yielding_jack_flag", "tag": "0014_yielding_jack_flag",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 15, "idx": 15,
"version": "7", "version": "7",
"when": 1770332054481, "when": 1770332054481,
"tag": "0015_concerned_kat_farrell", "tag": "0015_concerned_kat_farrell",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 16, "idx": 16,
"version": "7", "version": "7",
"when": 1771166328908, "when": 1771166328908,
"tag": "0016_complete_randall", "tag": "0016_complete_randall",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 17, "idx": 17,
"version": "7", "version": "7",
"when": 1772400510326, "when": 1772400510326,
"tag": "0017_previous_warstar", "tag": "0017_previous_warstar",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 18, "idx": 18,
"version": "7", "version": "7",
"when": 1773020417482, "when": 1773020417482,
"tag": "0018_rainy_epoch", "tag": "0018_rainy_epoch",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 19, "idx": 19,
"version": "7", "version": "7",
"when": 1773699152928, "when": 1773699152928,
"tag": "0019_ordinary_wild_pack", "tag": "0019_ordinary_wild_pack",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 20, "idx": 20,
"version": "7", "version": "7",
"when": 1773841892114, "when": 1773841892114,
"tag": "0020_add-budget-invoice-unique-constraints", "tag": "0020_add-budget-invoice-unique-constraints",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 21, "idx": 21,
"version": "7", "version": "7",
"when": 1774033320053, "when": 1774033320053,
"tag": "0021_careful_malcolm_colcord", "tag": "0021_careful_malcolm_colcord",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 22, "idx": 22,
"version": "7", "version": "7",
"when": 1748000000000, "when": 1748000000000,
"tag": "0022_import-category-mappings", "tag": "0022_import-category-mappings",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 23, "idx": 23,
"version": "7", "version": "7",
"when": 1774529878374, "when": 1774529878374,
"tag": "0023_sturdy_wolfpack", "tag": "0023_sturdy_wolfpack",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 24, "idx": 24,
"version": "7", "version": "7",
"when": 1774891206703, "when": 1774891206703,
"tag": "0024_petite_lucky_pierre", "tag": "0024_petite_lucky_pierre",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 25, "idx": 25,
"version": "7", "version": "7",
"when": 1776351838548, "when": 1776351838548,
"tag": "0025_burly_colonel_america", "tag": "0025_burly_colonel_america",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 26, "idx": 26,
"version": "7", "version": "7",
"when": 1777042423451, "when": 1777042423451,
"tag": "0026_bored_eternity", "tag": "0026_bored_eternity",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 28, "idx": 28,
"version": "7", "version": "7",
"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,54 +162,45 @@ 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 && <div className="flex flex-col gap-0.5">
available !== null && <span className="text-xs text-muted-foreground">
used !== null && Limite disponível
limitTotal !== null ? ( </span>
<> <MoneyValues
<div className="flex flex-col gap-0.5"> amount={available}
<span className="text-xs text-muted-foreground">Disponível</span> className="text-xl font-semibold text-success"
<MoneyValues />
amount={available} </div>
className="text-xl font-semibold text-success"
/>
</div>
<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 <MoneyValues
</span> amount={limit}
<MoneyValues className="text-sm font-semibold text-foreground"
amount={limitTotal} />
className="text-sm font-semibold text-foreground" </div>
/> <div className="flex flex-col gap-0.5">
</div> <span className="text-xs text-muted-foreground">
<div className="flex flex-col gap-0.5"> Limite utilizado
<span className="text-xs text-muted-foreground">Em uso</span> </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>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Progress <Progress
value={usagePercent} value={usagePercent}
className="h-2.5" className="h-2.5"
aria-label={`${usagePercent.toFixed(0)}% do limite utilizado`} aria-label={`${usagePercent.toFixed(0)}% do limite utilizado`}
/> />
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
{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)
*/ */