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