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).
This commit is contained in:
parent
b66f9e29bc
commit
66ff427481
|
|
@ -0,0 +1,19 @@
|
|||
<?php
|
||||
|
||||
namespace App\Features;
|
||||
|
||||
use App\Models\User;
|
||||
|
||||
/**
|
||||
* @api
|
||||
*/
|
||||
class CalculateBalancesOnImport
|
||||
{
|
||||
/**
|
||||
* Resolve the feature's initial value.
|
||||
*/
|
||||
public function resolve(?User $user): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
@ -4,11 +4,13 @@ namespace App\Http\Middleware;
|
|||
|
||||
use App\Enums\AccountType;
|
||||
use App\Enums\BankingConnectionStatus;
|
||||
use App\Features\CalculateBalancesOnImport;
|
||||
use App\Models\BankingConnection;
|
||||
use App\Services\CurrencyOptions;
|
||||
use Illuminate\Foundation\Inspiring;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Middleware;
|
||||
use Laravel\Pennant\Feature;
|
||||
|
||||
class HandleInertiaRequests extends Middleware
|
||||
{
|
||||
|
|
@ -171,8 +173,13 @@ class HandleInertiaRequests extends Middleware
|
|||
*/
|
||||
protected function resolveFeatureFlags(): array
|
||||
{
|
||||
$user = request()->user();
|
||||
|
||||
return [
|
||||
'cashflow' => true,
|
||||
'calculateBalancesOnImport' => $user
|
||||
? Feature::for($user)->active(CalculateBalancesOnImport::class)
|
||||
: false,
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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({
|
|||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{calculateBalancesAvailable && (
|
||||
<div className="space-y-3 pt-2">
|
||||
<div className="flex items-start gap-2">
|
||||
<Checkbox
|
||||
id="calculate-balances"
|
||||
checked={
|
||||
balanceColumnSet
|
||||
? false
|
||||
: calculateBalances
|
||||
}
|
||||
disabled={checkboxDisabled}
|
||||
onCheckedChange={(checked) =>
|
||||
onCalculateBalancesChange(
|
||||
checked === true,
|
||||
)
|
||||
}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<Label
|
||||
htmlFor="calculate-balances"
|
||||
className={`cursor-pointer font-normal ${checkboxDisabled ? 'opacity-50' : ''}`}
|
||||
>
|
||||
{__(
|
||||
'Calculate balances from transactions',
|
||||
)}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{__(
|
||||
'Use the balance on the latest transaction date as a reference to compute balances for older dates.',
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{effectiveCalculate && latestDate && (
|
||||
<div className="space-y-2 rounded-md border bg-muted/30 p-3">
|
||||
<Label htmlFor="reference-balance">
|
||||
{__('Balance on')}{' '}
|
||||
{formatRelativeDate(
|
||||
latestDate,
|
||||
locale,
|
||||
)}{' '}
|
||||
<span className="text-destructive">
|
||||
*
|
||||
</span>
|
||||
</Label>
|
||||
<AmountInput
|
||||
id="reference-balance"
|
||||
value={referenceBalance ?? 0}
|
||||
onChange={onReferenceBalanceChange}
|
||||
currencyCode={currencyCode}
|
||||
required
|
||||
/>
|
||||
{referenceBalancePrefilled && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{__(
|
||||
'Pre-filled from an existing balance for this date.',
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isValid && previewTransactions.length > 0 && (
|
||||
{baseMappingValid && previewTransactions.length > 0 && (
|
||||
<div className="space-y-4 rounded-lg border bg-muted/30 p-4">
|
||||
<Label className="pl-2 text-xs font-light tracking-widest uppercase opacity-50">
|
||||
{__('Preview (first 3 rows)')}
|
||||
|
|
|
|||
|
|
@ -94,6 +94,14 @@ export function ImportStepPreview({
|
|||
onSelectAll(checked);
|
||||
};
|
||||
|
||||
const hasBalances = useMemo(
|
||||
() =>
|
||||
transactions.some(
|
||||
(t) => t.balance !== null && t.balance !== undefined,
|
||||
),
|
||||
[transactions],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex gap-4 rounded-lg border bg-muted/50 p-4">
|
||||
|
|
@ -155,13 +163,18 @@ export function ImportStepPreview({
|
|||
<TableHead className="text-right">
|
||||
{__('Amount')}
|
||||
</TableHead>
|
||||
{hasBalances && (
|
||||
<TableHead className="text-right">
|
||||
{__('Balance')}
|
||||
</TableHead>
|
||||
)}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{transactions.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={5}
|
||||
colSpan={hasBalances ? 6 : 5}
|
||||
className="text-center text-muted-foreground"
|
||||
>
|
||||
No valid transactions found
|
||||
|
|
@ -221,6 +234,24 @@ export function ImportStepPreview({
|
|||
currencyCode={currencyCode}
|
||||
/>
|
||||
</TableCell>
|
||||
{hasBalances && (
|
||||
<TableCell className="text-right font-mono">
|
||||
{transaction.balance !== null &&
|
||||
transaction.balance !==
|
||||
undefined ? (
|
||||
<AmountDisplay
|
||||
amountInCents={
|
||||
transaction.balance
|
||||
}
|
||||
currencyCode={currencyCode}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-muted-foreground">
|
||||
—
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,7 @@
|
|||
import { store as storeBalance } from '@/actions/App/Http/Controllers/AccountBalanceController';
|
||||
import {
|
||||
index as indexBalances,
|
||||
store as storeBalance,
|
||||
} from '@/actions/App/Http/Controllers/AccountBalanceController';
|
||||
import AlertError from '@/components/alert-error';
|
||||
import {
|
||||
Drawer,
|
||||
|
|
@ -11,6 +14,7 @@ import { Progress } from '@/components/ui/progress';
|
|||
import { importKey } from '@/lib/crypto';
|
||||
import {
|
||||
autoDetectColumns,
|
||||
calculateBalancesFromTransactions,
|
||||
convertRowsToTransactions,
|
||||
parseFile,
|
||||
} from '@/lib/file-parser';
|
||||
|
|
@ -34,7 +38,7 @@ import {
|
|||
import { type UUID } from '@/types/uuid';
|
||||
import { __ } from '@/utils/i18n';
|
||||
import { router, usePage } from '@inertiajs/react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { ImportStepAccount } from './import-step-account';
|
||||
import { ImportStepMapping } from './import-step-mapping';
|
||||
|
|
@ -72,7 +76,7 @@ export function ImportTransactionsDrawer({
|
|||
onImportComplete,
|
||||
autoSelectSingleAccount = false,
|
||||
}: ImportTransactionsDrawerProps) {
|
||||
const { locale } = usePage<SharedData>().props;
|
||||
const { locale, features } = usePage<SharedData>().props;
|
||||
const [isImporting, setIsImporting] = useState(false);
|
||||
const [importProgress, setImportProgress] = useState(0);
|
||||
const [importTotal, setImportTotal] = useState(0);
|
||||
|
|
@ -99,6 +103,10 @@ export function ImportTransactionsDrawer({
|
|||
dateFormat: DateFormat.YearMonthDay,
|
||||
dateFormatDetected: false,
|
||||
transactions: [],
|
||||
calculateBalances: false,
|
||||
referenceBalance: null,
|
||||
referenceBalanceDate: null,
|
||||
referenceBalancePrefilled: false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -130,6 +138,10 @@ export function ImportTransactionsDrawer({
|
|||
dateFormat: DateFormat.YearMonthDay,
|
||||
dateFormatDetected: false,
|
||||
transactions: [],
|
||||
calculateBalances: false,
|
||||
referenceBalance: null,
|
||||
referenceBalanceDate: null,
|
||||
referenceBalancePrefilled: false,
|
||||
});
|
||||
setIsImporting(false);
|
||||
setError(null);
|
||||
|
|
@ -256,15 +268,126 @@ export function ImportTransactionsDrawer({
|
|||
field: keyof ColumnMapping,
|
||||
value: string | string[],
|
||||
) => {
|
||||
setState((prev) => {
|
||||
const next = {
|
||||
...prev,
|
||||
columnMapping: {
|
||||
...prev.columnMapping,
|
||||
[field]: value,
|
||||
},
|
||||
};
|
||||
// Setting a balance column disables the calculate-balances option
|
||||
if (field === 'balance' && value) {
|
||||
next.calculateBalances = false;
|
||||
next.referenceBalance = null;
|
||||
next.referenceBalanceDate = null;
|
||||
next.referenceBalancePrefilled = false;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleCalculateBalancesChange = (enabled: boolean) => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
columnMapping: {
|
||||
...prev.columnMapping,
|
||||
[field]: value,
|
||||
},
|
||||
calculateBalances: enabled,
|
||||
referenceBalance: enabled ? prev.referenceBalance : null,
|
||||
referenceBalancePrefilled: enabled
|
||||
? prev.referenceBalancePrefilled
|
||||
: false,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleReferenceBalanceChange = (balanceInCents: number) => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
referenceBalance: balanceInCents,
|
||||
referenceBalancePrefilled: false,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleLatestDateChange = useCallback(
|
||||
(date: string | null) => {
|
||||
setState((prev) => {
|
||||
if (prev.referenceBalanceDate === date) {
|
||||
return prev;
|
||||
}
|
||||
return {
|
||||
...prev,
|
||||
referenceBalanceDate: date,
|
||||
referenceBalance: null,
|
||||
referenceBalancePrefilled: false,
|
||||
};
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// Try to pre-fill the reference balance from an existing balance record
|
||||
// on that date. If found, no need to ask the user.
|
||||
useEffect(() => {
|
||||
if (
|
||||
!state.calculateBalances ||
|
||||
!state.referenceBalanceDate ||
|
||||
!state.selectedAccountId ||
|
||||
state.referenceBalance !== null
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
indexBalances.url(state.selectedAccountId as string, {
|
||||
query: { page: '1' },
|
||||
}),
|
||||
{ headers: { Accept: 'application/json' } },
|
||||
);
|
||||
if (!response.ok) {
|
||||
return;
|
||||
}
|
||||
const json = (await response.json()) as {
|
||||
data: { balance_date: string; balance: number }[];
|
||||
};
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
const match = json.data.find(
|
||||
(b) => b.balance_date === state.referenceBalanceDate,
|
||||
);
|
||||
if (match) {
|
||||
setState((prev) => {
|
||||
if (
|
||||
prev.referenceBalanceDate !==
|
||||
state.referenceBalanceDate ||
|
||||
prev.referenceBalance !== null
|
||||
) {
|
||||
return prev;
|
||||
}
|
||||
return {
|
||||
...prev,
|
||||
referenceBalance: match.balance,
|
||||
referenceBalancePrefilled: true,
|
||||
};
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load reference balance:', err);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [
|
||||
state.calculateBalances,
|
||||
state.referenceBalanceDate,
|
||||
state.selectedAccountId,
|
||||
state.referenceBalance,
|
||||
]);
|
||||
|
||||
const handleDateFormatChange = (format: DateFormat) => {
|
||||
setState((prev) => ({ ...prev, dateFormat: format }));
|
||||
};
|
||||
|
|
@ -293,7 +416,7 @@ export function ImportTransactionsDrawer({
|
|||
parsedTransactions,
|
||||
);
|
||||
|
||||
const transactionsWithDuplicateCheck = parsedTransactions.map(
|
||||
let transactionsWithDuplicateCheck = parsedTransactions.map(
|
||||
(transaction, index) => ({
|
||||
...transaction,
|
||||
isDuplicate: duplicateFlags[index],
|
||||
|
|
@ -301,6 +424,32 @@ export function ImportTransactionsDrawer({
|
|||
}),
|
||||
);
|
||||
|
||||
// When calculate-balances is enabled and no balance column is
|
||||
// mapped, derive balances from the reference balance for every
|
||||
// distinct transaction date.
|
||||
const shouldCalculate =
|
||||
state.calculateBalances &&
|
||||
!state.columnMapping.balance &&
|
||||
state.referenceBalanceDate !== null &&
|
||||
state.referenceBalance !== null;
|
||||
|
||||
if (shouldCalculate) {
|
||||
const calculatedBalances = calculateBalancesFromTransactions(
|
||||
transactionsWithDuplicateCheck,
|
||||
state.referenceBalanceDate as string,
|
||||
state.referenceBalance as number,
|
||||
);
|
||||
|
||||
transactionsWithDuplicateCheck =
|
||||
transactionsWithDuplicateCheck.map((transaction) => ({
|
||||
...transaction,
|
||||
balance:
|
||||
calculatedBalances.get(
|
||||
transaction.transaction_date,
|
||||
) ?? transaction.balance ?? null,
|
||||
}));
|
||||
}
|
||||
|
||||
if (state.selectedAccountId) {
|
||||
saveImportConfig(state.selectedAccountId, {
|
||||
columnMapping: state.columnMapping,
|
||||
|
|
@ -693,8 +842,21 @@ export function ImportTransactionsDrawer({
|
|||
dateFormatDetected={state.dateFormatDetected}
|
||||
parsedData={state.parsedData}
|
||||
currencyCode={selectedAccount?.currency_code || 'USD'}
|
||||
calculateBalances={state.calculateBalances}
|
||||
referenceBalance={state.referenceBalance}
|
||||
referenceBalancePrefilled={
|
||||
state.referenceBalancePrefilled
|
||||
}
|
||||
calculateBalancesAvailable={
|
||||
features.calculateBalancesOnImport
|
||||
}
|
||||
onMappingChange={handleMappingChange}
|
||||
onDateFormatChange={handleDateFormatChange}
|
||||
onCalculateBalancesChange={
|
||||
handleCalculateBalancesChange
|
||||
}
|
||||
onReferenceBalanceChange={handleReferenceBalanceChange}
|
||||
onLatestDateChange={handleLatestDateChange}
|
||||
onNext={handlePreviewTransactions}
|
||||
onBack={() => moveToStep(ImportStep.UploadFile)}
|
||||
/>
|
||||
|
|
@ -728,7 +890,7 @@ export function ImportTransactionsDrawer({
|
|||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between text-sm text-muted-foreground">
|
||||
<span>
|
||||
{importProgress} of {importTotal}
|
||||
{importProgress} of {importTotal}{' '}
|
||||
{__('transactions imported')}
|
||||
</span>
|
||||
<span>{Math.round(percentage)}%</span>
|
||||
|
|
|
|||
|
|
@ -2,9 +2,12 @@ import { DateFormat } from '@/types/import';
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
autoDetectDateFormat,
|
||||
calculateBalancesFromTransactions,
|
||||
convertRowsToTransactions,
|
||||
getLatestTransactionDate,
|
||||
getLocaleDateFormat,
|
||||
} from './file-parser';
|
||||
import type { ColumnMapping, ParsedTransaction } from '@/types/import';
|
||||
|
||||
describe('getLocaleDateFormat', () => {
|
||||
it('returns null for undefined locale', () => {
|
||||
|
|
@ -151,3 +154,97 @@ describe('autoDetectDateFormat', () => {
|
|||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getLatestTransactionDate', () => {
|
||||
const mapping: ColumnMapping = {
|
||||
transaction_date: 'date',
|
||||
description: 'desc',
|
||||
amount: 'amount',
|
||||
balance: null,
|
||||
};
|
||||
|
||||
it('returns null when no date column set', () => {
|
||||
expect(
|
||||
getLatestTransactionDate(
|
||||
[{ date: '2024-01-01' }],
|
||||
{ ...mapping, transaction_date: null },
|
||||
DateFormat.YearMonthDay,
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('returns latest date across rows in YYYY-MM-DD', () => {
|
||||
const rows = [
|
||||
{ date: '2024-01-15' },
|
||||
{ date: '2024-03-02' },
|
||||
{ date: '2024-02-10' },
|
||||
];
|
||||
expect(
|
||||
getLatestTransactionDate(rows, mapping, DateFormat.YearMonthDay),
|
||||
).toBe('2024-03-02');
|
||||
});
|
||||
|
||||
it('returns null when rows have no parseable date', () => {
|
||||
const rows = [{ date: '' }, { date: null }];
|
||||
expect(
|
||||
getLatestTransactionDate(rows, mapping, DateFormat.YearMonthDay),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculateBalancesFromTransactions', () => {
|
||||
function txn(
|
||||
date: string,
|
||||
amount: number,
|
||||
): ParsedTransaction {
|
||||
return {
|
||||
transaction_date: date,
|
||||
description: 'x',
|
||||
amount,
|
||||
};
|
||||
}
|
||||
|
||||
it('walks balances back across distinct dates', () => {
|
||||
const txns = [
|
||||
txn('2024-01-01', 1000),
|
||||
txn('2024-01-02', -500),
|
||||
txn('2024-01-02', -200),
|
||||
txn('2024-01-03', 300),
|
||||
];
|
||||
const balances = calculateBalancesFromTransactions(
|
||||
txns,
|
||||
'2024-01-03',
|
||||
10000,
|
||||
);
|
||||
expect(balances.get('2024-01-03')).toBe(10000);
|
||||
// before 03 net (+300): end of 02 = 9700
|
||||
expect(balances.get('2024-01-02')).toBe(9700);
|
||||
// before 02 net (-700): end of 01 = 10400
|
||||
expect(balances.get('2024-01-01')).toBe(10400);
|
||||
});
|
||||
|
||||
it('handles reference date with no transactions on it', () => {
|
||||
const txns = [
|
||||
txn('2024-01-01', 1000),
|
||||
txn('2024-01-02', -200),
|
||||
];
|
||||
const balances = calculateBalancesFromTransactions(
|
||||
txns,
|
||||
'2024-01-05',
|
||||
5000,
|
||||
);
|
||||
expect(balances.get('2024-01-05')).toBe(5000);
|
||||
expect(balances.get('2024-01-02')).toBe(5000);
|
||||
expect(balances.get('2024-01-01')).toBe(5200);
|
||||
});
|
||||
|
||||
it('returns only reference when no transactions provided', () => {
|
||||
const balances = calculateBalancesFromTransactions(
|
||||
[],
|
||||
'2024-01-05',
|
||||
5000,
|
||||
);
|
||||
expect(balances.size).toBe(1);
|
||||
expect(balances.get('2024-01-05')).toBe(5000);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -603,3 +603,84 @@ export function convertRowsToTransactions(
|
|||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the latest transaction date (YYYY-MM-DD) from parsed rows using the
|
||||
* provided column mapping and date format. Returns null if no valid dates.
|
||||
*/
|
||||
export function getLatestTransactionDate(
|
||||
rows: ParsedRow[],
|
||||
mapping: ColumnMapping,
|
||||
dateFormat: DateFormat,
|
||||
): string | null {
|
||||
if (!mapping.transaction_date) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let latest: Date | null = null;
|
||||
|
||||
for (const row of rows) {
|
||||
const raw = row[mapping.transaction_date];
|
||||
if (raw === null || raw === undefined || raw === '') {
|
||||
continue;
|
||||
}
|
||||
const parsed = parseDate(raw as string | number, dateFormat);
|
||||
if (!parsed) {
|
||||
continue;
|
||||
}
|
||||
if (!latest || parsed.getTime() > latest.getTime()) {
|
||||
latest = parsed;
|
||||
}
|
||||
}
|
||||
|
||||
return latest ? formatLocalDate(latest) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a chronologically sorted list of transactions (any order) and the
|
||||
* balance as of the latest transaction date, compute the end-of-day balance
|
||||
* for every distinct date by walking backwards: subtract each day's net
|
||||
* movement from the next day's balance.
|
||||
*
|
||||
* All amounts/balances are in cents.
|
||||
*/
|
||||
export function calculateBalancesFromTransactions(
|
||||
transactions: ParsedTransaction[],
|
||||
latestDate: string,
|
||||
referenceBalance: number,
|
||||
): Map<string, number> {
|
||||
const dailyNet = new Map<string, number>();
|
||||
|
||||
for (const txn of transactions) {
|
||||
dailyNet.set(
|
||||
txn.transaction_date,
|
||||
(dailyNet.get(txn.transaction_date) ?? 0) + txn.amount,
|
||||
);
|
||||
}
|
||||
|
||||
const dates = Array.from(dailyNet.keys()).sort();
|
||||
const balances = new Map<string, number>();
|
||||
|
||||
if (dates.length === 0) {
|
||||
balances.set(latestDate, referenceBalance);
|
||||
return balances;
|
||||
}
|
||||
|
||||
if (!dailyNet.has(latestDate)) {
|
||||
dates.push(latestDate);
|
||||
dates.sort();
|
||||
}
|
||||
|
||||
balances.set(latestDate, referenceBalance);
|
||||
|
||||
const latestIndex = dates.indexOf(latestDate);
|
||||
|
||||
for (let i = latestIndex - 1; i >= 0; i--) {
|
||||
const nextDate = dates[i + 1];
|
||||
const nextNet = dailyNet.get(nextDate) ?? 0;
|
||||
const nextBalance = balances.get(nextDate) ?? 0;
|
||||
balances.set(dates[i], nextBalance - nextNet);
|
||||
}
|
||||
|
||||
return balances;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -51,4 +51,8 @@ export interface ImportState {
|
|||
dateFormat: DateFormat;
|
||||
dateFormatDetected: boolean;
|
||||
transactions: ParsedTransaction[];
|
||||
calculateBalances: boolean;
|
||||
referenceBalance: number | null;
|
||||
referenceBalanceDate: string | null;
|
||||
referenceBalancePrefilled: boolean;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ export interface NavDivider {
|
|||
|
||||
export interface Features {
|
||||
cashflow: boolean;
|
||||
calculateBalancesOnImport: boolean;
|
||||
}
|
||||
|
||||
export interface ExpiredBankingConnectionNotification {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,10 @@
|
|||
import { format as dateFnsFormat } from 'date-fns';
|
||||
import {
|
||||
format as dateFnsFormat,
|
||||
isToday as dateFnsIsToday,
|
||||
isYesterday as dateFnsIsYesterday,
|
||||
} from 'date-fns';
|
||||
import { es } from 'date-fns/locale';
|
||||
import { __ } from '@/utils/i18n';
|
||||
|
||||
/**
|
||||
* Get the date-fns locale object based on locale code
|
||||
|
|
@ -99,6 +104,35 @@ export function formatDateMedium(
|
|||
return formatted.charAt(0).toUpperCase() + formatted.slice(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a date from YYYY-MM-DD string using relative wording when close to
|
||||
* the current date. Returns "Today" or "Yesterday" when appropriate, otherwise
|
||||
* a long weekday-based label like "Monday, 3 of Jun".
|
||||
*/
|
||||
export function formatRelativeDate(
|
||||
dateStr: string,
|
||||
locale: string = 'en-US',
|
||||
): string {
|
||||
const date = new Date(dateStr + 'T00:00:00');
|
||||
|
||||
if (dateFnsIsToday(date)) {
|
||||
return __('Today');
|
||||
}
|
||||
|
||||
if (dateFnsIsYesterday(date)) {
|
||||
return __('Yesterday');
|
||||
}
|
||||
|
||||
const weekday = formatDate(date, 'EEEE', locale);
|
||||
const day = formatDate(date, 'd', locale);
|
||||
const month = formatDate(date, 'MMM', locale);
|
||||
const capitalizedWeekday =
|
||||
weekday.charAt(0).toUpperCase() + weekday.slice(1);
|
||||
const capitalizedMonth = month.charAt(0).toUpperCase() + month.slice(1);
|
||||
|
||||
return `${capitalizedWeekday}, ${day} ${__('of')} ${capitalizedMonth}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a date from YYYY-MM-DD string for daily chart X-axis labels
|
||||
* Shows "MMM d" (e.g., "Feb 14") for current year, or "MMM d 'YY" for other years
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ test('shared feature flags do not include coinbase flag', function () {
|
|||
|
||||
expect($props['features'])->toBe([
|
||||
'cashflow' => true,
|
||||
'calculateBalancesOnImport' => false,
|
||||
]);
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue