Compare commits
1 Commits
books-ui
...
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,
|
externalIdFor,
|
||||||
findMatch,
|
findMatch,
|
||||||
mapBankTransaction,
|
mapBankTransaction,
|
||||||
|
pickBalance,
|
||||||
planAccountMapping
|
planAccountMapping
|
||||||
} from './bank-sync';
|
} from './bank-sync';
|
||||||
import { createEnableBankingClient, parseAuthCode, type BankTransaction } from './enable-banking';
|
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');
|
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 { createHash } from 'node:crypto';
|
||||||
import type { Sql } from 'postgres';
|
import type { Sql } from 'postgres';
|
||||||
import type {
|
import type {
|
||||||
|
Balance,
|
||||||
BankAccount,
|
BankAccount,
|
||||||
BankTransaction,
|
BankTransaction,
|
||||||
EnableBankingClient,
|
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 = {
|
export type SyncOptions = {
|
||||||
dryRun?: boolean;
|
dryRun?: boolean;
|
||||||
/** Match against the whole history of hand-entered rows (one-time), not just the window. */
|
/** Match against the whole history of hand-entered rows (one-time), not just the window. */
|
||||||
@@ -273,6 +287,8 @@ export type AccountSyncResult = {
|
|||||||
matched: number;
|
matched: number;
|
||||||
alreadyKnown: number;
|
alreadyKnown: number;
|
||||||
balance?: string;
|
balance?: string;
|
||||||
|
/** Bank balance minus the tracker's own rows, absorbed by the adjustment row. */
|
||||||
|
adjustmentCents?: number;
|
||||||
error?: string;
|
error?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -325,7 +341,10 @@ export async function syncBankAccounts(
|
|||||||
result.fetched = raw.length;
|
result.fetched = raw.length;
|
||||||
const mapped = raw.map(mapBankTransaction).filter((t): t is MappedTransaction => t !== null);
|
const mapped = raw.map(mapBankTransaction).filter((t): t is MappedTransaction => t !== null);
|
||||||
result.booked = mapped.length;
|
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 ids = mapped.map((t) => t.externalId);
|
||||||
const known = new Set(
|
const known = new Set(
|
||||||
@@ -382,17 +401,7 @@ export async function syncBankAccounts(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
try {
|
await syncBalance(sql, client, account, result, options, today, log);
|
||||||
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 */
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
result.error = error instanceof Error ? error.message : String(error);
|
result.error = error instanceof Error ? error.message : String(error);
|
||||||
}
|
}
|
||||||
@@ -409,3 +418,58 @@ export async function syncBankAccounts(
|
|||||||
}
|
}
|
||||||
return results;
|
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.
|
// Bank-side account identifier (Enable Banking account uid) for synced accounts.
|
||||||
externalUid: text('external_uid'),
|
externalUid: text('external_uid'),
|
||||||
provider: text('provider').notNull().default('manual'),
|
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
|
...timestamps
|
||||||
},
|
},
|
||||||
(table) => [
|
(table) => [
|
||||||
|
|||||||
@@ -367,7 +367,18 @@
|
|||||||
onclick={() => toggleAccount(account.id)}
|
onclick={() => toggleAccount(account.id)}
|
||||||
>{@render swatch(account.id)}<span
|
>{@render swatch(account.id)}<span
|
||||||
><strong>{account.name}</strong><small class="capitalize"
|
><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
|
></span
|
||||||
></Button
|
></Button
|
||||||
><strong class="item-amount">{@render signed(balances.get(account.id) ?? 0)}</strong
|
><strong class="item-amount">{@render signed(balances.get(account.id) ?? 0)}</strong
|
||||||
|
|||||||
Reference in New Issue
Block a user