From b1c8e84f80d6c293119f70a961ef49364d88d19f Mon Sep 17 00:00:00 2001 From: Felipe de Sousa Date: Sat, 18 Jul 2026 12:31:28 -0300 Subject: [PATCH 01/20] fix: considering savings as a expense in balance --- lib/finance/transactions.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/finance/transactions.ts b/lib/finance/transactions.ts index eb1cb44c..124966cf 100644 --- a/lib/finance/transactions.ts +++ b/lib/finance/transactions.ts @@ -3085,6 +3085,7 @@ export async function getDashboardData( const totalIncome = sumTransactionsByType(transactions, "income"); const totalExpenses = sumTransactionsByType(transactions, "expense"); + const totalMonthSavings = sumTransactionsByType(transactions, "saving"); const previousTotalIncome = sumTransactionsByType( previousTransactions, "income", @@ -3193,7 +3194,7 @@ export async function getDashboardData( })(), paymentMethods, summaryData: { - currentBalance: totalIncome - totalExpenses, + currentBalance: totalIncome - totalExpenses - totalMonthSavings, predictedExpenses, totalExpenses, totalIncome, From 00c11bf0a093d03c74f3c38aba5c88589b0994f0 Mon Sep 17 00:00:00 2001 From: Felipe de Sousa Date: Sat, 18 Jul 2026 12:42:28 -0300 Subject: [PATCH 02/20] fix: transactions screen shows only month transactions --- .github/workflows/ci.yml | 4 + components/dashboard/transaction-form.tsx | 3 +- components/dashboard/transactions-screen.tsx | 2522 ++++++++++------- lib/finance/category-colors.ts | 39 - lib/finance/transactions.ts | 802 ++---- lib/i18n-currency.ts | 26 - package.json | 5 + pnpm-lock.yaml | 543 +++- tests/auth/callback-route.test.ts | 58 + .../dashboard/add-transaction-dialog.test.tsx | 149 + tests/dashboard/new-category-dialog.test.tsx | 482 ++++ .../new-payment-method-dialog.test.tsx | 352 +++ tests/dashboard/transaction-form.test.tsx | 567 ++++ .../transactions-screen-details.test.tsx | 1392 +++++++++ .../transactions-screen-invoices.test.ts | 590 ++++ .../transactions-screen-list.test.tsx | 434 +++ tests/dashboard/transactions-screen.test.ts | 48 + tests/goals-actions.test.ts | 81 + tests/lib/animations/fluid-ribbon.test.ts | 97 + tests/lib/auth/profile.test.ts | 226 ++ tests/lib/finance/transactions-crud.test.ts | 1170 ++++++++ .../finance/transactions-mutations.test.ts | 955 +++++++ .../lib/finance/transactions-queries.test.ts | 754 +++++ .../lib/finance/transactions-reports.test.ts | 994 +++++++ tests/lib/i18n-currency.test.ts | 23 - tests/lib/supabase/client.test.ts | 28 + tests/lib/supabase/proxy.test.ts | 47 + tests/lib/supabase/server.test.ts | 67 + tests/mocks/server-only.ts | 1 + tests/proxy-middleware.test.ts | 32 + tests/setup.ts | 8 + tests/transactions-actions.test.ts | 397 ++- tests/ui/number-counter.test.ts | 26 - vitest.config.ts | 18 +- 34 files changed, 11216 insertions(+), 1724 deletions(-) delete mode 100644 lib/finance/category-colors.ts delete mode 100644 lib/i18n-currency.ts create mode 100644 tests/auth/callback-route.test.ts create mode 100644 tests/dashboard/add-transaction-dialog.test.tsx create mode 100644 tests/dashboard/new-category-dialog.test.tsx create mode 100644 tests/dashboard/new-payment-method-dialog.test.tsx create mode 100644 tests/dashboard/transaction-form.test.tsx create mode 100644 tests/dashboard/transactions-screen-details.test.tsx create mode 100644 tests/dashboard/transactions-screen-invoices.test.ts create mode 100644 tests/dashboard/transactions-screen-list.test.tsx create mode 100644 tests/dashboard/transactions-screen.test.ts create mode 100644 tests/goals-actions.test.ts create mode 100644 tests/lib/animations/fluid-ribbon.test.ts create mode 100644 tests/lib/auth/profile.test.ts create mode 100644 tests/lib/finance/transactions-crud.test.ts create mode 100644 tests/lib/finance/transactions-mutations.test.ts create mode 100644 tests/lib/finance/transactions-queries.test.ts create mode 100644 tests/lib/finance/transactions-reports.test.ts delete mode 100644 tests/lib/i18n-currency.test.ts create mode 100644 tests/lib/supabase/client.test.ts create mode 100644 tests/lib/supabase/proxy.test.ts create mode 100644 tests/lib/supabase/server.test.ts create mode 100644 tests/mocks/server-only.ts create mode 100644 tests/proxy-middleware.test.ts create mode 100644 tests/setup.ts delete mode 100644 tests/ui/number-counter.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0c9eea9f..3b503c1b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,6 +1,10 @@ name: Dragg CI on: + pull_request: + branches: + - main + - develop workflow_run: workflows: - CodeQL diff --git a/components/dashboard/transaction-form.tsx b/components/dashboard/transaction-form.tsx index 2299f4ca..91da38e3 100644 --- a/components/dashboard/transaction-form.tsx +++ b/components/dashboard/transaction-form.tsx @@ -404,7 +404,8 @@ export function TransactionForm({ setFormData({ ...formData, amount })} diff --git a/components/dashboard/transactions-screen.tsx b/components/dashboard/transactions-screen.tsx index bd0380c8..c28e7d97 100644 --- a/components/dashboard/transactions-screen.tsx +++ b/components/dashboard/transactions-screen.tsx @@ -114,39 +114,118 @@ type ConfirmRequest = }; type TransactionsScreenProps = { - categories: TransactionFormCategory[]; - advanceInstallmentsAction: (data: AdvanceInstallmentsInput) => Promise; - createCategoryAction: (data: CreateCategoryInput) => Promise; - createPaymentMethodAction?: (data: CreatePaymentMethodInput) => Promise; - createTransactionAction: (data: NewTransactionInput) => Promise; - deleteInstallmentsAction: (data: DeleteInstallmentsInput) => Promise; - deleteSubscriptionOccurrencesAction: ( + readonly categories: TransactionFormCategory[]; + readonly advanceInstallmentsAction: ( + data: AdvanceInstallmentsInput, + ) => Promise; + readonly createCategoryAction: (data: CreateCategoryInput) => Promise; + readonly createPaymentMethodAction?: ( + data: CreatePaymentMethodInput, + ) => Promise; + readonly createTransactionAction: ( + data: NewTransactionInput, + ) => Promise; + readonly deleteInstallmentsAction: ( + data: DeleteInstallmentsInput, + ) => Promise; + readonly deleteSubscriptionOccurrencesAction: ( data: DeleteSubscriptionOccurrencesInput, ) => Promise; - deleteTransactionAction: (transactionId: string) => Promise; - monthlySummary: { + readonly deleteTransactionAction: (transactionId: string) => Promise; + readonly monthlySummary: { totalIncome: number; totalExpenses: number; totalSavings: number; }; - nextInvoiceTransactions: Transaction[]; - paymentMethods: TransactionFormPaymentMethod[]; - previewInstallmentPrepaymentAction: ( + readonly nextInvoiceTransactions: Transaction[]; + readonly paymentMethods: TransactionFormPaymentMethod[]; + readonly previewInstallmentPrepaymentAction: ( data: AdvanceInstallmentsInput, ) => Promise; - showNextInvoice: boolean; - showPrevious: boolean; - transactions: Transaction[]; - updateTransactionAction: (data: UpdateTransactionInput) => Promise; + readonly showNextInvoice: boolean; + readonly showPrevious: boolean; + readonly transactions: Transaction[]; + readonly updateTransactionAction: ( + data: UpdateTransactionInput, + ) => Promise; }; +type Translate = (key: string) => string; +type FormatCurrency = (value: number) => string; +type FormatDate = ( + date: string | Date, + options?: Intl.DateTimeFormatOptions, +) => string; +type CategoryOption = { label: string; value: string }; + function normalizeSearchValue(value: string) { return value .normalize("NFD") - .replace(/[\u0300-\u036f]/g, "") + .replace(/[̀-ͯ]/g, "") .toLowerCase(); } +function isSubscriptionTransaction(transaction: Transaction) { + return transaction.notes?.startsWith("subscription") ?? false; +} + +function getTransactionAmountColorClass( + transaction: Pick, +) { + if (transaction.type === "income") return "text-income"; + if (transaction.group === "savings") return "text-savings"; + return "text-expense"; +} + +function renderSortIcon( + sortOption: SortOption, + descValue: SortOption, + ascValue: SortOption, +) { + if (sortOption === descValue) return ; + if (sortOption === ascValue) return ; + return ; +} + +function getInvoiceTransactionTitle( + transaction: Pick, + t: Translate, +) { + const invoiceLabelKey = + transaction.invoice?.paymentMethodKey ?? transaction.paymentMethodKey; + + return invoiceLabelKey + ? `${t("transaction.creditCardInvoiceFor")} ${t(invoiceLabelKey)}` + : t("transaction.creditCardInvoice"); +} + +function buildTransactionSearchText(transaction: Transaction, t: Translate) { + const invoiceLabelKey = + transaction.invoice?.paymentMethodKey ?? transaction.paymentMethodKey; + + return [ + transaction.isCreditCardInvoice + ? getInvoiceTransactionTitle(transaction, t) + : t(transaction.descriptionKey), + t(transaction.categoryKey), + t(`data.group.${transaction.group}`), + transaction.notes ?? "", + invoiceLabelKey ? t(invoiceLabelKey) : "", + ] + .map(normalizeSearchValue) + .join(" "); +} + +export function isVisibleInSelectedMonth( + transaction: Transaction, + selectedMonth: string, +) { + return ( + !transaction.isCreditCardInvoicePurchase || + transaction.date.startsWith(selectedMonth) + ); +} + function getInitialFormState( transaction: Transaction, incomeCategoryId: string, @@ -165,6 +244,1201 @@ function getInitialFormState( }; } +function getInstallmentDeleteTitleKey(scope: InstallmentDeleteScope) { + if (scope === "all") return "transactions.installments.deleteAllTitle"; + if (scope === "this_and_following") { + return "transactions.installments.deleteThisAndFollowingTitle"; + } + return "transactions.installments.deleteOnlyThisTitle"; +} + +function getInstallmentDeleteDescriptionKey(scope: InstallmentDeleteScope) { + if (scope === "all") return "transactions.installments.deleteAllConfirm"; + if (scope === "this_and_following") { + return "transactions.installments.deleteThisAndFollowingConfirm"; + } + return "transactions.installments.deleteOnlyThisConfirm"; +} + +function getBalanceChipColors(balance: number) { + if (balance >= 0) { + return { + iconBg: "bg-emerald-500/10", + iconColor: "text-emerald-600 dark:text-emerald-400", + valueColor: "text-emerald-600 dark:text-emerald-400", + }; + } + return { + iconBg: "bg-rose-500/10", + iconColor: "text-rose-500 dark:text-rose-400", + valueColor: "text-rose-500 dark:text-rose-400", + }; +} + +type MonthlySummaryTotals = { + income: number; + expenses: number; + savings: number; + balance: number; +}; + +type SummaryChipsProps = { + readonly monthlySummary: MonthlySummaryTotals; + readonly t: Translate; + readonly formatCurrency: FormatCurrency; +}; + +function SummaryChips({ + monthlySummary, + t, + formatCurrency, +}: SummaryChipsProps) { + const chips = [ + { + icon: TrendingUp, + label: t("dashboard.summary.totalIncome"), + value: formatCurrency(monthlySummary.income), + iconBg: "bg-emerald-500/10", + iconColor: "text-emerald-600 dark:text-emerald-400", + valueColor: "text-emerald-600 dark:text-emerald-400", + }, + { + icon: TrendingDown, + label: t("dashboard.summary.totalExpenses"), + value: formatCurrency(monthlySummary.expenses), + iconBg: "bg-rose-500/10", + iconColor: "text-rose-500 dark:text-rose-400", + valueColor: "text-expense", + }, + { + icon: PiggyBank, + label: t("dashboard.summary.totalSaved"), + value: formatCurrency(monthlySummary.savings), + iconBg: "bg-violet-500/10", + iconColor: "text-violet-600 dark:text-violet-400", + valueColor: "text-savings", + }, + { + icon: Wallet, + label: t("dashboard.summary.currentBalance"), + value: formatCurrency(monthlySummary.balance), + ...getBalanceChipColors(monthlySummary.balance), + }, + ]; + + return ( +
+ {chips.map((chip) => { + const Icon = chip.icon; + return ( +
+
+ +
+
+

+ {chip.label} +

+

+ {chip.value} +

+
+
+ ); + })} +
+ ); +} + +type SortToggleButtonProps = { + readonly label: string; + readonly active: boolean; + readonly sortOption: SortOption; + readonly descValue: SortOption; + readonly ascValue: SortOption; + readonly onToggle: () => void; +}; + +function SortToggleButton({ + label, + active, + sortOption, + descValue, + ascValue, + onToggle, +}: SortToggleButtonProps) { + return ( + + ); +} + +type NextInvoiceSectionProps = { + readonly transactions: Transaction[]; + readonly t: Translate; + readonly formatCurrency: FormatCurrency; + readonly formatDate: FormatDate; + readonly onOpen: (transaction: Transaction) => void; +}; + +function NextInvoiceSection({ + transactions, + t, + formatCurrency, + formatDate, + onOpen, +}: NextInvoiceSectionProps) { + if (transactions.length === 0) return null; + + return ( +
+
+
+

+ {t("screen.transactions.nextInvoice")} +

+

+ {t("screen.transactions.nextInvoiceDescription")} +

+
+ + {transactions.length} + +
+
+
+ {transactions.map((transaction) => { + const transactionTitle = transaction.isCreditCardInvoice + ? getInvoiceTransactionTitle(transaction, t) + : t(transaction.descriptionKey); + + return ( + + ); + })} +
+
+
+ ); +} + +type TransactionRowDeleteMenuItemsProps = { + readonly transaction: Transaction; + readonly isInstallment: boolean; + readonly isSubscription: boolean; + readonly isRowActionPending: boolean; + readonly t: Translate; + readonly onDeleteInstallments: ( + transaction: Transaction, + scope: InstallmentDeleteScope, + ) => void; + readonly onDeleteSubscriptionOccurrences: ( + transaction: Transaction, + scope: DeleteSubscriptionOccurrencesInput["scope"], + ) => void; + readonly onDeleteTransaction: (transaction: Transaction) => void; +}; + +function TransactionRowDeleteMenuItems({ + transaction, + isInstallment, + isSubscription, + isRowActionPending, + t, + onDeleteInstallments, + onDeleteSubscriptionOccurrences, + onDeleteTransaction, +}: TransactionRowDeleteMenuItemsProps) { + if (isInstallment) { + return ( + <> + { + event.stopPropagation(); + onDeleteInstallments(transaction, "single"); + }} + > + + {t("transactions.installments.deleteOnlyThis")} + + { + event.stopPropagation(); + onDeleteInstallments(transaction, "this_and_following"); + }} + > + + {t("transactions.installments.deleteThisAndFollowing")} + + { + event.stopPropagation(); + onDeleteInstallments(transaction, "all"); + }} + > + + {t("transactions.installments.deleteAll")} + + + ); + } + + if (isSubscription) { + return ( + <> + { + event.stopPropagation(); + onDeleteSubscriptionOccurrences(transaction, "single"); + }} + > + + {t("transactions.subscriptions.deleteOnlyThis")} + + { + event.stopPropagation(); + onDeleteSubscriptionOccurrences( + transaction, + "this_and_following_unpaid", + ); + }} + > + + {t("transactions.subscriptions.deleteThisAndFollowingUnpaid")} + + + ); + } + + return ( + { + event.stopPropagation(); + onDeleteTransaction(transaction); + }} + > + + {t("common.delete")} + + ); +} + +type TransactionRowProps = { + readonly transaction: Transaction; + readonly isLast: boolean; + readonly rowIdx: number; + readonly today: string; + readonly isPending: boolean; + readonly pendingTransactionId: string | null; + readonly t: Translate; + readonly formatCurrency: FormatCurrency; + readonly onOpen: (transaction: Transaction) => void; + readonly onDeleteInstallments: ( + transaction: Transaction, + scope: InstallmentDeleteScope, + ) => void; + readonly onDeleteSubscriptionOccurrences: ( + transaction: Transaction, + scope: DeleteSubscriptionOccurrencesInput["scope"], + ) => void; + readonly onDeleteTransaction: (transaction: Transaction) => void; +}; + +type PlannedBadgeProps = { + readonly isCreditCardInvoice: boolean; + readonly t: Translate; + readonly className: string; +}; + +export function PlannedBadge({ + isCreditCardInvoice, + t, + className, +}: PlannedBadgeProps) { + return ( + + {t( + isCreditCardInvoice + ? "screen.transactions.plannedInvoice" + : "screen.transactions.planned", + )} + + ); +} + +type TransactionTypeIndicatorProps = { + readonly type: TransactionType; +}; + +export function TransactionTypeIndicator({ + type, +}: TransactionTypeIndicatorProps) { + if (type === "income") { + return ; + } + if (type === "expense") { + return ; + } + if (type === "saving") { + return ; + } + return null; +} + +function TransactionRow({ + transaction, + isLast, + rowIdx, + today, + isPending, + pendingTransactionId, + t, + formatCurrency, + onOpen, + onDeleteInstallments, + onDeleteSubscriptionOccurrences, + onDeleteTransaction, +}: TransactionRowProps) { + const isPlanned = Boolean(transaction.isPlanned); + const isCreditCardInvoice = Boolean(transaction.isCreditCardInvoice); + const isInstallment = isInstallmentTransaction(transaction); + const isSubscription = isSubscriptionTransaction(transaction); + const shouldPresentAsPlanned = isPlanned && transaction.date > today; + const transactionTitle = isCreditCardInvoice + ? getInvoiceTransactionTitle(transaction, t) + : t(transaction.descriptionKey); + const isRowActionPending = + isPending && pendingTransactionId === transaction.id; + + return ( +
+ + {isCreditCardInvoice ? null : ( + + + + + + { + event.stopPropagation(); + onOpen(transaction); + }} + > + + {t("common.edit")} + + + + + )} +
+ ); +} + +type TransactionDetailsDialogProps = { + readonly selectedTransaction: Transaction | null; + readonly formData: EditableTransaction | null; + readonly setFormData: (data: EditableTransaction) => void; + readonly transactions: Transaction[]; + readonly categoryOptions: CategoryOption[]; + readonly incomeCategoryOption: CategoryOption; + readonly paymentMethodOptions: CategoryOption[]; + readonly isPending: boolean; + readonly pendingTransactionId: string | null; + readonly t: Translate; + readonly formatCurrency: FormatCurrency; + readonly formatDate: FormatDate; + readonly onClose: () => void; + readonly onUpdate: () => void; + readonly onDeleteInstallments: ( + transaction: Transaction, + scope: InstallmentDeleteScope, + ) => void; + readonly onDeleteSubscriptionOccurrences: ( + transaction: Transaction, + scope: DeleteSubscriptionOccurrencesInput["scope"], + ) => void; + readonly onDeleteTransaction: (transaction: Transaction) => void; + readonly onAdvanceInstallments: (transactionId: string) => void; + readonly onOpenInstallmentFromInvoice: ( + installmentTransaction: Transaction, + ) => void; + readonly resolveCategoryForType: ( + type: TransactionType, + currentCategory: string, + ) => string; +}; + +function TransactionDetailsDialog({ + selectedTransaction, + formData, + setFormData, + transactions, + categoryOptions, + incomeCategoryOption, + paymentMethodOptions, + isPending, + pendingTransactionId, + t, + formatCurrency, + formatDate, + onClose, + onUpdate, + onDeleteInstallments, + onDeleteSubscriptionOccurrences, + onDeleteTransaction, + onAdvanceInstallments, + onOpenInstallmentFromInvoice, + resolveCategoryForType, +}: TransactionDetailsDialogProps) { + return ( + { + if (!open) onClose(); + }} + > + + + + {selectedTransaction?.isCreditCardInvoice + ? getInvoiceTransactionTitle(selectedTransaction, t) + : t("transaction.detailsTitle")} + + + {selectedTransaction?.isCreditCardInvoice + ? t("transaction.invoiceDetailsDescription") + : t("transaction.detailsDescription")} + + + + {selectedTransaction?.isCreditCardInvoice && + selectedTransaction.invoice && ( +
+
+
+

+ {t("transaction.paymentMethod")} +

+

+ {t(selectedTransaction.invoice.paymentMethodKey)} +

+
+
+

+ {t("transaction.invoiceCycle")} +

+

+ {formatDate(selectedTransaction.invoice.startsAt, { + day: "numeric", + month: "short", + })}{" "} + -{" "} + {formatDate(selectedTransaction.invoice.closingDate, { + day: "numeric", + month: "short", + })} +

+
+
+

+ {t("transaction.invoiceDueDate")} +

+

+ {formatDate(selectedTransaction.invoice.dueDate, { + day: "numeric", + month: "short", + })} +

+
+
+ +
+
+ {t("transaction.invoicePurchases")} + + {formatCurrency(Math.abs(selectedTransaction.amount))} + +
+
+ {selectedTransaction.invoice.purchases.map((purchase) => { + const installmentTransaction = transactions.find( + (transaction) => transaction.id === purchase.id, + ); + // `purchase.installmentLabel` ("N/M") is computed + // server-side from the same installment fields and is + // always present on invoice purchases — including next + // month's, which never show up in the locally loaded + // `transactions` page window — so gate on it instead + // of requiring a local match. + const installmentMatch = purchase.installmentLabel?.match( + /^(\d+)\/(\d+)$/, + ); + const canAdvanceInstallment = Boolean( + installmentMatch && + Number(installmentMatch[1]) < + Number(installmentMatch[2]), + ); + + return ( +
+
+

+ {t(purchase.descriptionKey)} +

+
+ + {formatDate(purchase.date, { + day: "numeric", + month: "short", + })} + + + {t(purchase.categoryKey)} + +
+
+
+

+ {formatCurrency(purchase.amount)} +

+ {installmentTransaction ? ( + + ) : null} + {canAdvanceInstallment ? ( + + ) : null} +
+
+ ); + })} +
+
+
+ )} + + {selectedTransaction && formData && ( +
+
+
+ + +
+
+ + setFormData({ + ...formData, + amount, + }) + } + labelClassName="normal-case tracking-normal text-foreground" + /> +
+
+ +
+ + + setFormData({ + ...formData, + description: event.target.value, + }) + } + /> +
+ +
+
+ + + setFormData({ ...formData, date: event.target.value }) + } + className="w-full max-w-full min-w-0 appearance-none overflow-hidden pr-3 text-left [&::-webkit-calendar-picker-indicator]:shrink-0 [&::-webkit-date-and-time-value]:min-w-0 [&::-webkit-date-and-time-value]:overflow-hidden [&::-webkit-date-and-time-value]:text-left" + /> +
+
+ + +
+
+ + +
+
+ +
+ +