Per-account bank reconciliation + overdue/inbox/privacy fixes (#619)
* feat(reconciliation): scope bank reconciliation per cash account via transactions.cash_account_id A company with two same-currency cash accounts (e.g. checking 1930 + a savings account) saw every SEK transaction on every account, and the status card summed across both — reconciliation filtered transactions by CURRENCY while filtering GL lines by ACCOUNT (issue #604). Bind each bank transaction to the cash_accounts row it settled on: - New nullable transactions.cash_account_id FK (ON DELETE SET NULL — a bank transaction is räkenskapsinformation, BFL 7 kap, and must survive cash-account deletion) + a best-effort 4-pass backfill. - All reconciliation/transaction queries scope to the selected account with a NULL->currency fallback, so legacy/un-backfilled rows never disappear mid-backfill. - ingestTransactions stamps cash_account_id from the batch's settlementAccount; categorize + manualLink resolve and use it. - Bank leg now books to the transaction's actual settlement account via applySettlementAccount (no-op for 1930), so interest/fees on a savings/EUR account reconcile instead of mis-booking to 1930. - manualLink cross-checks the transaction's account and requires a voucher line on the selected account (no silent cross-account links). - BankReconciliationView: quick-book menu for any settlement account, in-flight request abort on account/date switch, 500-row truncation notice, per-account state reset. - pg-real coverage for the FK, all backfill passes, account-scoped query isolation, and cross-company isolation. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(supplier-invoices): stop marking paid invoices and credit notes as overdue update_overdue_supplier_invoices() (the daily pg_cron job) flipped every past-due 'registered'/'approved' row to 'overdue' without looking at the outstanding balance. Credit notes — created 'registered', remaining 0, due today — got flipped the next day, surfacing as "Förfallen" with "kvar att betala 0 kr"; so did any fully-paid invoice left in 'registered'/'approved'. Guard the cron on remaining_amount > 0.005 (the "fully paid" threshold used by the payment/match paths) and is_credit_note = false, and backfill the rows already mis-flagged (credit notes -> 'registered', paid -> 'paid' with paid_at stamped only when missing). pg-real coverage for the guarded function and the one-off backfill. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(invoice-inbox): refresh dokumentinkorg on realtime row changes The InvoiceInboxWorkspace only refetched on mount and on explicit in-component actions. When an inbox item was resolved out of band — the in-app agent sheet committing a staged create_supplier_invoice_from_inbox / book-direct op, the /pending page approving one, or another tab booking it — none of those paths called fetchItems(), so the booked underlag stayed in "Att göra" until a manual reload (issue #600). Add invoice_inbox_items to the supabase_realtime publication (mirrors the /pending fix in 20260520120100) and subscribe in the workspace, refetching the whole list on any change so derived status/counts/ordering stay authoritative. RLS scopes the channel to the user's company. fetchItems now preserves optimistic upload placeholders so a refetch firing mid-upload can't drop an in-flight row. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(privacy): disclose EU AI inference via Amazon Bedrock (eu-north-1) Update the privacy policy and DPA to state that AI inference, when AI features are enabled, runs inside the EU via Amazon Bedrock (eu-north-1, Stockholm) using Anthropic's Claude models — no transfer to a third country, prompts not retained after the call or used for model training. Add AWS as a subprocessor row and refresh the "last updated" dates. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(migrations): rename invoice_inbox_realtime to avoid version collision main's #617 shipped 20260605120000_transactions_original_description.sql — the same version this branch used for the inbox-realtime publication. The Supabase migration tracker keys on the numeric version, not the filename, so the preview branch failed with a duplicate-key error on supabase_migrations.schema_migrations (version 20260605120000 already exists). Rename to the unique version 20260605120500; the body (ALTER PUBLICATION) is order-independent. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(reconciliation): align run guard with status; harden filter interpolation Addresses PR review (greptile + compliance swarm): - The v1 and core bank/run routes rejected an unknown account uniformly, including the default '1930', while the status routes were lenient for '1930'. A company reconciling its primary SEK account without a cash_accounts row got 200 from status but 400 from run. Make run match status: '1930' falls back to currency-only scoping (cashAccountId undefined); non-default unknown accounts are still rejected. Adds a test. - /api/transactions accepts a user-supplied `currency` query param that was interpolated raw into a PostgREST .or() filter. Reject anything that isn't a 3-letter ISO code — RLS already scopes to the company, but an unsanitized value could otherwise malform/widen the filter. Assert currency/cashAccountId shape in scopeTransactionsToAccount as well. - categorize: log (instead of silently swallowing) a cash_accounts settlement-account lookup error, so a fall-back-to-1930 mis-booking is observable in the audit log. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(migrations): correct backfill UPDATE..FROM join; idempotent realtime publication Two SQL errors that only surface on real Postgres (CI pg-real + Supabase preview) — the unit suite mocks Supabase, so neither was caught locally. - Backfill pass (a): `UPDATE transactions t ... FROM journal_entry_lines jel JOIN cash_accounts ca ON ca.company_id = t.company_id` referenced the UPDATE target `t` inside the FROM join's ON clause, which Postgres rejects ("invalid reference to FROM-clause entry for table t"). Move the company match to WHERE; the JOIN now relates jel<->ca only. Semantics unchanged. - invoice_inbox_realtime: `ALTER PUBLICATION ... ADD TABLE` is not idempotent (SQLSTATE 42710 if the table is already a member). The earlier version-collision push partially applied it on the Supabase preview branch, so the re-apply errored. Guard with a pg_publication_tables existence check. Both statements validated against a real Postgres: the single-line tx binds, the two-bank-line transfer stays NULL, and the publication add runs twice cleanly. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(migrations): backfill pass (c) uses array_agg, not min(uuid) Postgres has no min() aggregate for uuid, so pass (c)'s min(id) raised "function min(uuid) does not exist" on apply (CI pg-real + Supabase). The HAVING count(*) = 1 already guarantees one row per group, so (array_agg(id))[1] returns that single id. Validated the full backfill (all four passes) and the overdue migration against a real Postgres: every pass binds / falls through as intended, and the overdue guard + backfill produce the right statuses. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(compliance): add RoPA entry for Amazon Bedrock AI inference (GDPR Art.30) The privacy policy now discloses AI inference (transaction categorization + document/receipt OCR) via Amazon Bedrock as a processing activity, but .compliance/ropa.yaml had no matching Art.30 record. Add it: opt-in consent basis, EU-region (eu-north-1) inference with no third-country transfer, prompts not retained or used for model training. Mirrors the privacy-page disclosure shipped in this PR. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
c6c86cded4
commit
953980c875
@@ -129,6 +129,7 @@ describe('POST /reconciliation/bank/run', () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
cash_accounts: { data: { id: 'ca-1930', currency: 'SEK' }, error: null },
|
||||
}),
|
||||
)
|
||||
const res = await runPOST(
|
||||
@@ -146,7 +147,50 @@ describe('POST /reconciliation/bank/run', () => {
|
||||
expect.anything(),
|
||||
COMPANY_ID,
|
||||
'user-1',
|
||||
expect.objectContaining({ dryRun: false }),
|
||||
expect.objectContaining({ dryRun: false, accountNumber: '1930', cashAccountId: 'ca-1930' }),
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects an unknown settlement account', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
// No matching cash_accounts row for the requested account_number.
|
||||
cash_accounts: { data: null, error: null },
|
||||
}),
|
||||
)
|
||||
const res = await runPOST(
|
||||
postRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/reconciliation/bank/run`, {
|
||||
account_number: '9999',
|
||||
}),
|
||||
{ params: Promise.resolve({ companyId: COMPANY_ID }) },
|
||||
)
|
||||
expect(res.status).toBe(400)
|
||||
expect(runRecMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('runs the default 1930 account even without a cash_accounts row (currency fallback)', async () => {
|
||||
// Mirrors the status endpoint's leniency: the primary SEK account always
|
||||
// reconciles via the currency fallback, so a company without a 1930
|
||||
// cash_accounts row is not blocked from running reconciliation.
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
cash_accounts: { data: null, error: null },
|
||||
}),
|
||||
)
|
||||
const res = await runPOST(
|
||||
postRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/reconciliation/bank/run`, {
|
||||
account_number: '1930',
|
||||
}),
|
||||
{ params: Promise.resolve({ companyId: COMPANY_ID }) },
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
expect(runRecMock).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
COMPANY_ID,
|
||||
'user-1',
|
||||
expect.objectContaining({ accountNumber: '1930', cashAccountId: undefined }),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -154,6 +198,7 @@ describe('POST /reconciliation/bank/run', () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
cash_accounts: { data: { id: 'ca-1930', currency: 'SEK' }, error: null },
|
||||
}),
|
||||
)
|
||||
const res = await runPOST(
|
||||
@@ -207,6 +252,7 @@ describe('GET /reconciliation/bank/status', () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
cash_accounts: { data: { id: 'ca-1930', currency: 'SEK' }, error: null },
|
||||
}),
|
||||
)
|
||||
const res = await statusGET(
|
||||
@@ -223,6 +269,7 @@ describe('GET /reconciliation/bank/status', () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
cash_accounts: { data: { id: 'ca-1930', currency: 'SEK' }, error: null },
|
||||
}),
|
||||
)
|
||||
const res = await statusGET(
|
||||
|
||||
@@ -21,6 +21,10 @@ const RunRequest = z
|
||||
.object({
|
||||
date_from: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
|
||||
date_to: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
|
||||
// Settlement account (BAS code) to reconcile against, e.g. '1930' (SEK) or
|
||||
// '1932' (EUR). Defaults to '1930'. Required for multi-account companies to
|
||||
// reconcile anything other than their primary SEK account.
|
||||
account_number: z.string().regex(/^\d{4}$/).optional(),
|
||||
})
|
||||
// Bound the window so a key with no explicit range can't trigger an
|
||||
// unbounded join across years. 366 days covers a full räkenskapsår + a
|
||||
@@ -67,6 +71,7 @@ registerEndpoint({
|
||||
'Creating new journal entries — this only links bank transactions to existing GL lines. Matching to invoices — use `:match-invoice` or `:match-supplier-invoice` for explicit invoice payments.',
|
||||
pitfalls: [
|
||||
'date_from / date_to default to the company\'s full bank history if omitted. Specify a window for predictable performance.',
|
||||
'account_number defaults to 1930. Multi-account companies must pass the BAS code of the account they are reconciling (e.g. 1932 for a EUR account), or it silently reconciles 1930.',
|
||||
'Idempotency-Key is mandatory.',
|
||||
'matches.confidence is between 0 and 1; the matcher only applies matches above the internal threshold (currently ~0.85).',
|
||||
],
|
||||
@@ -110,11 +115,35 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>(
|
||||
}
|
||||
const body = parsed.data
|
||||
|
||||
// Resolve the settlement account to its cash account (currency + id) so the
|
||||
// matcher scopes transactions to this exact account, not every same-currency
|
||||
// account. The default '1930' is exempt from the existence check — it falls
|
||||
// back to currency-only scoping, matching the status endpoint and the
|
||||
// pre-feature behaviour. A non-default unknown account is rejected.
|
||||
const accountNumber = body.account_number ?? '1930'
|
||||
const { data: cashAccount } = await ctx.supabase
|
||||
.from('cash_accounts')
|
||||
.select('id, currency')
|
||||
.eq('company_id', ctx.companyId!)
|
||||
.eq('ledger_account', accountNumber)
|
||||
.maybeSingle()
|
||||
if (!cashAccount && accountNumber !== '1930') {
|
||||
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: {
|
||||
issues: [{ field: 'account_number', message: 'Okänt kassakonto för det här företaget' }],
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
let result
|
||||
try {
|
||||
result = await runReconciliation(ctx.supabase, ctx.companyId!, ctx.userId, {
|
||||
dateFrom: body.date_from,
|
||||
dateTo: body.date_to,
|
||||
accountNumber,
|
||||
currency: (cashAccount?.currency as string | undefined) ?? 'SEK',
|
||||
cashAccountId: cashAccount?.id as string | undefined,
|
||||
dryRun: ctx.dryRun,
|
||||
})
|
||||
} catch (err) {
|
||||
|
||||
@@ -66,10 +66,13 @@ export const GET = withApiV1<{ params: Promise<{ companyId: string }> }>(
|
||||
const Filters = z.object({
|
||||
date_from: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
|
||||
date_to: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
|
||||
// Settlement account (BAS code), e.g. '1930' / '1932'. Defaults to 1930.
|
||||
account_number: z.string().regex(/^\d{4}$/).optional(),
|
||||
})
|
||||
const parsed = Filters.safeParse({
|
||||
date_from: url.searchParams.get('date_from') ?? undefined,
|
||||
date_to: url.searchParams.get('date_to') ?? undefined,
|
||||
account_number: url.searchParams.get('account_number') ?? undefined,
|
||||
})
|
||||
if (!parsed.success) {
|
||||
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
|
||||
@@ -83,12 +86,31 @@ export const GET = withApiV1<{ params: Promise<{ companyId: string }> }>(
|
||||
})
|
||||
}
|
||||
|
||||
const accountNumber = parsed.data.account_number ?? '1930'
|
||||
const { data: cashAccount } = await ctx.supabase
|
||||
.from('cash_accounts')
|
||||
.select('id, currency')
|
||||
.eq('company_id', ctx.companyId!)
|
||||
.eq('ledger_account', accountNumber)
|
||||
.maybeSingle()
|
||||
if (!cashAccount && accountNumber !== '1930') {
|
||||
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: {
|
||||
issues: [{ field: 'account_number', message: 'Okänt kassakonto för det här företaget' }],
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
const status = await getReconciliationStatus(
|
||||
ctx.supabase,
|
||||
ctx.companyId!,
|
||||
parsed.data.date_from,
|
||||
parsed.data.date_to,
|
||||
accountNumber,
|
||||
(cashAccount?.currency as string | undefined) ?? 'SEK',
|
||||
cashAccount?.id as string | undefined,
|
||||
)
|
||||
return ok(status, { requestId: ctx.requestId })
|
||||
} catch (err) {
|
||||
|
||||
Reference in New Issue
Block a user