mirror of
https://github.com/felipegcoutinho/openmonetis.git
synced 2026-05-09 02:51:46 +00:00
Refatoração estrutural sem mudanças funcionais. Saldo líquido: −428 linhas. Removido: - 14 funções/constantes mortas verificadas via grep no repo todo: validateCategoriaOwnership, getInstallmentAnticipationsAction, getAnticipationDetailsAction, formatDecimalForDb, currencyFormatterNoCents, optionalDecimalSchema, formatMonthLabel, getGoalProgressStatusColorClass, MONTH_PERIOD_PARAM, calculateRemainingInstallments, e 5 funções fetch* não usadas em inbox/queries.ts. - 1 tipo morto (ImportRow) + 2 órfãos consequentes (InstallmentAnticipationWithRelations, GoalProgressStatus convertido em interno). - ~30 export keywords desnecessários (símbolos usados apenas no próprio arquivo). - Re-exports mortos em barrels: EstablishmentLogoPicker, CategoryReportSkeleton, WidgetSkeleton, toNameKey. - Arquivo features/reports/types.ts (barrel inteiro era órfão). Padronizado (PT-BR→EN em identificadores expostos): - 4 constantes globais (LANCAMENTOS_* → TRANSACTIONS_*). - 12 tipos/interfaces (Lancamento*/Pagador*/Estabelecimento* → equivalentes EN). - 13 funções/components exportados (fetchPagador*, EstabelecimentoInput, PagadorInfoCard, etc.). - 5 props cross-file (preLancamentosCount → inboxPendingCount, pagadorAvatarUrl → payerAvatarUrl, etc.). - Mantidas em PT-BR conforme exceção do CLAUDE.md: variáveis locais (pagador, categoria, lancamento), accessor key pagadorName (persistida em preferências), strings de UI. Reorganizado: - transactions/: 14 helpers soltos na raiz movidos para lib/; barrel actions.ts reduzido de 76 linhas de wrappers para 14 linhas de re-exports puros; anticipation-actions.ts movido para actions/anticipation.ts. - dashboard/: 8 helpers soltos consolidados em dashboard/lib/. - reports/: 5 query files na raiz consolidados em reports/lib/. - payers/: detail-actions.ts (21KB) e detail-queries.ts movidos para payers/lib/. - shared/components/: 9 dos 16 componentes soltos agrupados em brand/, widgets/, feedback/. - shared/lib/fetch-json.ts movido para shared/utils/fetch-json.ts. Validação: pnpm exec tsc --noEmit (0 erros), biome check (0 issues), knip (sem unused). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
445 lines
11 KiB
TypeScript
445 lines
11 KiB
TypeScript
import { and, eq, ilike, inArray, isNotNull, sql } from "drizzle-orm";
|
|
import {
|
|
cards,
|
|
financialAccounts,
|
|
invoices,
|
|
payers,
|
|
transactions,
|
|
} from "@/db/schema";
|
|
import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/shared/lib/accounts/constants";
|
|
import { db } from "@/shared/lib/db";
|
|
import {
|
|
INVOICE_PAYMENT_STATUS,
|
|
INVOICE_STATUS_VALUES,
|
|
type InvoicePaymentStatus,
|
|
} from "@/shared/lib/invoices";
|
|
import {
|
|
buildDateOnlyStringFromPeriodDay,
|
|
compareDateOnly,
|
|
getBusinessDateString,
|
|
isDateOnlyPast,
|
|
toDateOnlyString,
|
|
} from "@/shared/utils/date";
|
|
import { calculatePercentageChange } from "@/shared/utils/math";
|
|
import { safeToNumber as toNumber } from "@/shared/utils/number";
|
|
import { getPreviousPeriod } from "@/shared/utils/period";
|
|
|
|
type RawDashboardInvoice = {
|
|
invoiceId: string | null;
|
|
cardId: string;
|
|
cardName: string;
|
|
cardBrand: string | null;
|
|
cardStatus: string | null;
|
|
logo: string | null;
|
|
dueDay: string;
|
|
period: string | null;
|
|
paymentStatus: string | null;
|
|
totalAmount: string | number | null;
|
|
transactionCount: string | number | null;
|
|
invoiceCreatedAt: Date | null;
|
|
cardAccountId: string | null;
|
|
};
|
|
|
|
export type InvoicePaymentAccountOption = {
|
|
value: string;
|
|
label: string;
|
|
logo: string | null;
|
|
};
|
|
|
|
type RawInvoiceBreakdownRow = {
|
|
cardId: string | null;
|
|
period: string | null;
|
|
payerId: string | null;
|
|
pagadorName: string | null;
|
|
pagadorAvatar: string | null;
|
|
amount: number | string | null;
|
|
};
|
|
|
|
type InvoicePagadorBreakdown = {
|
|
payerId: string | null;
|
|
pagadorName: string;
|
|
pagadorAvatar: string | null;
|
|
amount: number;
|
|
percentageChange: number | null;
|
|
};
|
|
|
|
export type DashboardInvoice = {
|
|
id: string;
|
|
cardId: string;
|
|
cardName: string;
|
|
cardBrand: string | null;
|
|
cardStatus: string | null;
|
|
logo: string | null;
|
|
dueDay: string;
|
|
period: string;
|
|
paymentStatus: InvoicePaymentStatus;
|
|
totalAmount: number;
|
|
paidAt: string | null;
|
|
pagadorBreakdown: InvoicePagadorBreakdown[];
|
|
defaultPaymentAccountId: string | null;
|
|
};
|
|
|
|
type DashboardInvoicesSnapshot = {
|
|
invoices: DashboardInvoice[];
|
|
totalPending: number;
|
|
paymentAccountOptions: InvoicePaymentAccountOption[];
|
|
};
|
|
|
|
const isInvoiceStatus = (value: unknown): value is InvoicePaymentStatus =>
|
|
typeof value === "string" &&
|
|
(INVOICE_STATUS_VALUES as string[]).includes(value);
|
|
|
|
const buildFallbackId = (cardId: string, period: string) =>
|
|
`${cardId}:${period}`;
|
|
|
|
const compareDateOnlyAscWithNullsLast = (
|
|
left: string | null,
|
|
right: string | null,
|
|
) => {
|
|
if (!left && !right) return 0;
|
|
if (!left) return 1;
|
|
if (!right) return -1;
|
|
return compareDateOnly(left, right);
|
|
};
|
|
|
|
const compareDateOnlyDescWithNullsLast = (
|
|
left: string | null,
|
|
right: string | null,
|
|
) => {
|
|
if (!left && !right) return 0;
|
|
if (!left) return 1;
|
|
if (!right) return -1;
|
|
return compareDateOnly(right, left);
|
|
};
|
|
|
|
export async function fetchDashboardInvoices(
|
|
userId: string,
|
|
period: string,
|
|
): Promise<DashboardInvoicesSnapshot> {
|
|
const today = getBusinessDateString();
|
|
const previousPeriod = getPreviousPeriod(period);
|
|
const paymentRows = await db
|
|
.select({
|
|
note: transactions.note,
|
|
purchaseDate: transactions.purchaseDate,
|
|
createdAt: transactions.createdAt,
|
|
})
|
|
.from(transactions)
|
|
.where(
|
|
and(
|
|
eq(transactions.userId, userId),
|
|
ilike(transactions.note, `${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`),
|
|
),
|
|
);
|
|
|
|
const paymentMap = new Map<string, string>();
|
|
for (const row of paymentRows) {
|
|
const note = row.note;
|
|
if (!note?.startsWith(ACCOUNT_AUTO_INVOICE_NOTE_PREFIX)) {
|
|
continue;
|
|
}
|
|
const parts = note.split(":");
|
|
if (parts.length < 3) {
|
|
continue;
|
|
}
|
|
const cardIdPart = parts[1];
|
|
const periodPart = parts[2];
|
|
if (!cardIdPart || !periodPart) {
|
|
continue;
|
|
}
|
|
const key = `${cardIdPart}:${periodPart}`;
|
|
const resolvedDate =
|
|
row.purchaseDate instanceof Date &&
|
|
!Number.isNaN(row.purchaseDate.valueOf())
|
|
? row.purchaseDate
|
|
: row.createdAt;
|
|
const isoDate = toDateOnlyString(resolvedDate);
|
|
if (!isoDate) {
|
|
continue;
|
|
}
|
|
const existing = paymentMap.get(key);
|
|
if (!existing || existing < isoDate) {
|
|
paymentMap.set(key, isoDate);
|
|
}
|
|
}
|
|
|
|
const [rows, breakdownRows, accountRows] = (await Promise.all([
|
|
db
|
|
.select({
|
|
invoiceId: invoices.id,
|
|
cardId: cards.id,
|
|
cardName: cards.name,
|
|
logo: cards.logo,
|
|
dueDay: cards.dueDay,
|
|
period: invoices.period,
|
|
paymentStatus: invoices.paymentStatus,
|
|
invoiceCreatedAt: invoices.createdAt,
|
|
cardAccountId: cards.accountId,
|
|
totalAmount: sql<number | null>`
|
|
COALESCE(SUM(${transactions.amount}), 0)
|
|
`,
|
|
transactionCount: sql<number | null>`COUNT(${transactions.id})`,
|
|
})
|
|
.from(cards)
|
|
.leftJoin(
|
|
invoices,
|
|
and(
|
|
eq(invoices.cardId, cards.id),
|
|
eq(invoices.userId, userId),
|
|
eq(invoices.period, period),
|
|
),
|
|
)
|
|
.leftJoin(
|
|
transactions,
|
|
and(
|
|
eq(transactions.cardId, cards.id),
|
|
eq(transactions.userId, userId),
|
|
eq(transactions.period, period),
|
|
),
|
|
)
|
|
.where(eq(cards.userId, userId))
|
|
.groupBy(
|
|
invoices.id,
|
|
cards.id,
|
|
cards.name,
|
|
cards.brand,
|
|
cards.status,
|
|
cards.logo,
|
|
cards.dueDay,
|
|
cards.accountId,
|
|
invoices.period,
|
|
invoices.paymentStatus,
|
|
),
|
|
db
|
|
.select({
|
|
cardId: transactions.cardId,
|
|
period: transactions.period,
|
|
payerId: transactions.payerId,
|
|
pagadorName: payers.name,
|
|
pagadorAvatar: payers.avatarUrl,
|
|
amount: sql<number>`coalesce(sum(${transactions.amount}), 0)`,
|
|
})
|
|
.from(transactions)
|
|
.leftJoin(payers, eq(transactions.payerId, payers.id))
|
|
.where(
|
|
and(
|
|
eq(transactions.userId, userId),
|
|
inArray(transactions.period, [period, previousPeriod]),
|
|
isNotNull(transactions.cardId),
|
|
),
|
|
)
|
|
.groupBy(
|
|
transactions.cardId,
|
|
transactions.period,
|
|
transactions.payerId,
|
|
payers.name,
|
|
payers.avatarUrl,
|
|
),
|
|
db
|
|
.select({
|
|
id: financialAccounts.id,
|
|
name: financialAccounts.name,
|
|
logo: financialAccounts.logo,
|
|
})
|
|
.from(financialAccounts)
|
|
.where(eq(financialAccounts.userId, userId)),
|
|
])) as [
|
|
RawDashboardInvoice[],
|
|
RawInvoiceBreakdownRow[],
|
|
{ id: string; name: string; logo: string | null }[],
|
|
];
|
|
|
|
const paymentAccountOptions: InvoicePaymentAccountOption[] = accountRows
|
|
.map((account) => ({
|
|
value: account.id,
|
|
label: account.name,
|
|
logo: account.logo,
|
|
}))
|
|
.sort((a, b) =>
|
|
a.label.localeCompare(b.label, "pt-BR", { sensitivity: "base" }),
|
|
);
|
|
|
|
const groupedBreakdown = new Map<
|
|
string,
|
|
{
|
|
cardId: string;
|
|
payerId: string | null;
|
|
pagadorName: string;
|
|
pagadorAvatar: string | null;
|
|
currentAmount: number;
|
|
previousAmount: number;
|
|
}
|
|
>();
|
|
|
|
for (const row of breakdownRows) {
|
|
if (!row.cardId) {
|
|
continue;
|
|
}
|
|
|
|
const resolvedPeriod = row.period ?? period;
|
|
const amount = Math.abs(toNumber(row.amount));
|
|
if (amount <= 0) {
|
|
continue;
|
|
}
|
|
|
|
const payerId = row.payerId ?? null;
|
|
const pagadorName = row.pagadorName?.trim() || "Sem pessoa";
|
|
const pagadorAvatar = row.pagadorAvatar ?? null;
|
|
const payerKey = payerId ?? "__without-payer__";
|
|
const key = `${row.cardId}:${payerKey}`;
|
|
const current = groupedBreakdown.get(key) ?? {
|
|
cardId: row.cardId,
|
|
payerId,
|
|
pagadorName,
|
|
pagadorAvatar,
|
|
currentAmount: 0,
|
|
previousAmount: 0,
|
|
};
|
|
|
|
if (resolvedPeriod === period) {
|
|
current.payerId = payerId;
|
|
current.pagadorName = pagadorName;
|
|
current.pagadorAvatar = pagadorAvatar;
|
|
current.currentAmount = amount;
|
|
}
|
|
|
|
if (resolvedPeriod === previousPeriod) {
|
|
current.previousAmount = amount;
|
|
}
|
|
|
|
groupedBreakdown.set(key, current);
|
|
}
|
|
|
|
const breakdownMap = new Map<string, InvoicePagadorBreakdown[]>();
|
|
for (const share of groupedBreakdown.values()) {
|
|
if (share.currentAmount <= 0) {
|
|
continue;
|
|
}
|
|
|
|
const key = `${share.cardId}:${period}`;
|
|
const current = breakdownMap.get(key) ?? [];
|
|
current.push({
|
|
payerId: share.payerId,
|
|
pagadorName: share.pagadorName,
|
|
pagadorAvatar: share.pagadorAvatar,
|
|
amount: share.currentAmount,
|
|
percentageChange: calculatePercentageChange(
|
|
share.currentAmount,
|
|
share.previousAmount,
|
|
),
|
|
});
|
|
breakdownMap.set(key, current);
|
|
}
|
|
|
|
const invoiceList: DashboardInvoice[] = [];
|
|
|
|
for (const row of rows) {
|
|
if (!row) {
|
|
continue;
|
|
}
|
|
|
|
const totalAmount = toNumber(row.totalAmount);
|
|
const transactionCount = toNumber(row.transactionCount);
|
|
const paymentStatus = isInvoiceStatus(row.paymentStatus)
|
|
? row.paymentStatus
|
|
: INVOICE_PAYMENT_STATUS.PENDING;
|
|
|
|
const shouldInclude =
|
|
transactionCount > 0 ||
|
|
Math.abs(totalAmount) > 0 ||
|
|
row.invoiceId !== null;
|
|
|
|
if (!shouldInclude) {
|
|
continue;
|
|
}
|
|
|
|
const resolvedPeriod = row.period ?? period;
|
|
const paymentKey = `${row.cardId}:${resolvedPeriod}`;
|
|
const paidAt =
|
|
paymentStatus === INVOICE_PAYMENT_STATUS.PAID
|
|
? (paymentMap.get(paymentKey) ?? toDateOnlyString(row.invoiceCreatedAt))
|
|
: null;
|
|
|
|
invoiceList.push({
|
|
id: row.invoiceId ?? buildFallbackId(row.cardId, period),
|
|
cardId: row.cardId,
|
|
cardName: row.cardName,
|
|
cardBrand: row.cardBrand,
|
|
cardStatus: row.cardStatus,
|
|
logo: row.logo,
|
|
dueDay: row.dueDay,
|
|
period: resolvedPeriod,
|
|
paymentStatus,
|
|
totalAmount,
|
|
paidAt,
|
|
pagadorBreakdown: (
|
|
breakdownMap.get(`${row.cardId}:${resolvedPeriod}`) ?? []
|
|
).sort((a, b) => b.amount - a.amount),
|
|
defaultPaymentAccountId: row.cardAccountId ?? null,
|
|
});
|
|
}
|
|
|
|
invoiceList.sort((a, b) => {
|
|
const aIsPending = a.paymentStatus === INVOICE_PAYMENT_STATUS.PENDING;
|
|
const bIsPending = b.paymentStatus === INVOICE_PAYMENT_STATUS.PENDING;
|
|
if (aIsPending !== bIsPending) {
|
|
return aIsPending ? -1 : 1;
|
|
}
|
|
|
|
if (aIsPending && bIsPending) {
|
|
const aDueDate = buildDateOnlyStringFromPeriodDay(a.period, a.dueDay);
|
|
const bDueDate = buildDateOnlyStringFromPeriodDay(b.period, b.dueDay);
|
|
const aIsOverdue = aDueDate ? isDateOnlyPast(aDueDate, today) : false;
|
|
const bIsOverdue = bDueDate ? isDateOnlyPast(bDueDate, today) : false;
|
|
|
|
if (aIsOverdue !== bIsOverdue) {
|
|
return aIsOverdue ? -1 : 1;
|
|
}
|
|
|
|
const dueDateDiff = compareDateOnlyAscWithNullsLast(aDueDate, bDueDate);
|
|
if (dueDateDiff !== 0) {
|
|
return dueDateDiff;
|
|
}
|
|
|
|
const amountDiff = Math.abs(b.totalAmount) - Math.abs(a.totalAmount);
|
|
if (amountDiff !== 0) {
|
|
return amountDiff;
|
|
}
|
|
}
|
|
|
|
if (!aIsPending && !bIsPending) {
|
|
const paidAtDiff = compareDateOnlyDescWithNullsLast(a.paidAt, b.paidAt);
|
|
if (paidAtDiff !== 0) {
|
|
return paidAtDiff;
|
|
}
|
|
|
|
const amountDiff = Math.abs(b.totalAmount) - Math.abs(a.totalAmount);
|
|
if (amountDiff !== 0) {
|
|
return amountDiff;
|
|
}
|
|
}
|
|
|
|
const nameDiff = a.cardName.localeCompare(b.cardName, "pt-BR", {
|
|
sensitivity: "base",
|
|
});
|
|
if (nameDiff !== 0) {
|
|
return nameDiff;
|
|
}
|
|
|
|
return a.id.localeCompare(b.id);
|
|
});
|
|
|
|
const totalPending = invoiceList.reduce((total, invoice) => {
|
|
if (invoice.paymentStatus !== INVOICE_PAYMENT_STATUS.PENDING) {
|
|
return total;
|
|
}
|
|
return total + invoice.totalAmount;
|
|
}, 0);
|
|
|
|
return {
|
|
invoices: invoiceList,
|
|
totalPending,
|
|
paymentAccountOptions,
|
|
};
|
|
}
|