Compare commits
1 Commits
money-sync
...
money-bala
| Author | SHA1 | Date | |
|---|---|---|---|
| 2da375451a |
3
drizzle/20261024011919_money_bank_balance/migration.sql
Normal file
3
drizzle/20261024011919_money_bank_balance/migration.sql
Normal file
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE "money"."accounts" ADD COLUMN "bank_balance_cents" integer;--> statement-breakpoint
|
||||
ALTER TABLE "money"."accounts" ADD COLUMN "bank_balance_type" text;--> statement-breakpoint
|
||||
ALTER TABLE "money"."accounts" ADD COLUMN "bank_balance_at" timestamp with time zone;
|
||||
12076
drizzle/20261024011919_money_bank_balance/snapshot.json
Normal file
12076
drizzle/20261024011919_money_bank_balance/snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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