feat(finance): refina fluxos de transacoes e pagadores

This commit is contained in:
Felipe Coutinho
2026-03-09 17:13:44 +00:00
parent 69da27276c
commit ada1377640
58 changed files with 1288 additions and 1559 deletions

32
lib/faturas/index.ts Normal file
View File

@@ -0,0 +1,32 @@
export const INVOICE_PAYMENT_STATUS = {
PENDING: "pendente",
PAID: "pago",
} as const;
export const INVOICE_STATUS_VALUES = Object.values(INVOICE_PAYMENT_STATUS);
export type InvoicePaymentStatus =
(typeof INVOICE_PAYMENT_STATUS)[keyof typeof INVOICE_PAYMENT_STATUS];
export const INVOICE_STATUS_LABEL: Record<InvoicePaymentStatus, string> = {
[INVOICE_PAYMENT_STATUS.PENDING]: "Em aberto",
[INVOICE_PAYMENT_STATUS.PAID]: "Pago",
};
export const INVOICE_STATUS_BADGE_VARIANT: Record<
InvoicePaymentStatus,
"default" | "secondary" | "success" | "info"
> = {
[INVOICE_PAYMENT_STATUS.PENDING]: "info",
[INVOICE_PAYMENT_STATUS.PAID]: "success",
};
export const INVOICE_STATUS_DESCRIPTION: Record<InvoicePaymentStatus, string> =
{
[INVOICE_PAYMENT_STATUS.PENDING]:
"Esta fatura ainda não foi quitada. Você pode realizar o pagamento assim que revisar os lançamentos.",
[INVOICE_PAYMENT_STATUS.PAID]:
"Esta fatura está quitada. Caso tenha sido um engano, é possível desfazer o pagamento.",
};
export const PERIOD_FORMAT_REGEX = /^\d{4}-\d{2}$/;

View File

@@ -1,3 +1,5 @@
import { displayPeriod, periodToDate } from "@/lib/utils/period";
/**
* Calcula a data da última parcela baseado no período da parcela atual
* @param currentPeriod - Período da parcela atual no formato YYYY-MM (ex: "2025-11")
@@ -10,18 +12,13 @@ export function calculateLastInstallmentDate(
currentInstallment: number,
totalInstallments: number,
): Date {
// Parse do período atual (formato: "YYYY-MM")
const [yearStr, monthStr] = currentPeriod.split("-");
const year = Number.parseInt(yearStr ?? "", 10);
const monthIndex = Number.parseInt(monthStr ?? "", 10) - 1; // 0-indexed
if (Number.isNaN(year) || Number.isNaN(monthIndex)) {
let currentDate: Date;
try {
currentDate = periodToDate(currentPeriod);
} catch {
return new Date();
}
// Cria data do período atual (parcela atual)
const currentDate = new Date(year, monthIndex, 1);
// Calcula quantas parcelas faltam (incluindo a atual)
// Ex: parcela 2 de 6 -> restam 5 parcelas (2, 3, 4, 5, 6)
const remainingInstallments = totalInstallments - currentInstallment + 1;
@@ -41,15 +38,9 @@ export function calculateLastInstallmentDate(
* Exemplo: "Março de 2026"
*/
export function formatLastInstallmentDate(date: Date): string {
const formatter = new Intl.DateTimeFormat("pt-BR", {
month: "long",
year: "numeric",
timeZone: "UTC",
});
const formatted = formatter.format(date);
// Capitaliza a primeira letra
return formatted.charAt(0).toUpperCase() + formatted.slice(1);
return displayPeriod(
`${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`,
);
}
/**
@@ -71,6 +62,9 @@ export function formatPurchaseDate(date: Date): string {
* Formata o texto da parcela atual
* Exemplo: "1 de 6"
*/
export function formatCurrentInstallment(current: number, total: number): string {
export function formatCurrentInstallment(
current: number,
total: number,
): string {
return `${current} de ${total}`;
}

View File

@@ -1,6 +1,14 @@
/**
* Formatting helpers for displaying lancamento data
*/
import {
currencyFormatter,
formatCurrency as formatCurrencyValue,
} from "@/lib/utils/currency";
import { formatDateOnly } from "@/lib/utils/date";
import { formatMonthYearLabel } from "@/lib/utils/period";
export { currencyFormatter };
/**
* Capitalizes the first letter of a string
@@ -11,14 +19,6 @@ function capitalize(value: string): string {
: value;
}
/**
* Currency formatter for pt-BR locale (BRL)
*/
export const currencyFormatter = new Intl.NumberFormat("pt-BR", {
style: "currency",
currency: "BRL",
});
/**
* Date formatter for pt-BR locale (dd/mm/yyyy)
*/
@@ -44,9 +44,13 @@ export const monthFormatter = new Intl.DateTimeFormat("pt-BR", {
*/
export function formatDate(value?: string | null): string {
if (!value) return "—";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "—";
return dateFormatter.format(date);
return (
formatDateOnly(value, {
day: "2-digit",
month: "2-digit",
year: "numeric",
}) ?? "—"
);
}
/**
@@ -57,10 +61,11 @@ export function formatDate(value?: string | null): string {
*/
export function formatPeriod(value?: string | null): string {
if (!value) return "—";
const [year, month] = value.split("-").map(Number);
if (!year || !month) return value;
const date = new Date(year, month - 1, 1);
return capitalize(monthFormatter.format(date));
try {
return formatMonthYearLabel(value);
} catch {
return value;
}
}
/**
@@ -97,5 +102,5 @@ export function getTransactionBadgeVariant(
* @example formatCurrency(1234.56) => "R$ 1.234,56"
*/
export function formatCurrency(value: number): string {
return currencyFormatter.format(value);
return formatCurrencyValue(value);
}

View File

@@ -19,6 +19,7 @@ import {
PAGADOR_ROLE_ADMIN,
PAGADOR_ROLE_TERCEIRO,
} from "@/lib/pagadores/constants";
import { toDateOnlyString } from "@/lib/utils/date";
type PagadorRow = typeof pagadores.$inferSelect;
type ContaRow = typeof contas.$inferSelect;
@@ -185,12 +186,10 @@ export const fetchLancamentoFilterSources = async (userId: string) => {
where: eq(pagadores.userId, userId),
}),
db.query.contas.findMany({
where: (contas, { eq, and }) =>
and(eq(contas.userId, userId), eq(contas.status, "Ativa")),
where: and(eq(contas.userId, userId), eq(contas.status, "Ativa")),
}),
db.query.cartoes.findMany({
where: (cartoes, { eq, and }) =>
and(eq(cartoes.userId, userId), eq(cartoes.status, "Ativo")),
where: and(eq(cartoes.userId, userId), eq(cartoes.status, "Ativo")),
}),
db.query.categorias.findMany({
where: eq(categorias.userId, userId),
@@ -405,7 +404,7 @@ export const mapLancamentosData = (rows: LancamentoRowWithRelations[]) =>
id: item.id,
userId: item.userId,
name: item.name,
purchaseDate: item.purchaseDate?.toISOString() ?? new Date().toISOString(),
purchaseDate: toDateOnlyString(item.purchaseDate) ?? "",
period: item.period ?? "",
transactionType: item.transactionType,
amount: Number(item.amount ?? 0),

View File

@@ -14,6 +14,13 @@ import {
import { cartoes, lancamentos } from "@/db/schema";
import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/lib/contas/constants";
import { db } from "@/lib/db";
import { toDateOnlyString } from "@/lib/utils/date";
import { safeToNumber as toNumber } from "@/lib/utils/number";
import {
addMonthsToPeriod,
buildPeriodRange,
formatCompactPeriodLabel,
} from "@/lib/utils/period";
const RECEITA = "Receita";
const DESPESA = "Despesa";
@@ -65,76 +72,6 @@ export type PagadorPaymentStatusData = {
totalAmount: number;
};
const toISODate = (value: Date | string | null | undefined): string | null => {
if (!value) return null;
if (value instanceof Date) return value.toISOString().slice(0, 10);
return typeof value === "string" ? value : null;
};
const toNumber = (value: string | number | bigint | null) => {
if (typeof value === "number") {
return value;
}
if (typeof value === "bigint") {
return Number(value);
}
if (!value) {
return 0;
}
const parsed = Number(value);
return Number.isNaN(parsed) ? 0 : parsed;
};
const formatPeriod = (year: number, month: number) =>
`${year}-${String(month).padStart(2, "0")}`;
const normalizePeriod = (period: string) => {
const [yearStr, monthStr] = period.split("-");
const year = Number.parseInt(yearStr ?? "", 10);
const month = Number.parseInt(monthStr ?? "", 10);
if (Number.isNaN(year) || Number.isNaN(month)) {
throw new Error(`Período inválido: ${period}`);
}
return { year, month };
};
const buildPeriodWindow = (period: string, months: number) => {
const { year, month } = normalizePeriod(period);
const items: string[] = [];
let currentYear = year;
let currentMonth = month;
for (let i = 0; i < months; i += 1) {
items.unshift(formatPeriod(currentYear, currentMonth));
currentMonth -= 1;
if (currentMonth < 1) {
currentMonth = 12;
currentYear -= 1;
}
}
return items;
};
const formatPeriodLabel = (period: string) => {
try {
const { year, month } = normalizePeriod(period);
const formatter = new Intl.DateTimeFormat("pt-BR", {
month: "short",
});
const date = new Date(year, month - 1, 1);
const rawLabel = formatter.format(date).replace(".", "");
const label =
rawLabel.length > 0
? rawLabel.charAt(0).toUpperCase().concat(rawLabel.slice(1))
: rawLabel;
const suffix = String(year).slice(-2);
return `${label}/${suffix}`;
} catch {
return period;
}
};
const excludeAutoInvoiceEntries = () =>
or(
isNull(lancamentos.note),
@@ -206,9 +143,10 @@ export async function fetchPagadorHistory({
period,
months = 6,
}: BaseFilters & { months?: number }): Promise<PagadorHistoryPoint[]> {
const window = buildPeriodWindow(period, months);
const start = window[0];
const end = window[window.length - 1];
const startPeriod = addMonthsToPeriod(period, -(Math.max(months, 1) - 1));
const windowPeriods = buildPeriodRange(startPeriod, period);
const start = windowPeriods[0];
const end = windowPeriods[windowPeriods.length - 1];
const rows = await db
.select({
@@ -233,7 +171,7 @@ export async function fetchPagadorHistory({
{ receitas: number; despesas: number }
>();
for (const key of window) {
for (const key of windowPeriods) {
totalsByPeriod.set(key, { receitas: 0, despesas: 0 });
}
@@ -250,9 +188,9 @@ export async function fetchPagadorHistory({
}
}
return window.map((key) => ({
return windowPeriods.map((key) => ({
period: key,
label: formatPeriodLabel(key),
label: formatCompactPeriodLabel(key),
receitas: totalsByPeriod.get(key)?.receitas ?? 0,
despesas: totalsByPeriod.get(key)?.despesas ?? 0,
}));
@@ -283,20 +221,22 @@ export async function fetchPagadorCardUsage({
)
.groupBy(lancamentos.cartaoId, cartoes.name, cartoes.logo);
return rows
.filter((row) => Boolean(row.cartaoId))
.map((row) => {
if (!row.cartaoId) {
throw new Error("cartaoId should not be null after filter");
}
return {
id: row.cartaoId,
name: row.cardName ?? "Cartão",
logo: row.cardLogo ?? null,
amount: Math.abs(toNumber(row.totalAmount)),
};
})
.sort((a, b) => b.amount - a.amount);
const items: PagadorCardUsageItem[] = [];
for (const row of rows) {
if (!row.cartaoId) {
continue;
}
items.push({
id: row.cartaoId,
name: row.cardName ?? "Cartão",
logo: row.cardLogo ?? null,
amount: Math.abs(toNumber(row.totalAmount)),
});
}
return items.sort((a, b) => b.amount - a.amount);
}
export async function fetchPagadorBoletoStats({
@@ -374,14 +314,20 @@ export async function fetchPagadorBoletoItems({
)
.orderBy(asc(lancamentos.dueDate));
return rows.map((row) => ({
id: row.id,
name: row.name,
amount: Math.abs(toNumber(row.amount)),
dueDate: toISODate(row.dueDate),
boletoPaymentDate: toISODate(row.boletoPaymentDate),
isSettled: Boolean(row.isSettled),
}));
const items: PagadorBoletoItem[] = [];
for (const row of rows) {
items.push({
id: row.id,
name: row.name,
amount: Math.abs(toNumber(row.amount)),
dueDate: toDateOnlyString(row.dueDate),
boletoPaymentDate: toDateOnlyString(row.boletoPaymentDate),
isSettled: Boolean(row.isSettled),
});
}
return items;
}
export async function fetchPagadorPaymentStatus({

View File

@@ -3,6 +3,8 @@ import { Resend } from "resend";
import { pagadores } from "@/db/schema";
import { db } from "@/lib/db";
import { getResendFromEmail } from "@/lib/email/resend";
import { formatCurrency } from "@/lib/utils/currency";
import { formatDateTime } from "@/lib/utils/date";
type ActionType = "created" | "deleted";
@@ -24,20 +26,21 @@ export type PagadorNotificationRequest = {
entriesByPagador: Map<string, NotificationEntry[]>;
};
const formatCurrency = (value: number) =>
value.toLocaleString("pt-BR", {
style: "currency",
currency: "BRL",
maximumFractionDigits: 2,
});
type PagadorNotificationRecipient = {
id: string;
name: string | null;
email: string | null;
isAutoSend: boolean | null;
};
const formatDate = (value: Date | null) => {
if (!value) return "—";
return value.toLocaleDateString("pt-BR", {
day: "2-digit",
month: "short",
year: "numeric",
});
return (
formatDateTime(value, {
day: "2-digit",
month: "short",
year: "numeric",
}) ?? "—"
);
};
const buildHtmlBody = ({
@@ -133,9 +136,9 @@ export async function sendPagadorAutoEmails({
return;
}
const pagadorRows = await db.query.pagadores.findMany({
const pagadorRows = (await db.query.pagadores.findMany({
where: inArray(pagadores.id, pagadorIds),
});
})) as PagadorNotificationRecipient[];
if (pagadorRows.length === 0) {
return;
@@ -146,7 +149,7 @@ export async function sendPagadorAutoEmails({
action === "created" ? "Novo lançamento" : "Lançamento removido";
const results = await Promise.allSettled(
pagadorRows.map(async (pagador) => {
pagadorRows.map(async (pagador: PagadorNotificationRecipient) => {
if (!pagador.email || !pagador.isAutoSend) {
return;
}
@@ -172,7 +175,7 @@ export async function sendPagadorAutoEmails({
);
// Log any failed email sends
results.forEach((result, index) => {
results.forEach((result: PromiseSettledResult<void>, index: number) => {
if (result.status === "rejected") {
const pagador = pagadorRows[index];
console.error(