From 838975217214542d9cd983480c9fb44678318efc Mon Sep 17 00:00:00 2001 From: Felipe Coutinho Date: Sat, 2 May 2026 22:07:56 +0000 Subject: [PATCH] =?UTF-8?q?feat(cartoes):=20exigir=20limite=20e=20bloquear?= =?UTF-8?q?=20lan=C3=A7amentos=20acima=20do=20dispon=C3=ADvel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- drizzle/meta/_journal.json | 409 +++++++++--------- .../cards/[cardId]/invoice/page.tsx | 17 +- src/db/schema.ts | 4 +- src/features/cards/actions.ts | 10 +- src/features/cards/components/card-dialog.tsx | 10 +- .../cards/components/card-form-fields.tsx | 3 +- src/features/cards/components/card-item.tsx | 107 ++--- src/features/cards/components/types.ts | 4 +- src/features/cards/queries.ts | 9 +- src/features/transactions/actions/core.ts | 85 +++- src/shared/lib/schemas/common.ts | 32 ++ 11 files changed, 400 insertions(+), 290 deletions(-) diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 618e985..824db9b 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -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 + } + ] +} \ No newline at end of file diff --git a/src/app/(dashboard)/cards/[cardId]/invoice/page.tsx b/src/app/(dashboard)/cards/[cardId]/invoice/page.tsx index fe75995..e1793e4 100644 --- a/src/app/(dashboard)/cards/[cardId]/invoice/page.tsx +++ b/src/app/(dashboard)/cards/[cardId]/invoice/page.tsx @@ -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={
- + onChange("limit", value)} placeholder="R$ 0,00" + required />
diff --git a/src/features/cards/components/card-item.tsx b/src/features/cards/components/card-item.tsx index 230dccf..cb770ac 100644 --- a/src/features/cards/components/card-item.tsx +++ b/src/features/cards/components/card-item.tsx @@ -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 ( @@ -174,54 +162,45 @@ export function CardItem({ - {hasMetrics && - available !== null && - used !== null && - limitTotal !== null ? ( - <> -
- Disponível - -
+
+ + Limite disponível + + +
-
-
- - Limite total - - -
-
- Em uso - -
-
+
+
+ Limite total + +
+
+ + Limite utilizado + + +
+
-
- - - {usagePercent.toFixed(1)}% utilizado - -
- - ) : ( -

- Ainda não há limite registrado para este cartão. -

- )} +
+ + + {usagePercent.toFixed(1)}% utilizado + +
diff --git a/src/features/cards/components/types.ts b/src/features/cards/components/types.ts index fdea2b3..0912484 100644 --- a/src/features/cards/components/types.ts +++ b/src/features/cards/components/types.ts @@ -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 = { diff --git a/src/features/cards/queries.ts b/src/features/cards/queries.ts index e528dce..06fab5e 100644 --- a/src/features/cards/queries.ts +++ b/src/features/cards/queries.ts @@ -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); diff --git a/src/features/transactions/actions/core.ts b/src/features/transactions/actions/core.ts index 3573dcc..3c0f4a8 100644 --- a/src/features/transactions/actions/core.ts +++ b/src/features/transactions/actions/core.ts @@ -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`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; diff --git a/src/shared/lib/schemas/common.ts b/src/shared/lib/schemas/common.ts index 1639f4d..fbd4078 100644 --- a/src/shared/lib/schemas/common.ts +++ b/src/shared/lib/schemas/common.ts @@ -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) */