From 66ff427481dfe0e00cf632e3f0f1caef33238636 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADctor=20Falc=C3=B3n?= Date: Wed, 20 May 2026 10:29:14 +0100 Subject: [PATCH] feat(import): calculate balances from transactions (#403) ## Why Some banks return transactions without a balance column. The CSV/Excel importer happily creates the transactions but leaves the account without any balance history, making the account look incomplete. ## What Opt-in flow that derives per-day balances from the transactions themselves, using a single reference point (the balance on the date of the latest transaction). ### Mapping step - New checkbox **"Calculate balances from transactions"** next to the Balance column select. - Available only when no balance column is mapped (disabled + visually faded otherwise). - When checked, a balance input appears labeled with the latest transaction date using a relative format: - `Today` - `Yesterday` - `Monday, 3 of Jun` - If an `AccountBalance` already exists for that date, it is pre-filled automatically (no need to ask) with a small hint. - The reference balance is **mandatory** when the checkbox is checked; the Next button is disabled until it is provided. ### Preview step - A new `Balance` column shows the balance about to be created for each date, regardless of whether it came from the file or was calculated. ### Persistence - Balances are computed by walking backwards across distinct transaction dates and subtracting each day's net movement. - They flow through the existing import path that POSTs to `AccountBalanceController@store`, so no new endpoint was needed. ## Feature flag Hidden behind a new Pennant feature, **off by default**: ```bash php artisan feature:enable CalculateBalancesOnImport user@example.com # or php artisan feature:enable CalculateBalancesOnImport all ``` The flag is exposed to the frontend via Inertia shared props (`features.calculateBalancesOnImport`). ## Tests - New vitest cases for `getLatestTransactionDate` and `calculateBalancesFromTransactions` (sparse dates, reference date without txns, empty list). - `LocalizationTest` passes (new ES strings added). - Full vitest suite green (106 tests). --- app/Features/CalculateBalancesOnImport.php | 19 ++ app/Http/Middleware/HandleInertiaRequests.php | 7 + lang/es.json | 5 + .../transactions/import-step-mapping.tsx | 124 +++++++++++- .../transactions/import-step-preview.tsx | 33 +++- .../import-transactions-drawer.tsx | 180 +++++++++++++++++- resources/js/lib/file-parser.test.ts | 97 ++++++++++ resources/js/lib/file-parser.ts | 81 ++++++++ resources/js/types/import.ts | 4 + resources/js/types/index.d.ts | 1 + resources/js/utils/date.ts | 36 +++- tests/Feature/InertiaSharedDataTest.php | 1 + 12 files changed, 572 insertions(+), 16 deletions(-) create mode 100644 app/Features/CalculateBalancesOnImport.php diff --git a/app/Features/CalculateBalancesOnImport.php b/app/Features/CalculateBalancesOnImport.php new file mode 100644 index 00000000..74e2ec6e --- /dev/null +++ b/app/Features/CalculateBalancesOnImport.php @@ -0,0 +1,19 @@ +user(); + return [ 'cashflow' => true, + 'calculateBalancesOnImport' => $user + ? Feature::for($user)->active(CalculateBalancesOnImport::class) + : false, ]; } diff --git a/lang/es.json b/lang/es.json index 2055e679..b8c2ab00 100644 --- a/lang/es.json +++ b/lang/es.json @@ -225,6 +225,7 @@ "Balance History": "Historial de Balance", "Balance Tracking": "Seguimiento de Balance", "Balance evolution": "Evolución del balance", + "Balance on": "Balance del", "Balances": "Balances", "Bank": "Banco", "Bank Account": "Cuenta Bancaria", @@ -271,6 +272,7 @@ "Can I export or delete my data?": "¿Puedo exportar o eliminar mis datos?", "Canadian Dollar": "Dólar canadiense", "Cancel": "Cancelar", + "Calculate balances from transactions": "Calcular balances a partir de transacciones", "Carry Over": "Acumular", "Cash inflow": "Entrada de efectivo", "Cash outflow": "Salida de efectivo", @@ -1005,6 +1007,7 @@ "Prefer to Connect Your Bank?": "¿Prefieres Conectar Tu Banco?", "Prefer to Start Fresh?": "¿Prefieres Empezar de Cero?", "Prev": "Ant", + "Pre-filled from an existing balance for this date.": "Rellenado con un balance existente para esta fecha.", "Preview (first 3 rows)": "Vista previa (primeras 3 filas)", "Preview Balances": "Vista Previa de Balances", "Preview Owed Amounts": "Vista Previa de Montos Adeudados", @@ -1453,6 +1456,7 @@ "Uruguayan Peso": "Peso uruguayo", "Usage Information:": "Información de Uso:", "Use Defaults": "Usar Valores Predeterminados", + "Use the balance on the latest transaction date as a reference to compute balances for older dates.": "Usa el balance de la fecha de la última transacción como referencia para calcular los balances de fechas anteriores.", "Use a view-only key.": "Usa una clave de solo lectura.", "Use a strong password (minimum 12 characters). This password will encrypt your data.": "Usa una contraseña fuerte (mínimo 12 caracteres). Esta contraseña encriptará tus datos.", "Use a strong password (minimum 12 characters). This\\n password will encrypt your data.": "Usa una contraseña segura (mínimo 12 caracteres). Esta contraseña cifrará tus datos.", @@ -1587,6 +1591,7 @@ "Year": "Año", "Yearly": "Anual", "Yearly code": "Código anual", + "Yesterday": "Ayer", "Yes! Whisper Money is fully open source. You can review the code, suggest improvements, or even self-host it. Transparency is a core part of our privacy commitment.": "¡Sí! Whisper Money es completamente de código abierto. Puedes revisar el código, sugerir mejoras o incluso alojarlo tú mismo. La transparencia es una parte fundamental de nuestro compromiso con la privacidad.", "Yes. Bank connections use secure Open Banking and never require your bank credentials, so transactions can sync automatically. This is a Pro feature; free users can import everything, but only through the CSV/Excel importer.": "Sí. Las conexiones bancarias usan Open Banking seguro y nunca requieren tus credenciales bancarias, por lo que las transacciones se sincronizan automáticamente. Es una función Pro; los usuarios gratuitos pueden importarlo todo, pero solo mediante el importador CSV/Excel.", "You agree to indemnify, defend, and hold harmless Whisper Money and its officers, directors, employees, and agents from any claims, liabilities, damages, losses, and expenses, including reasonable legal fees, arising out of or related to your use of the service, violation of these Terms, or violation of any rights of another party.": "Aceptas indemnizar, defender y eximir de responsabilidad a Whisper Money y sus funcionarios, directores, empleados y agentes de cualquier reclamo, responsabilidad, daño, pérdida y gasto, incluidos los honorarios legales razonables, que surjan de o estén relacionados con tu uso del servicio, violación de estos Términos o violación de los derechos de otra parte.", diff --git a/resources/js/components/transactions/import-step-mapping.tsx b/resources/js/components/transactions/import-step-mapping.tsx index 57ada1b9..58048ff8 100644 --- a/resources/js/components/transactions/import-step-mapping.tsx +++ b/resources/js/components/transactions/import-step-mapping.tsx @@ -1,4 +1,6 @@ +import { AmountInput } from '@/components/ui/amount-input'; import { Button } from '@/components/ui/button'; +import { Checkbox } from '@/components/ui/checkbox'; import { Label } from '@/components/ui/label'; import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; import { @@ -9,14 +11,20 @@ import { SelectValue, } from '@/components/ui/select'; import { useLocale } from '@/hooks/use-locale'; -import { parseAmount, parseDate } from '@/lib/file-parser'; +import { + getLatestTransactionDate, + parseAmount, + parseDate, +} from '@/lib/file-parser'; import { DateFormat, type ColumnMapping, type ColumnOption, type ParsedRow, } from '@/types/import'; +import { formatRelativeDate } from '@/utils/date'; import { __ } from '@/utils/i18n'; +import { useEffect, useMemo } from 'react'; interface ImportStepMappingProps { columnOptions: ColumnOption[]; @@ -25,11 +33,18 @@ interface ImportStepMappingProps { dateFormatDetected: boolean; parsedData: ParsedRow[]; currencyCode: string; + calculateBalances: boolean; + referenceBalance: number | null; + referenceBalancePrefilled: boolean; + calculateBalancesAvailable: boolean; onMappingChange: ( field: keyof ColumnMapping, value: string | string[], ) => void; onDateFormatChange: (format: DateFormat) => void; + onCalculateBalancesChange: (enabled: boolean) => void; + onReferenceBalanceChange: (balanceInCents: number) => void; + onLatestDateChange: (date: string | null) => void; onNext: () => void; onBack: () => void; } @@ -41,8 +56,15 @@ export function ImportStepMapping({ dateFormatDetected, parsedData, currencyCode, + calculateBalances, + referenceBalance, + referenceBalancePrefilled, + calculateBalancesAvailable, onMappingChange, onDateFormatChange, + onCalculateBalancesChange, + onReferenceBalanceChange, + onLatestDateChange, onNext, onBack, }: ImportStepMappingProps) { @@ -53,10 +75,36 @@ export function ImportStepMapping({ : []; const locale = useLocale(); + const balanceColumnSet = !!columnMapping.balance; + const checkboxDisabled = balanceColumnSet; + const effectiveCalculate = calculateBalances && !balanceColumnSet; + + const latestDate = useMemo(() => { + if (!effectiveCalculate) { + return null; + } + return getLatestTransactionDate( + parsedData, + columnMapping, + dateFormat, + ); + }, [effectiveCalculate, parsedData, columnMapping, dateFormat]); + + useEffect(() => { + onLatestDateChange(latestDate); + }, [latestDate, onLatestDateChange]); + + const baseMappingValid = + !!columnMapping.transaction_date && + !!columnMapping.description && + !!columnMapping.amount; + const isValid = - columnMapping.transaction_date && - columnMapping.description && - columnMapping.amount; + baseMappingValid && + (!effectiveCalculate || + (latestDate !== null && + referenceBalance !== null && + referenceBalance !== undefined)); const getDescriptionFromRow = (row: ParsedRow): string => { if (!columnMapping.description) { @@ -334,9 +382,75 @@ export function ImportStepMapping({ ))} + + {calculateBalancesAvailable && ( +
+
+ + onCalculateBalancesChange( + checked === true, + ) + } + className="mt-0.5" + /> +
+ +

+ {__( + 'Use the balance on the latest transaction date as a reference to compute balances for older dates.', + )} +

+
+
+ + {effectiveCalculate && latestDate && ( +
+ + + {referenceBalancePrefilled && ( +

+ {__( + 'Pre-filled from an existing balance for this date.', + )} +

+ )} +
+ )} +
+ )} - {isValid && previewTransactions.length > 0 && ( + {baseMappingValid && previewTransactions.length > 0 && (