Money tracker: bank balance is the source of truth for synced accounts
All checks were successful
Deploy production / deploy (push) Successful in 2m43s
All checks were successful
Deploy production / deploy (push) Successful in 2m43s
Sync stores the bank-reported balance (ITAV preferred) on the account and keeps one Bank balance adjustment row so the tracker total equals the bank balance. Accounts panel shows the bank balance and flags any drift.
This commit is contained in:
@@ -6,6 +6,7 @@ import {
|
||||
externalIdFor,
|
||||
findMatch,
|
||||
mapBankTransaction,
|
||||
pickBalance,
|
||||
planAccountMapping
|
||||
} from './bank-sync';
|
||||
import { createEnableBankingClient, parseAuthCode, type BankTransaction } from './enable-banking';
|
||||
@@ -174,3 +175,18 @@ describe('enable banking client', () => {
|
||||
expect(calls[0]).toContain('date_from=2026-01-01');
|
||||
});
|
||||
});
|
||||
|
||||
describe('bank balance', () => {
|
||||
const b = (balance_type: string, amount: string) => ({
|
||||
balance_type,
|
||||
balance_amount: { amount, currency: 'EUR' }
|
||||
});
|
||||
it('prefers the available balance the bank app shows', () => {
|
||||
expect(pickBalance([b('CLBD', '70.00'), b('ITAV', '69.88')])?.balance_amount.amount).toBe(
|
||||
'69.88'
|
||||
);
|
||||
expect(pickBalance([b('XPCD', '1.00'), b('CLBD', '2.00')])?.balance_type).toBe('CLBD');
|
||||
expect(pickBalance([b('OTHR', '3.00')])?.balance_type).toBe('OTHR');
|
||||
expect(pickBalance([])).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import type { Sql } from 'postgres';
|
||||
import type {
|
||||
Balance,
|
||||
BankAccount,
|
||||
BankTransaction,
|
||||
EnableBankingClient,
|
||||
@@ -255,6 +256,19 @@ export async function saveSession(
|
||||
});
|
||||
}
|
||||
|
||||
/** The balance Stan sees in the bank app: interim available, else closing booked. */
|
||||
export function pickBalance(balances: Balance[]): Balance | undefined {
|
||||
const order = ['ITAV', 'CLAV', 'ITBD', 'CLBD', 'XPCD'];
|
||||
for (const type of order) {
|
||||
const found = balances.find((b) => b.balance_type === type);
|
||||
if (found) return found;
|
||||
}
|
||||
return balances[0];
|
||||
}
|
||||
|
||||
/** Synthetic row that makes the tracker's sum equal the bank balance. */
|
||||
export const BALANCE_ADJUSTMENT_ID = 'balance-adjustment';
|
||||
|
||||
export type SyncOptions = {
|
||||
dryRun?: boolean;
|
||||
/** Match against the whole history of hand-entered rows (one-time), not just the window. */
|
||||
@@ -273,6 +287,8 @@ export type AccountSyncResult = {
|
||||
matched: number;
|
||||
alreadyKnown: number;
|
||||
balance?: string;
|
||||
/** Bank balance minus the tracker's own rows, absorbed by the adjustment row. */
|
||||
adjustmentCents?: number;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
@@ -325,7 +341,10 @@ export async function syncBankAccounts(
|
||||
result.fetched = raw.length;
|
||||
const mapped = raw.map(mapBankTransaction).filter((t): t is MappedTransaction => t !== null);
|
||||
result.booked = mapped.length;
|
||||
if (!mapped.length) continue;
|
||||
if (!mapped.length) {
|
||||
await syncBalance(sql, client, account, result, options, today, log);
|
||||
continue;
|
||||
}
|
||||
|
||||
const ids = mapped.map((t) => t.externalId);
|
||||
const known = new Set(
|
||||
@@ -382,17 +401,7 @@ export async function syncBankAccounts(
|
||||
}
|
||||
});
|
||||
}
|
||||
try {
|
||||
const { balances } = await client.balances(account.external_uid);
|
||||
const preferred =
|
||||
balances.find((b) => b.balance_type === 'CLBD') ??
|
||||
balances.find((b) => b.balance_type === 'ITAV') ??
|
||||
balances[0];
|
||||
if (preferred)
|
||||
result.balance = `${preferred.balance_amount.amount} ${preferred.balance_amount.currency} (${preferred.balance_type})`;
|
||||
} catch {
|
||||
/* balances are informational only */
|
||||
}
|
||||
await syncBalance(sql, client, account, result, options, today, log);
|
||||
} catch (error) {
|
||||
result.error = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
@@ -409,3 +418,58 @@ export async function syncBankAccounts(
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
async function syncBalance(
|
||||
sql: Sql,
|
||||
client: EnableBankingClient,
|
||||
account: { id: number; user_id: string; name: string; external_uid: string },
|
||||
result: AccountSyncResult,
|
||||
options: SyncOptions,
|
||||
today: string,
|
||||
log: (line: string) => void
|
||||
) {
|
||||
let balances: Balance[];
|
||||
try {
|
||||
({ balances } = await client.balances(account.external_uid));
|
||||
} catch (error) {
|
||||
log(`${account.name}: balance unavailable (${error instanceof Error ? error.message : error})`);
|
||||
return;
|
||||
}
|
||||
const chosen = pickBalance(balances);
|
||||
if (!chosen) return;
|
||||
const bankCents = amountToCents(chosen.balance_amount.amount);
|
||||
result.balance = `${chosen.balance_amount.amount} ${chosen.balance_amount.currency} (${chosen.balance_type})`;
|
||||
await sql.begin(async (q) => {
|
||||
const [row] = await q<{ tracked: number; adjustment: number | null; first: string | null }[]>`
|
||||
select (a.opening_balance_cents + coalesce(sum(t.amount_cents) filter (
|
||||
where t.date <= ${today} and t.external_id is distinct from ${BALANCE_ADJUSTMENT_ID}), 0))::int as tracked,
|
||||
(select amount_cents from money.transactions
|
||||
where account_id = a.id and external_id = ${BALANCE_ADJUSTMENT_ID}) as adjustment,
|
||||
min(t.date)::text as first
|
||||
from money.accounts a left join money.transactions t on t.account_id = a.id
|
||||
where a.id = ${account.id} group by a.id`;
|
||||
const needed = bankCents - row.tracked;
|
||||
result.adjustmentCents = needed;
|
||||
if (row.adjustment !== needed)
|
||||
log(
|
||||
`${account.name}: tracker rows sum differs from bank by ${(needed / 100).toFixed(2)}; adjustment ${row.adjustment ?? 'none'} -> ${needed}`
|
||||
);
|
||||
if (options.dryRun) return;
|
||||
await q`
|
||||
update money.accounts set bank_balance_cents = ${bankCents},
|
||||
bank_balance_type = ${chosen.balance_type}, bank_balance_at = now()
|
||||
where id = ${account.id}`;
|
||||
if (row.adjustment === null) {
|
||||
if (needed !== 0)
|
||||
await q`
|
||||
insert into money.transactions
|
||||
(user_id, account_id, date, description, amount_cents, external_id, source, booking_status)
|
||||
values (${account.user_id}, ${account.id}, ${row.first ?? today}, 'Bank balance adjustment',
|
||||
${needed}, ${BALANCE_ADJUSTMENT_ID}, 'enablebanking', 'ADJ')`;
|
||||
} else if (row.adjustment !== needed) {
|
||||
await q`
|
||||
update money.transactions set amount_cents = ${needed}
|
||||
where account_id = ${account.id} and external_id = ${BALANCE_ADJUSTMENT_ID}`;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -32,6 +32,10 @@ export const moneyAccounts = moneySchema.table(
|
||||
// Bank-side account identifier (Enable Banking account uid) for synced accounts.
|
||||
externalUid: text('external_uid'),
|
||||
provider: text('provider').notNull().default('manual'),
|
||||
// Last balance reported by the bank (synced accounts); the source of truth for the total.
|
||||
bankBalanceCents: integer('bank_balance_cents'),
|
||||
bankBalanceType: text('bank_balance_type'),
|
||||
bankBalanceAt: timestamptz('bank_balance_at'),
|
||||
...timestamps
|
||||
},
|
||||
(table) => [
|
||||
|
||||
@@ -367,7 +367,18 @@
|
||||
onclick={() => toggleAccount(account.id)}
|
||||
>{@render swatch(account.id)}<span
|
||||
><strong>{account.name}</strong><small class="capitalize"
|
||||
>{account.kind} · opening {amount(account.openingBalanceCents)}</small
|
||||
>{account.kind} · {#if account.bankBalanceAt}bank {amount(
|
||||
account.bankBalanceCents ?? 0
|
||||
)}, {new Date(account.bankBalanceAt).toLocaleDateString('en-GB', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})}{#if account.bankBalanceCents !== (balances.get(account.id) ?? 0)}<span
|
||||
class="text-destructive"
|
||||
>
|
||||
· differs from tracker</span
|
||||
>{/if}{:else}opening {amount(account.openingBalanceCents)}{/if}</small
|
||||
></span
|
||||
></Button
|
||||
><strong class="item-amount">{@render signed(balances.get(account.id) ?? 0)}</strong
|
||||
|
||||
Reference in New Issue
Block a user