Files
openmonetis/src/features/cards/actions.ts
Felipe Coutinho 8389752172 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>
2026-05-02 22:07:56 +00:00

172 lines
4.1 KiB
TypeScript

"use server";
import { and, eq } from "drizzle-orm";
import { z } from "zod";
import { cards, financialAccounts } from "@/db/schema";
import {
type ActionResult,
handleActionError,
revalidateForEntity,
} from "@/shared/lib/actions/helpers";
import { getUser } from "@/shared/lib/auth/server";
import { db } from "@/shared/lib/db";
import {
dayOfMonthSchema,
noteSchema,
requiredDecimalSchema,
uuidSchema,
} from "@/shared/lib/schemas/common";
import { formatDecimalForDbRequired } from "@/shared/utils/currency";
import { normalizeFilePath } from "@/shared/utils/string";
const cardBaseSchema = z.object({
name: z
.string({ message: "Informe o nome do cartão." })
.trim()
.min(1, "Informe o nome do cartão."),
brand: z
.string({ message: "Informe a bandeira." })
.trim()
.min(1, "Informe a bandeira."),
status: z
.string({ message: "Informe o status do cartão." })
.trim()
.min(1, "Informe o status do cartão."),
closingDay: dayOfMonthSchema,
dueDay: dayOfMonthSchema,
note: noteSchema,
limit: requiredDecimalSchema("limite"),
logo: z
.string({ message: "Selecione um logo." })
.trim()
.min(1, "Selecione um logo."),
accountId: uuidSchema("FinancialAccount"),
});
const createCardSchema = cardBaseSchema;
const updateCardSchema = cardBaseSchema.extend({
id: uuidSchema("Cartão"),
});
const deleteCardSchema = z.object({
id: uuidSchema("Cartão"),
});
type CardCreateInput = z.infer<typeof createCardSchema>;
type CardUpdateInput = z.infer<typeof updateCardSchema>;
type CardDeleteInput = z.infer<typeof deleteCardSchema>;
async function assertAccountOwnership(userId: string, accountId: string) {
const account = await db.query.financialAccounts.findFirst({
columns: { id: true },
where: and(
eq(financialAccounts.id, accountId),
eq(financialAccounts.userId, userId),
),
});
if (!account) {
throw new Error("Conta vinculada não encontrada.");
}
}
export async function createCardAction(
input: CardCreateInput,
): Promise<ActionResult> {
try {
const user = await getUser();
const data = createCardSchema.parse(input);
await assertAccountOwnership(user.id, data.accountId);
const logoFile = normalizeFilePath(data.logo);
await db.insert(cards).values({
name: data.name,
brand: data.brand,
status: data.status,
closingDay: data.closingDay,
dueDay: data.dueDay,
note: data.note ?? null,
limit: formatDecimalForDbRequired(data.limit),
logo: logoFile,
accountId: data.accountId,
userId: user.id,
});
revalidateForEntity("cards", user.id);
return { success: true, message: "Cartão criado com sucesso." };
} catch (error) {
return handleActionError(error);
}
}
export async function updateCardAction(
input: CardUpdateInput,
): Promise<ActionResult> {
try {
const user = await getUser();
const data = updateCardSchema.parse(input);
await assertAccountOwnership(user.id, data.accountId);
const logoFile = normalizeFilePath(data.logo);
const [updated] = await db
.update(cards)
.set({
name: data.name,
brand: data.brand,
status: data.status,
closingDay: data.closingDay,
dueDay: data.dueDay,
note: data.note ?? null,
limit: formatDecimalForDbRequired(data.limit),
logo: logoFile,
accountId: data.accountId,
})
.where(and(eq(cards.id, data.id), eq(cards.userId, user.id)))
.returning();
if (!updated) {
return {
success: false,
error: "Cartão não encontrado.",
};
}
revalidateForEntity("cards", user.id);
return { success: true, message: "Cartão atualizado com sucesso." };
} catch (error) {
return handleActionError(error);
}
}
export async function deleteCardAction(
input: CardDeleteInput,
): Promise<ActionResult> {
try {
const user = await getUser();
const data = deleteCardSchema.parse(input);
const [deleted] = await db
.delete(cards)
.where(and(eq(cards.id, data.id), eq(cards.userId, user.id)))
.returning({ id: cards.id });
if (!deleted) {
return {
success: false,
error: "Cartão não encontrado.",
};
}
revalidateForEntity("cards", user.id);
return { success: true, message: "Cartão removido com sucesso." };
} catch (error) {
return handleActionError(error);
}
}