mirror of
https://github.com/felipegcoutinho/openmonetis.git
synced 2026-05-09 11:01:45 +00:00
feat(finance): refina fluxos de transacoes e pagadores
This commit is contained in:
32
lib/faturas/index.ts
Normal file
32
lib/faturas/index.ts
Normal 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}$/;
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user