mirror of
https://github.com/felipegcoutinho/openmonetis.git
synced 2026-05-09 11:01:45 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 há 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">
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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)
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user