Files
openmonetis/app/(dashboard)/cartoes/[cartaoId]/fatura/actions.ts
Felipe Coutinho f50261208a feat(v1.4.0): design system semântico, correções de revalidação e melhorias de UX
- Adicionar tokens semânticos de estado (success, warning, info) no globals.css
- Migrar ~60+ componentes de cores hardcoded do Tailwind para tokens semânticos
- Unificar 3 arrays duplicados de cores de categorias em importação única
- Corrigir widgets de boleto/fatura que não atualizavam após pagamento
  (actions de fatura e antecipação não invalidavam cache do dashboard)
- Corrigir scroll em listas Popover+Command (modal prop)
- Adicionar link "detalhes" no card de orçamento para página da categoria
- Adicionar indicadores de tendência coloridos nos cards de métricas
- Estender cores de chart de 6 para 10
- Normalizar dark mode e remover tokens não utilizados

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 15:14:59 +00:00

293 lines
7.6 KiB
TypeScript

"use server";
import { and, eq, sql } from "drizzle-orm";
import { z } from "zod";
import {
cartoes,
categorias,
faturas,
lancamentos,
pagadores,
} from "@/db/schema";
import { buildInvoicePaymentNote } from "@/lib/accounts/constants";
import { revalidateForEntity } from "@/lib/actions/helpers";
import { getUser } from "@/lib/auth/server";
import { db } from "@/lib/db";
import {
INVOICE_PAYMENT_STATUS,
INVOICE_STATUS_VALUES,
type InvoicePaymentStatus,
PERIOD_FORMAT_REGEX,
} from "@/lib/faturas";
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
import { parseLocalDateString } from "@/lib/utils/date";
const updateInvoicePaymentStatusSchema = z.object({
cartaoId: z.string({ message: "Cartão inválido." }).uuid("Cartão inválido."),
period: z
.string({ message: "Período inválido." })
.regex(PERIOD_FORMAT_REGEX, "Período inválido."),
status: z.enum(
INVOICE_STATUS_VALUES as [InvoicePaymentStatus, ...InvoicePaymentStatus[]],
),
paymentDate: z.string().optional(),
});
type UpdateInvoicePaymentStatusInput = z.infer<
typeof updateInvoicePaymentStatusSchema
>;
type ActionResult =
| { success: true; message: string }
| { success: false; error: string };
const successMessageByStatus: Record<InvoicePaymentStatus, string> = {
[INVOICE_PAYMENT_STATUS.PAID]: "Fatura marcada como paga.",
[INVOICE_PAYMENT_STATUS.PENDING]: "Pagamento da fatura foi revertido.",
};
const formatDecimal = (value: number) =>
(Math.round(value * 100) / 100).toFixed(2);
export async function updateInvoicePaymentStatusAction(
input: UpdateInvoicePaymentStatusInput,
): Promise<ActionResult> {
try {
const user = await getUser();
const data = updateInvoicePaymentStatusSchema.parse(input);
await db.transaction(async (tx: typeof db) => {
const card = await tx.query.cartoes.findFirst({
columns: { id: true, contaId: true, name: true },
where: and(eq(cartoes.id, data.cartaoId), eq(cartoes.userId, user.id)),
});
if (!card) {
throw new Error("Cartão não encontrado.");
}
const existingInvoice = await tx.query.faturas.findFirst({
columns: {
id: true,
},
where: and(
eq(faturas.cartaoId, data.cartaoId),
eq(faturas.userId, user.id),
eq(faturas.period, data.period),
),
});
if (existingInvoice) {
await tx
.update(faturas)
.set({
paymentStatus: data.status,
})
.where(eq(faturas.id, existingInvoice.id));
} else {
await tx.insert(faturas).values({
cartaoId: data.cartaoId,
period: data.period,
paymentStatus: data.status,
userId: user.id,
});
}
const shouldMarkAsPaid = data.status === INVOICE_PAYMENT_STATUS.PAID;
await tx
.update(lancamentos)
.set({ isSettled: shouldMarkAsPaid })
.where(
and(
eq(lancamentos.userId, user.id),
eq(lancamentos.cartaoId, card.id),
eq(lancamentos.period, data.period),
),
);
const invoiceNote = buildInvoicePaymentNote(card.id, data.period);
if (shouldMarkAsPaid) {
const [adminShareRow] = await tx
.select({
total: sql<number>`
coalesce(
sum(
case
when ${lancamentos.transactionType} = 'Despesa' then ${lancamentos.amount}
else 0
end
),
0
)
`,
})
.from(lancamentos)
.leftJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.where(
and(
eq(lancamentos.userId, user.id),
eq(lancamentos.cartaoId, card.id),
eq(lancamentos.period, data.period),
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
),
);
const adminShare = Math.abs(Number(adminShareRow?.total ?? 0));
if (adminShare > 0 && card.contaId) {
const adminPagador = await tx.query.pagadores.findFirst({
columns: { id: true },
where: and(
eq(pagadores.userId, user.id),
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
),
});
const paymentCategory = await tx.query.categorias.findFirst({
columns: { id: true },
where: and(
eq(categorias.userId, user.id),
eq(categorias.name, "Pagamentos"),
),
});
if (adminPagador) {
// Usar a data customizada ou a data atual como data de pagamento
const invoiceDate = data.paymentDate
? parseLocalDateString(data.paymentDate)
: new Date();
const amount = `-${formatDecimal(adminShare)}`;
const payload = {
condition: "À vista",
name: `Pagamento fatura - ${card.name}`,
paymentMethod: "Pix",
note: invoiceNote,
amount,
purchaseDate: invoiceDate,
transactionType: "Despesa" as const,
period: data.period,
isSettled: true,
userId: user.id,
contaId: card.contaId,
categoriaId: paymentCategory?.id ?? null,
pagadorId: adminPagador.id,
};
const existingPayment = await tx.query.lancamentos.findFirst({
columns: { id: true },
where: and(
eq(lancamentos.userId, user.id),
eq(lancamentos.note, invoiceNote),
),
});
if (existingPayment) {
await tx
.update(lancamentos)
.set(payload)
.where(eq(lancamentos.id, existingPayment.id));
} else {
await tx.insert(lancamentos).values(payload);
}
}
}
} else {
await tx
.delete(lancamentos)
.where(
and(
eq(lancamentos.userId, user.id),
eq(lancamentos.note, invoiceNote),
),
);
}
});
revalidateForEntity("cartoes");
return { success: true, message: successMessageByStatus[data.status] };
} catch (error) {
if (error instanceof z.ZodError) {
return {
success: false,
error: error.issues[0]?.message ?? "Dados inválidos.",
};
}
return {
success: false,
error: error instanceof Error ? error.message : "Erro inesperado.",
};
}
}
const updatePaymentDateSchema = z.object({
cartaoId: z.string({ message: "Cartão inválido." }).uuid("Cartão inválido."),
period: z
.string({ message: "Período inválido." })
.regex(PERIOD_FORMAT_REGEX, "Período inválido."),
paymentDate: z.string({ message: "Data de pagamento inválida." }),
});
type UpdatePaymentDateInput = z.infer<typeof updatePaymentDateSchema>;
export async function updatePaymentDateAction(
input: UpdatePaymentDateInput,
): Promise<ActionResult> {
try {
const user = await getUser();
const data = updatePaymentDateSchema.parse(input);
await db.transaction(async (tx: typeof db) => {
const card = await tx.query.cartoes.findFirst({
columns: { id: true },
where: and(eq(cartoes.id, data.cartaoId), eq(cartoes.userId, user.id)),
});
if (!card) {
throw new Error("Cartão não encontrado.");
}
const invoiceNote = buildInvoicePaymentNote(card.id, data.period);
const existingPayment = await tx.query.lancamentos.findFirst({
columns: { id: true },
where: and(
eq(lancamentos.userId, user.id),
eq(lancamentos.note, invoiceNote),
),
});
if (!existingPayment) {
throw new Error("Pagamento não encontrado.");
}
await tx
.update(lancamentos)
.set({
purchaseDate: parseLocalDateString(data.paymentDate),
})
.where(eq(lancamentos.id, existingPayment.id));
});
revalidateForEntity("cartoes");
return { success: true, message: "Data de pagamento atualizada." };
} catch (error) {
if (error instanceof z.ZodError) {
return {
success: false,
error: error.issues[0]?.message ?? "Dados inválidos.",
};
}
return {
success: false,
error: error instanceof Error ? error.message : "Erro inesperado.",
};
}
}