From 953980c8753b11a0ea21d0c8fd7d3e223c9c8173 Mon Sep 17 00:00:00 2001 From: Jakob Wennberg <149234542+jakobwennberg@users.noreply.github.com> Date: Mon, 1 Jun 2026 18:26:13 +0200 Subject: [PATCH] Per-account bank reconciliation + overdue/inbox/privacy fixes (#619) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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) * 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) * 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) * 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) * 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) * 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) * 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) * 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) * 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) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .compliance/ropa.yaml | 43 +++ app/(public)/dpa/page.tsx | 6 +- app/(public)/privacy/page.tsx | 27 +- app/api/reconciliation/bank/link/route.ts | 11 +- app/api/reconciliation/bank/run/route.ts | 17 +- app/api/reconciliation/bank/status/route.ts | 4 +- .../[id]/categorize/__tests__/route.test.ts | 13 + app/api/transactions/[id]/categorize/route.ts | 30 ++- app/api/transactions/route.ts | 39 ++- .../bank/__tests__/route.test.ts | 49 +++- .../reconciliation/bank/run/route.ts | 29 ++ .../reconciliation/bank/status/route.ts | 22 ++ .../general/InvoiceInboxWorkspace.tsx | 42 ++- components/reports/BankReconciliationView.tsx | 150 ++++++++--- lib/api/schemas.ts | 7 + .../__tests__/booking-templates.test.ts | 45 ++++ lib/bookkeeping/mapping-engine.ts | 2 +- .../__tests__/bank-reconciliation.test.ts | 65 ++++- lib/reconciliation/bank-reconciliation.ts | 88 +++++- lib/transactions/__tests__/ingest.test.ts | 67 ++++- lib/transactions/ingest.ts | 20 ++ ...500_invoice_inbox_realtime_publication.sql | 39 +++ ...606120000_transactions_cash_account_id.sql | 40 +++ ..._transactions_cash_account_id_backfill.sql | 85 ++++++ ...ice_overdue_skip_paid_and_credit_notes.sql | 66 +++++ tests/helpers.ts | 1 + tests/pg/fixtures.ts | 72 +++++ .../supplier-invoice-overdue-cron.pg.test.ts | 204 ++++++++++++++ .../transactions-cash-account-id.pg.test.ts | 253 ++++++++++++++++++ types/index.ts | 6 + 30 files changed, 1453 insertions(+), 89 deletions(-) create mode 100644 supabase/migrations/20260605120500_invoice_inbox_realtime_publication.sql create mode 100644 supabase/migrations/20260606120000_transactions_cash_account_id.sql create mode 100644 supabase/migrations/20260606120100_transactions_cash_account_id_backfill.sql create mode 100644 supabase/migrations/20260607120000_supplier_invoice_overdue_skip_paid_and_credit_notes.sql create mode 100644 tests/pg/supplier-invoice-overdue-cron.pg.test.ts create mode 100644 tests/pg/transactions-cash-account-id.pg.test.ts diff --git a/.compliance/ropa.yaml b/.compliance/ropa.yaml index de68b919..46bbbffa 100644 --- a/.compliance/ropa.yaml +++ b/.compliance/ropa.yaml @@ -234,3 +234,46 @@ processing_activities: - no_financial_figures_in_body - tls_to_resend - rls_company_scoped + + - id: ai.inference + name: AI-inferens (kategorisering + dokumenttolkning) via Amazon Bedrock + purpose: >- + Föreslå bokföringskategori för banktransaktioner och tolka/extrahera + uppladdade underlag (kvitton, leverantörsfakturor) med Anthropic + Claude-modeller körda i Amazon Bedrock. Endast aktiv när tenanten + uttryckligen aktiverat AI-funktioner — kärntjänsten (bokföring, fakturor, + moms, rapporter) fungerar fullt ut utan AI. + lawful_basis: art_6_1_a # consent — opt-in, AI features off by default + special_category_basis: null + controller: gnubok-tenant + processor: aws-bedrock + data_subjects: + - business_owner + - counterparty # namn/belopp på uppladdade underlag + data_categories: + - user.financial # transaktionsdata skickad för kategorisering + - user.document # uppladdade kvitton/fakturor för OCR/extraktion + recipients: + - name: Amazon Web Services (Amazon Bedrock) + country: EU + role: processor + international_transfers: + applicable: false + mechanism: null + note: >- + Inferens körs i AWS-regionen eu-north-1 (Stockholm) — ingen överföring + till tredje land. AWS DPA + SCC + DPF-certifiering finns som + skyddsmekanism. Prompter lagras ej hos Bedrock efter anropet och används + ej till modellträning. + retention: + duration: none_at_processor + basis: no_retention_bedrock # prompt not persisted by Bedrock post-inference + stored_in: + - ai_usage_tracking # usage metadata only (tokens/cost), no payload body + security_measures: + - opt_in_consent_required + - eu_region_inference_eu_north_1 + - prompts_not_retained_after_inference + - not_used_for_model_training + - rls_company_scoped + - tls_to_bedrock diff --git a/app/(public)/dpa/page.tsx b/app/(public)/dpa/page.tsx index 06285757..ed718e6a 100644 --- a/app/(public)/dpa/page.tsx +++ b/app/(public)/dpa/page.tsx @@ -19,7 +19,7 @@ export default function DPAPage() { Personuppgiftsbitradesavtal (DPA)

- Enligt GDPR Art. 28 | Senast uppdaterad: 2026-03-05 + Enligt GDPR Art. 28 | Senast uppdaterad: 2026-06-01

@@ -81,7 +81,9 @@ export default function DPAPage() {
  • Oföränderlig bokföring: Bokförda verifikationer kan inte ändras eller raderas (databasutlösare)
  • Säkerhetskopior: Kontinuerliga databaskopior med point-in-time-recovery
  • -
  • EU-lagring: All primär datalagring sker i EU (eu-central-1)
  • +
  • EU-lagring och EU-inferens: All primär datalagring sker i EU + (Supabase, eu-central-1). AI-inferens sker, när AI-funktioner är aktiverade, inom + EU via Amazon Bedrock (eu-north-1, Stockholm) — ingen överföring till tredje land
  • diff --git a/app/(public)/privacy/page.tsx b/app/(public)/privacy/page.tsx index 7c81d92d..f1799094 100644 --- a/app/(public)/privacy/page.tsx +++ b/app/(public)/privacy/page.tsx @@ -18,7 +18,7 @@ export default function PrivacyPolicyPage() { Integritetspolicy

    - Senast uppdaterad: 2026-05-28 + Senast uppdaterad: 2026-06-01

    @@ -118,6 +118,21 @@ export default function PrivacyPolicyPage() { EU EU-baserad + + Amazon Web Services (AWS) + + AI-inferens (kategorisering samt dokument- och + kvittotolkning) via Amazon Bedrock. Bearbetar bokföringsdata + och uppladdade underlag — endast när AI-funktioner är + aktiverade. + + EU (eu-north-1, Stockholm) + + EU-baserad inferens — ingen tredjelandsöverföring. DPA, SCC + och DPF-certifiering. Prompter lagras ej efter anropet och + används ej till modellträning. + + Resend Transaktionell e-postleverans @@ -139,8 +154,12 @@ export default function PrivacyPolicyPage() {

    - AI-funktioner (Anthropic, OpenAI) kräver separat samtycke före aktivering. - Data skickas först när du aktivt godkänner användningen. + AI-funktioner är frivilliga och kräver separat samtycke före + aktivering — data skickas först när du aktivt godkänner + användningen. AI:t använder Anthropics Claude-modeller men körs + inom Amazon Bedrock i EU (eu-north-1, Stockholm); datan lämnar + alltså inte EU och delas inte med Anthropic. Kärntjänsten + (bokföring, fakturor, moms och rapporter) fungerar fullt ut utan AI.

    @@ -154,6 +173,8 @@ export default function PrivacyPolicyPage() { Vissa underbiträden är baserade i USA. För dessa överföringar används EU-kommissionens standardavtalsklausuler (SCCs) som skyddsmekanism i enlighet med GDPR kapitel V. All primär datalagring (databas, filer) sker inom EU via Supabase (eu-central-1). + Även AI-inferens sker inom EU (Amazon Bedrock, eu-north-1) och innebär ingen + överföring till tredje land.

    diff --git a/app/api/reconciliation/bank/link/route.ts b/app/api/reconciliation/bank/link/route.ts index 807cfae2..91854780 100644 --- a/app/api/reconciliation/bank/link/route.ts +++ b/app/api/reconciliation/bank/link/route.ts @@ -24,9 +24,16 @@ export async function POST(request: Request) { const validation = await validateBody(request, BankLinkSchema) if (!validation.success) return validation.response - const { transaction_id, journal_entry_id } = validation.data + const { transaction_id, journal_entry_id, account_number } = validation.data - const result = await manualLink(supabase, companyId, transaction_id, journal_entry_id, user.id) + const result = await manualLink( + supabase, + companyId, + transaction_id, + journal_entry_id, + user.id, + account_number ?? '1930', + ) if (!result.success) { return NextResponse.json({ error: result.error }, { status: 400 }) diff --git a/app/api/reconciliation/bank/run/route.ts b/app/api/reconciliation/bank/run/route.ts index d00e796b..6af4266e 100644 --- a/app/api/reconciliation/bank/run/route.ts +++ b/app/api/reconciliation/bank/run/route.ts @@ -28,30 +28,33 @@ export async function POST(request: Request) { const accountNumber = account_number ?? '1930' - // Defense-in-depth: only allow account numbers the company has registered as - // a cash account. Applies uniformly including '1930' — the cash_accounts - // backfill seeds 1930 for every company that had a SEK PSD2 account, and the - // AccountPickerDialog seeds it for new companies on first connection. + // Defense-in-depth: reject a non-default account the company hasn't + // registered as a cash account. The default '1930' is exempt — when no + // cash_accounts row exists it falls back to currency-only scoping + // (cashAccountId undefined), so a company reconciling its primary SEK account + // without a row behaves exactly as before this feature. Matches the status + // endpoint, which is likewise lenient for '1930'. const { data: cashAccount } = await supabase .from('cash_accounts') - .select('currency') + .select('id, currency') .eq('company_id', companyId) .eq('ledger_account', accountNumber) .maybeSingle() - if (!cashAccount) { + if (!cashAccount && accountNumber !== '1930') { return NextResponse.json( { error: 'Okänt kassakonto för det här företaget' }, { status: 400 }, ) } - const currency = (cashAccount.currency as string | undefined) ?? 'SEK' + const currency = (cashAccount?.currency as string | undefined) ?? 'SEK' const result = await runReconciliation(supabase, companyId, user.id, { dateFrom: date_from, dateTo: date_to, accountNumber, currency, + cashAccountId: cashAccount?.id as string | undefined, dryRun: dry_run ?? false, }) diff --git a/app/api/reconciliation/bank/status/route.ts b/app/api/reconciliation/bank/status/route.ts index b9ac7983..a485d90c 100644 --- a/app/api/reconciliation/bank/status/route.ts +++ b/app/api/reconciliation/bank/status/route.ts @@ -23,7 +23,7 @@ export async function GET(request: Request) { // produces nonsense. const { data: cashAccount } = await supabase .from('cash_accounts') - .select('currency') + .select('id, currency') .eq('company_id', companyId) .eq('ledger_account', accountNumber) .maybeSingle() @@ -36,6 +36,7 @@ export async function GET(request: Request) { } const currency = (cashAccount?.currency as string | undefined) ?? 'SEK' + const cashAccountId = cashAccount?.id as string | undefined const status = await getReconciliationStatus( supabase, @@ -44,6 +45,7 @@ export async function GET(request: Request) { dateTo, accountNumber, currency, + cashAccountId, ) return NextResponse.json({ data: status }) diff --git a/app/api/transactions/[id]/categorize/__tests__/route.test.ts b/app/api/transactions/[id]/categorize/__tests__/route.test.ts index 0b20981e..305c06dc 100644 --- a/app/api/transactions/[id]/categorize/__tests__/route.test.ts +++ b/app/api/transactions/[id]/categorize/__tests__/route.test.ts @@ -41,6 +41,19 @@ vi.mock('@/lib/bookkeeping/transaction-entries', () => ({ const mockSaveUserMappingRule = vi.fn() vi.mock('@/lib/bookkeeping/mapping-engine', () => ({ saveUserMappingRule: (...args: unknown[]) => mockSaveUserMappingRule(...args), + // Mirror the real implementation: rewrite a 1930 bank leg to the settlement + // account, no-op when the settlement account is 1930. + applySettlementAccount: ( + result: { debit_account?: string; credit_account?: string }, + bankAccount: string, + ) => + bankAccount === '1930' + ? result + : { + ...result, + debit_account: result.debit_account === '1930' ? bankAccount : result.debit_account, + credit_account: result.credit_account === '1930' ? bankAccount : result.credit_account, + }, })) vi.mock('@/lib/bookkeeping/counterparty-templates', () => ({ diff --git a/app/api/transactions/[id]/categorize/route.ts b/app/api/transactions/[id]/categorize/route.ts index 91919ab1..8fcadd19 100644 --- a/app/api/transactions/[id]/categorize/route.ts +++ b/app/api/transactions/[id]/categorize/route.ts @@ -5,7 +5,7 @@ import { ensureInitialized } from '@/lib/init' import { buildMappingResultFromCategory } from '@/lib/bookkeeping/category-mapping' import { getTemplateById, buildMappingResultFromTemplate, validateTemplateForEntity } from '@/lib/bookkeeping/booking-templates' import { createTransactionJournalEntry } from '@/lib/bookkeeping/transaction-entries' -import { saveUserMappingRule } from '@/lib/bookkeeping/mapping-engine' +import { saveUserMappingRule, applySettlementAccount } from '@/lib/bookkeeping/mapping-engine' import { upsertCounterpartyTemplate, buildMappingResultFromCounterpartyTemplate } from '@/lib/bookkeeping/counterparty-templates' import { withRouteContext } from '@/lib/api/with-route-context' import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error' @@ -226,6 +226,34 @@ export const POST = withRouteContext( ) } + // Book the bank leg against the transaction's ACTUAL settlement account + // rather than the hardcoded 1930 in the templates. Without this, interest + // or fees that landed on a savings/EUR account mis-book to 1930 and the + // real bank line never reconciles. applySettlementAccount only rewrites a + // 1930 leg and is a no-op when the settlement account is 1930 — so legacy + // rows with no cash_account_id behave exactly as before. + let settlementAccount = '1930' + if (transaction.cash_account_id) { + const { data: txCashAccount, error: cashAccountError } = await supabase + .from('cash_accounts') + .select('ledger_account') + .eq('id', transaction.cash_account_id) + .eq('company_id', companyId) + .maybeSingle() + if (cashAccountError) { + // Don't fail the booking — fall back to 1930 — but surface the lookup + // failure so a silent mis-booking to the wrong bank leg stays auditable. + txLog.warn('settlement-account lookup failed; defaulting to 1930', { + cashAccountId: transaction.cash_account_id, + error: cashAccountError.message, + }) + } + if (txCashAccount?.ledger_account) { + settlementAccount = txCashAccount.ledger_account as string + } + } + mappingResult = applySettlementAccount(mappingResult, settlementAccount) + txLog.info('mapping resolved', { debit: mappingResult.debit_account, credit: mappingResult.credit_account, diff --git a/app/api/transactions/route.ts b/app/api/transactions/route.ts index 911ff207..709fa5f3 100644 --- a/app/api/transactions/route.ts +++ b/app/api/transactions/route.ts @@ -18,28 +18,39 @@ export async function GET(request: Request) { const unmatched = searchParams.get('unmatched') === 'true' const reconciled = searchParams.get('reconciled') === 'true' const currency = searchParams.get('currency') || undefined + // currency is interpolated into the PostgREST .or() filter below, so reject + // anything that isn't a 3-letter ISO code. RLS still scopes results to the + // company, but an unsanitized value could otherwise malform or widen the + // filter (PostgREST filter injection). + if (currency && !/^[A-Z]{3}$/.test(currency)) { + return NextResponse.json({ error: 'Ogiltig valutakod' }, { status: 400 }) + } const dateFrom = searchParams.get('date_from') || undefined const dateTo = searchParams.get('date_to') || undefined // When set, return only ignored rows — used by the reconciliation view to // surface a "Visa ignorerade" undo list. The default (no param) behaviour // continues to exclude ignored rows from unmatched results. const onlyIgnored = searchParams.get('only_ignored') === 'true' - // account_number is accepted for API symmetry with the reconciliation status - // endpoint; transactions don't carry a cash_account FK today (PSD2 account - // identity is embedded in external_id), so we use it to derive a default - // currency when the caller didn't supply one. Anything more precise needs - // the cash_account_id backfill tracked as Tier 4. + // account_number selects which cash account to scope to. We resolve it to a + // cash_accounts.id (ledger_account is unique per company) and scope + // transactions by that id, falling back to currency for legacy rows whose + // cash_account_id hasn't been backfilled yet. This is what stops two + // same-currency accounts from showing each other's transactions. const accountNumberParam = searchParams.get('account_number') || undefined let derivedCurrency = currency - if (!derivedCurrency && accountNumberParam) { + let cashAccountId: string | undefined + if (accountNumberParam) { const { data: cashAccount } = await supabase .from('cash_accounts') - .select('currency') + .select('id, currency') .eq('company_id', companyId) .eq('ledger_account', accountNumberParam) .maybeSingle() - if (cashAccount?.currency) derivedCurrency = cashAccount.currency as string + if (cashAccount) { + cashAccountId = cashAccount.id as string + if (!derivedCurrency && cashAccount.currency) derivedCurrency = cashAccount.currency as string + } } let query = supabase @@ -60,7 +71,17 @@ export async function GET(request: Request) { if (onlyIgnored) query = query.eq('is_ignored', true) - if (derivedCurrency) query = query.eq('currency', derivedCurrency) + // Scope to the selected cash account. With a resolved id, match that account + // OR legacy NULL rows of the same currency (so nothing disappears mid- + // backfill). With only a currency (no account), filter by currency. With + // neither (e.g. the company-wide only_ignored recovery list), no scope. + if (cashAccountId) { + query = query.or( + `cash_account_id.eq.${cashAccountId},and(cash_account_id.is.null,currency.eq.${derivedCurrency ?? 'SEK'})`, + ) + } else if (derivedCurrency) { + query = query.eq('currency', derivedCurrency) + } if (dateFrom) query = query.gte('date', dateFrom) if (dateTo) query = query.lte('date', dateTo) diff --git a/app/api/v1/companies/[companyId]/reconciliation/bank/__tests__/route.test.ts b/app/api/v1/companies/[companyId]/reconciliation/bank/__tests__/route.test.ts index dffec36e..99a020ca 100644 --- a/app/api/v1/companies/[companyId]/reconciliation/bank/__tests__/route.test.ts +++ b/app/api/v1/companies/[companyId]/reconciliation/bank/__tests__/route.test.ts @@ -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( diff --git a/app/api/v1/companies/[companyId]/reconciliation/bank/run/route.ts b/app/api/v1/companies/[companyId]/reconciliation/bank/run/route.ts index 25967eab..29ebe3be 100644 --- a/app/api/v1/companies/[companyId]/reconciliation/bank/run/route.ts +++ b/app/api/v1/companies/[companyId]/reconciliation/bank/run/route.ts @@ -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) { diff --git a/app/api/v1/companies/[companyId]/reconciliation/bank/status/route.ts b/app/api/v1/companies/[companyId]/reconciliation/bank/status/route.ts index 49038612..5f834152 100644 --- a/app/api/v1/companies/[companyId]/reconciliation/bank/status/route.ts +++ b/app/api/v1/companies/[companyId]/reconciliation/bank/status/route.ts @@ -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) { diff --git a/components/extensions/general/InvoiceInboxWorkspace.tsx b/components/extensions/general/InvoiceInboxWorkspace.tsx index 91507349..9e8c3bcf 100644 --- a/components/extensions/general/InvoiceInboxWorkspace.tsx +++ b/components/extensions/general/InvoiceInboxWorkspace.tsx @@ -27,6 +27,7 @@ import { } from 'lucide-react' import Link from 'next/link' import { cn, formatCurrency } from '@/lib/utils' +import { createClient } from '@/lib/supabase/client' import type { WorkspaceComponentProps } from '@/lib/extensions/workspace-registry' import type { InvoiceExtractionResult } from '@/types' import BookDirectlyDialog from '@/components/extensions/general/BookDirectlyDialog' @@ -217,7 +218,19 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) { try { const res = await fetch('/api/extensions/ext/invoice-inbox/items?limit=500') const json = await res.json() - if (res.ok) setItems(json.data?.items ?? []) + if (res.ok) { + const serverItems: InboxItem[] = json.data?.items ?? [] + // Preserve optimistic upload placeholders that haven't resolved to a + // server row yet. A refetch can now fire mid-upload (a realtime event + // from an unrelated booking), and a wholesale replace would briefly + // drop the in-flight placeholder. Placeholders carry a `temp-` id that + // never collides with a real row, and uploadFile() removes its own + // placeholder before its fetchItems(), so this never duplicates. + setItems((prev) => { + const pending = prev.filter((it) => it.isPlaceholder) + return pending.length > 0 ? [...pending, ...serverItems] : serverItems + }) + } } catch (err) { console.error('[invoice-inbox] fetchItems failed:', err) } finally { @@ -252,6 +265,33 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) { .catch(() => { /* keep 'accrual' default */ }) }, [fetchItems, fetchInboxAddress]) + // Realtime: refetch when any invoice_inbox_items row changes for this + // company. The inbox is routinely resolved "out of band" — the in-app agent + // sheet commits a staged create_supplier_invoice_from_inbox / book-direct + // operation, the /pending page approves one, or another tab books it — and + // none of those paths call this component's fetchItems(). Without this, a + // booked underlag stayed in "Att göra" until a manual reload (issue #600). + // RLS scopes the channel to the user's company, so we never receive other + // tenants' events; we refetch the whole list (rather than patch in place) so + // the derived status, count pills, and ordering stay authoritative. Mirrors + // the /pending page subscription (app/(dashboard)/pending/page.tsx). + useEffect(() => { + const supabase = createClient() + const channel = supabase + .channel('invoice_inbox_items:list') + .on( + 'postgres_changes', + { event: '*', schema: 'public', table: 'invoice_inbox_items' }, + () => { + fetchItems() + } + ) + .subscribe() + return () => { + void supabase.removeChannel(channel) + } + }, [fetchItems]) + // Read the onboarding-dismissed flag from localStorage after mount // (SSR-safe — no window access during initial render). useEffect(() => { diff --git a/components/reports/BankReconciliationView.tsx b/components/reports/BankReconciliationView.tsx index 98b0ffb5..d22c5971 100644 --- a/components/reports/BankReconciliationView.tsx +++ b/components/reports/BankReconciliationView.tsx @@ -15,6 +15,8 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' import { @@ -37,6 +39,27 @@ const METHOD_LABELS: Record = { manual: 'Manuell', } +// One-click bookings for transactions with no upstream invoice/voucher to match +// against — the common "stuck on the unmatched list" cause (small ränteintäkter, +// bankavgifter, valutakursdifferenser). These reuse the existing bank_finance +// booking templates; the categorize endpoint rewrites the bank leg to the +// transaction's actual settlement account, so they book correctly on ANY cash +// account (1930, a savings account, a EUR account…), not just 1930. +// `account` is the non-bank leg (revenue/cost) — the bank leg is the selected +// account. Income templates apply to positive amounts, expense to negative. +const QUICK_BOOK_TEMPLATES: { + id: string + label: string + account: string + direction: 'income' | 'expense' +}[] = [ + { id: 'bank_interest_income', label: 'ränteintäkt', account: '8310', direction: 'income' }, + { id: 'bank_currency_gain', label: 'valutakursvinst', account: '3960', direction: 'income' }, + { id: 'bank_fees', label: 'bankavgift', account: '6570', direction: 'expense' }, + { id: 'bank_interest_expense', label: 'räntekostnad', account: '8410', direction: 'expense' }, + { id: 'bank_currency_loss', label: 'valutakursförlust', account: '7960', direction: 'expense' }, +] + // ============================================================ // Types // ============================================================ @@ -282,6 +305,13 @@ export function BankReconciliationView() { const [showIgnored, setShowIgnored] = useState(true) const [ignoredTx, setIgnoredTx] = useState([]) const [selectedMatch, setSelectedMatch] = useState>({}) + // True when the unmatched list hit the API's 500-row cap — surfaced so a long + // date range doesn't silently hide rows and let the user think they're done. + const [unmatchedTruncated, setUnmatchedTruncated] = useState(false) + // Aborts the previous in-flight load when the account/date filters change, so + // a slow stale response can't overwrite the freshly-selected account's data + // (the intermittent "flips between accounts" bug). + const fetchAbortRef = useRef(null) const { dialogProps: confirmDialogProps, confirm } = useDestructiveConfirm() const { toast } = useToast() @@ -308,6 +338,14 @@ export function BankReconciliationView() { }, []) const fetchAll = useCallback(async () => { + // Cancel any in-flight load — it may be for a different account. Without + // this, switching accounts quickly lets an older response land last and + // overwrite the current account's data. + fetchAbortRef.current?.abort() + const controller = new AbortController() + fetchAbortRef.current = controller + const { signal } = controller + setLoading(true) setError(null) try { @@ -326,10 +364,10 @@ export function BankReconciliationView() { const reconciledQs = `?reconciled=true&${txParams}` const [statusRes, glRes, unmatchedRes, matchedRes] = await Promise.all([ - fetch(`/api/reconciliation/bank/status${qs}`), - fetch(`/api/reconciliation/bank/unmatched-entries${qs}`), - fetch(`/api/transactions${unmatchedQs}`), - fetch(`/api/transactions${reconciledQs}`), + fetch(`/api/reconciliation/bank/status${qs}`, { signal }), + fetch(`/api/reconciliation/bank/unmatched-entries${qs}`, { signal }), + fetch(`/api/transactions${unmatchedQs}`, { signal }), + fetch(`/api/transactions${reconciledQs}`, { signal }), ]) const [statusData, glData, unmatchedData, matchedData] = await Promise.all([ @@ -339,10 +377,15 @@ export function BankReconciliationView() { matchedRes.json(), ]) + // A newer load superseded this one while we awaited — discard these + // stale results rather than clobber the current account's data. + if (signal.aborted) return + if (statusData.data) setStatus(statusData.data) setGlLines(glData.data || []) setUnmatchedTx(unmatchedData.data || []) setMatchedTx(matchedData.data || []) + setUnmatchedTruncated(Boolean(unmatchedData.has_more)) // Refresh the ignored list whenever the main lists refresh. // Deliberately NOT filtered by account or currency — if a user ignored @@ -351,17 +394,21 @@ export function BankReconciliationView() { // keeps the Återställ path reachable from any account selection. The // date filter is also dropped so old ignores stay visible. try { - const ignoredRes = await fetch(`/api/transactions?unmatched=true&only_ignored=true`) + const ignoredRes = await fetch(`/api/transactions?unmatched=true&only_ignored=true`, { signal }) const ignoredData = await ignoredRes.json() - setIgnoredTx(ignoredData.data || []) + if (!signal.aborted) setIgnoredTx(ignoredData.data || []) } catch { - setIgnoredTx([]) + if (!signal.aborted) setIgnoredTx([]) } } catch (e) { + // Aborts are expected when the user switches account/date quickly. + if (signal.aborted || (e instanceof DOMException && e.name === 'AbortError')) return console.error('[reconciliation] fetchAll failed', e) setError('Kunde inte hämta avstämningsdata') } finally { - setLoading(false) + // Only the latest load owns the spinner; a superseded load must not flip + // it off while the fresh one is still running. + if (!signal.aborted) setLoading(false) } }, [dateFrom, dateTo, accountNumber, accountCurrency]) @@ -369,6 +416,14 @@ export function BankReconciliationView() { fetchAll() }, [fetchAll]) + // Reset transient per-account UI state when the selected account changes. A + // verifikation pick or a dry-run preview computed for the previous account is + // meaningless against the new one — and applying it would cross-link. + useEffect(() => { + setSelectedMatch({}) + setDryRunResults(null) + }, [accountNumber]) + const handleDryRun = async () => { setRunLoading(true) setDryRunResults(null) @@ -428,6 +483,7 @@ export function BankReconciliationView() { body: JSON.stringify({ transaction_id: transactionId, journal_entry_id: journalEntryId, + account_number: accountNumber, }), }) const result = await res.json() @@ -470,13 +526,15 @@ export function BankReconciliationView() { } /** - * Inline shortcut for the most common "stuck on the unmatched list" cause: - * a small ränteintäkt that has no upstream voucher to match against. Calls - * the standard categorize endpoint with the existing bank_interest_income + * Inline one-click booking for an unmatched transaction with no upstream + * voucher to match against (ränteintäkter, bankavgifter, valutakurs- + * differenser). Calls the standard categorize endpoint with a bank_finance * template so the resulting verifikation is identical to the /transactions - * flow — no parallel booking path. + * flow — no parallel booking path. The categorize endpoint rewrites the bank + * leg to the transaction's actual settlement account, so this is correct on + * any cash account. */ - const handleBookInterestIncome = async (transactionId: string) => { + const handleQuickBook = async (transactionId: string, templateId: string) => { setActionLoading(transactionId) try { const res = await fetch(`/api/transactions/${transactionId}/categorize`, { @@ -484,13 +542,13 @@ export function BankReconciliationView() { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ is_business: true, - template_id: 'bank_interest_income', + template_id: templateId, confirm_no_match: true, }), }) const result = await res.json() if (!res.ok || result.error) { - setError(result.error?.message || result.error || 'Kunde inte bokföra ränteintäkten') + setError(result.error?.message || result.error || 'Kunde inte bokföra transaktionen') return } if (result.journal_entry_error) { @@ -499,7 +557,7 @@ export function BankReconciliationView() { } await fetchAll() } catch { - setError('Kunde inte bokföra ränteintäkten') + setError('Kunde inte bokföra transaktionen') } finally { setActionLoading(null) } @@ -773,14 +831,21 @@ export function BankReconciliationView() {

    )} + {unmatchedTruncated && ( +

    + Visar de senaste 500 transaktionerna — begränsa datumintervallet för att se fler. +

    + )}
    {unmatchedTx.map((tx) => { - // Piggy-bank shortcut hardcodes the 1930↔8310 ränteintäkt template, - // so only offer it on a SEK account using 1930. On EUR (1932) or - // other settlement accounts the booking would post the EUR amount - // to the SEK cash account — silently wrong, hide it. - const canBookInterest = tx.amount > 0 && accountNumber === '1930' const isPositive = tx.amount > 0 + // Quick-book options matching the transaction's direction. The + // bank leg books to the SELECTED account (the categorize endpoint + // rewrites it from the cash_account_id), so these are correct on + // any account, not just 1930. + const quickBooks = QUICK_BOOK_TEMPLATES.filter((t) => + isPositive ? t.direction === 'income' : t.direction === 'expense', + ) return (
    - {canBookInterest && ( - handleBookInterestIncome(tx.id)} - disabled={actionLoading === tx.id} - > - -
    - Bokför som ränteintäkt - - 1930 mot 8310, ingen moms - -
    -
    + {quickBooks.length > 0 && ( + <> + + Bokför direkt + + {quickBooks.map((t) => { + // Read as "debit mot credit": income debits the + // bank (selected account), credits revenue; + // expense debits the cost account, credits bank. + const legs = isPositive + ? `${accountNumber} mot ${t.account}` + : `${t.account} mot ${accountNumber}` + return ( + handleQuickBook(tx.id, t.id)} + disabled={actionLoading === tx.id} + > + +
    + Bokför som {t.label} + + {legs} + +
    +
    + ) + })} + + )} handleIgnore(tx)} diff --git a/lib/api/schemas.ts b/lib/api/schemas.ts index 6052461a..6d5bcbf4 100644 --- a/lib/api/schemas.ts +++ b/lib/api/schemas.ts @@ -952,6 +952,13 @@ export const UpdateAccountSchema = z.object({ export const BankLinkSchema = z.object({ transaction_id: uuid, journal_entry_id: uuid, + // Settlement account being reconciled. The voucher must have a line on this + // account and the transaction must belong to it. Defaults to '1930' in the + // route for back-compat. + account_number: z + .string() + .regex(/^[0-9]{4}$/, 'Kontonummer måste vara 4 siffror') + .optional(), }) export const BankUnlinkSchema = z.object({ diff --git a/lib/bookkeeping/__tests__/booking-templates.test.ts b/lib/bookkeeping/__tests__/booking-templates.test.ts index 784064fd..2619a721 100644 --- a/lib/bookkeeping/__tests__/booking-templates.test.ts +++ b/lib/bookkeeping/__tests__/booking-templates.test.ts @@ -15,6 +15,7 @@ import { stripBankNoise, type BookingTemplate, } from '../booking-templates' +import { applySettlementAccount } from '../mapping-engine' // ============================================================ // Template Data Integrity @@ -801,3 +802,47 @@ describe('new and split templates', () => { expect(t!.deductibility_note_sv).toContain('46 kr/person') }) }) + +// ============================================================ +// applySettlementAccount — bank-leg routing for non-1930 accounts +// ============================================================ + +describe('applySettlementAccount (bank-leg routing)', () => { + it('routes the bank_interest_income debit leg to the transaction settlement account', () => { + const template = getTemplateById('bank_interest_income') + expect(template).toBeDefined() + const tx = makeTransaction({ amount: 50, currency: 'SEK' }) + const base = buildMappingResultFromTemplate(template!, tx, 'enskild_firma') + // Template hardcodes 1930 as the bank leg. + expect(base.debit_account).toBe('1930') + expect(base.credit_account).toBe('8310') + + // Interest that landed on a savings account mapped to 1931 must debit 1931, + // not 1930 — otherwise the real bank transaction never reconciles. + const routed = applySettlementAccount(base, '1931') + expect(routed.debit_account).toBe('1931') + expect(routed.credit_account).toBe('8310') + }) + + it('routes the bank_fees credit leg to the transaction settlement account', () => { + const template = getTemplateById('bank_fees') + expect(template).toBeDefined() + const tx = makeTransaction({ amount: -29, currency: 'SEK' }) + const base = buildMappingResultFromTemplate(template!, tx, 'enskild_firma') + expect(base.debit_account).toBe('6570') + expect(base.credit_account).toBe('1930') + + const routed = applySettlementAccount(base, '1931') + expect(routed.debit_account).toBe('6570') + expect(routed.credit_account).toBe('1931') + }) + + it('is a no-op when the settlement account is 1930 (legacy/unresolved rows)', () => { + const template = getTemplateById('bank_interest_income')! + const tx = makeTransaction({ amount: 50, currency: 'SEK' }) + const base = buildMappingResultFromTemplate(template, tx, 'enskild_firma') + const routed = applySettlementAccount(base, '1930') + expect(routed.debit_account).toBe('1930') + expect(routed.credit_account).toBe('8310') + }) +}) diff --git a/lib/bookkeeping/mapping-engine.ts b/lib/bookkeeping/mapping-engine.ts index 6b17e943..dadaa383 100644 --- a/lib/bookkeeping/mapping-engine.ts +++ b/lib/bookkeeping/mapping-engine.ts @@ -374,7 +374,7 @@ function buildOwnAccountTransferResult( * This allows mapping rules and templates that don't explicitly set a bank account * to work correctly with secondary bank accounts (e.g. 1931). */ -function applySettlementAccount(result: MappingResult, bankAccount: string): MappingResult { +export function applySettlementAccount(result: MappingResult, bankAccount: string): MappingResult { if (bankAccount === '1930') return result return { ...result, diff --git a/lib/reconciliation/__tests__/bank-reconciliation.test.ts b/lib/reconciliation/__tests__/bank-reconciliation.test.ts index 1e9dafc9..ccb978aa 100644 --- a/lib/reconciliation/__tests__/bank-reconciliation.test.ts +++ b/lib/reconciliation/__tests__/bank-reconciliation.test.ts @@ -440,39 +440,84 @@ describe('manualLink', () => { expect(result.error).toBe('Transaction is already linked to a journal entry') }) - it('rejects when journal entry has no 1930 line', async () => { + it('rejects when journal entry has no line on the selected account', async () => { const { supabase, enqueue } = createQueueMockSupabase() const tx = makeTransaction({ id: 'tx-1', journal_entry_id: null }) - // Transaction found + // Transaction found (cash_account_id null → cross-check skipped) enqueue({ data: tx }) // Journal entry found enqueue({ data: { id: 'je-1', user_id: 'company-1', status: 'posted' } }) - // No 1930 lines + // No line on the selected account enqueue({ data: [] }) - const result = await manualLink(supabase as never, 'company-1', 'tx-1', 'je-1', 'user-1') + const result = await manualLink(supabase as never, 'company-1', 'tx-1', 'je-1', 'user-1', '1930') expect(result.success).toBe(false) - expect(result.error).toBe('Verifikationen saknar rad på bankkonto (19xx)') + expect(result.error).toBe('Verifikationen saknar rad på 1930') }) - it('succeeds when all validations pass', async () => { + it('rejects when the transaction belongs to a different cash account', async () => { + const { supabase, enqueue } = createQueueMockSupabase() + const tx = makeTransaction({ + id: 'tx-1', + journal_entry_id: null, + cash_account_id: 'ca-1931', + }) + + // Transaction found (bound to a cash account) + enqueue({ data: tx }) + // Journal entry found + posted + enqueue({ data: { id: 'je-1', user_id: 'company-1', status: 'posted' } }) + // Cross-check: this cash account maps to 1931, but we're reconciling 1930 + enqueue({ data: { ledger_account: '1931' } }) + + const result = await manualLink(supabase as never, 'company-1', 'tx-1', 'je-1', 'user-1', '1930') + + expect(result.success).toBe(false) + expect(result.error).toBe('Transaktionen hör till 1931, inte 1930') + }) + + it('succeeds when all validations pass (line on selected account)', async () => { const { supabase, enqueue } = createQueueMockSupabase() const tx = makeTransaction({ id: 'tx-1', journal_entry_id: null }) - // Transaction found + // Transaction found (cash_account_id null → cross-check skipped) enqueue({ data: tx }) // Journal entry found enqueue({ data: { id: 'je-1', user_id: 'company-1', status: 'posted' } }) - // 1930 line exists - enqueue({ data: [{ debit_amount: 1000, credit_amount: 0 }] }) + // Line exists on the selected account + enqueue({ data: [{ debit_amount: 1000, credit_amount: 0, account_number: '1930' }] }) // No existing link enqueue({ data: null, error: null }) // Update succeeds enqueue({ data: null, error: null }) - const result = await manualLink(supabase as never, 'company-1', 'tx-1', 'je-1', 'user-1') + const result = await manualLink(supabase as never, 'company-1', 'tx-1', 'je-1', 'user-1', '1930') + + expect(result.success).toBe(true) + }) + + it('succeeds for a bound transaction when the account matches', async () => { + const { supabase, enqueue } = createQueueMockSupabase() + const tx = makeTransaction({ + id: 'tx-1', + journal_entry_id: null, + cash_account_id: 'ca-1930', + }) + + enqueue({ data: tx }) + enqueue({ data: { id: 'je-1', user_id: 'company-1', status: 'posted' } }) + // Cross-check: cash account maps to the account being reconciled + enqueue({ data: { ledger_account: '1930' } }) + // Line exists on 1930 + enqueue({ data: [{ debit_amount: 1000, credit_amount: 0, account_number: '1930' }] }) + // No existing link + enqueue({ data: null, error: null }) + // Update succeeds + enqueue({ data: null, error: null }) + + const result = await manualLink(supabase as never, 'company-1', 'tx-1', 'je-1', 'user-1', '1930') expect(result.success).toBe(true) }) diff --git a/lib/reconciliation/bank-reconciliation.ts b/lib/reconciliation/bank-reconciliation.ts index c75fe200..1ef166c1 100644 --- a/lib/reconciliation/bank-reconciliation.ts +++ b/lib/reconciliation/bank-reconciliation.ts @@ -81,6 +81,45 @@ export interface ReconciliationOptions { * cash account so EUR transactions reconcile against 1932 etc. */ currency?: string + /** + * cash_accounts.id of the selected account. When set, transactions are + * scoped to this exact account (with a currency fallback for legacy rows + * whose cash_account_id hasn't been backfilled yet) instead of being matched + * by currency alone — this is what stops two same-currency accounts (e.g. + * checking 1930 + savings 1931) from pooling together. Omit for the legacy + * currency-only behaviour. + */ + cashAccountId?: string +} + +/** + * Scope a transactions query builder to a single cash account, tolerating + * legacy rows that predate the cash_account_id backfill: + * cash_account_id = X OR (cash_account_id IS NULL AND currency = cur) + * A bound row shows only on its own account; an unbound row falls back to + * currency so nothing disappears mid-backfill. When cashAccountId is omitted + * we keep the pure currency filter (back-compat). + */ +function scopeTransactionsToAccount(query: Q, cashAccountId: string | undefined, currency: string): Q { + // Both values are interpolated into a raw PostgREST filter string below. They + // are DB-derived in every caller (cash_accounts.id / .currency, or the 'SEK' + // default), never raw user input — but assert their shape anyway so a future + // caller cannot thread an unsanitized value through into the filter. + if (!/^[A-Z]{3}$/.test(currency)) { + throw new Error(`scopeTransactionsToAccount: invalid currency ${JSON.stringify(currency)}`) + } + if (cashAccountId) { + if (!/^[0-9a-fA-F-]{36}$/.test(cashAccountId)) { + throw new Error('scopeTransactionsToAccount: invalid cashAccountId (expected UUID)') + } + return query.or( + `cash_account_id.eq.${cashAccountId},and(cash_account_id.is.null,currency.eq.${currency})`, + ) + } + return query.eq('currency', currency) } // ============================================================ @@ -175,19 +214,20 @@ export async function runReconciliation( dryRun = false, accountNumber = '1930', currency = 'SEK', + cashAccountId, } = options // Fetch unlinked GL lines via RPC const glLines = await fetchUnlinkedGLLines(supabase, companyId, accountNumber, dateFrom, dateTo) - // Fetch unmatched transactions + // Fetch unmatched transactions, scoped to the selected cash account. let query = supabase .from('transactions') .select('*') .eq('company_id', companyId) .is('journal_entry_id', null) .eq('is_ignored', false) - .eq('currency', currency) + query = scopeTransactionsToAccount(query, cashAccountId, currency) if (dateFrom) query = query.gte('date', dateFrom) if (dateTo) query = query.lte('date', dateTo) @@ -268,16 +308,19 @@ export async function getReconciliationStatus( dateTo?: string, bankAccount = '1930', currency: string = 'SEK', + cashAccountId?: string, ): Promise { - // Get all transactions in range. Ignored rows are pulled too so the totals - // card still reflects what the bank actually moved, but they're excluded - // from the "unmatched" count below — the user has explicitly said they - // don't want them surfacing as something to reconcile. + // Get all transactions in range, scoped to the selected cash account. Ignored + // rows are pulled too so the totals card still reflects what the bank + // actually moved, but they're excluded from the "unmatched" count below — the + // user has explicitly said they don't want them surfacing as something to + // reconcile. Scoping by cash account (not just currency) is what stops a + // second same-currency account from inflating bankTotal here. let txQuery = supabase .from('transactions') .select('amount, journal_entry_id, reconciliation_method, is_ignored') .eq('company_id', companyId) - .eq('currency', currency) + txQuery = scopeTransactionsToAccount(txQuery, cashAccountId, currency) if (dateFrom) txQuery = txQuery.gte('date', dateFrom) if (dateTo) txQuery = txQuery.lte('date', dateTo) @@ -397,7 +440,8 @@ export async function manualLink( companyId: string, transactionId: string, journalEntryId: string, - userId: string + userId: string, + accountNumber: string = '1930', ): Promise<{ success: boolean; error?: string }> { // Fetch transaction const { data: tx, error: txError } = await supabase @@ -431,16 +475,36 @@ export async function manualLink( return { success: false, error: 'Journal entry is not posted' } } - // Check for a bank account line (19xx class accounts) + // Defense-in-depth: the transaction must belong to the account being + // reconciled. A transaction bound to 1930 must not be linked against a 1931 + // voucher even if the caller passes accountNumber=1931. Legacy rows with no + // cash_account_id fall through (the UI list already gates them by currency). + if (tx.cash_account_id) { + const { data: txCa } = await supabase + .from('cash_accounts') + .select('ledger_account') + .eq('id', tx.cash_account_id) + .eq('company_id', companyId) + .maybeSingle() + if (txCa?.ledger_account && txCa.ledger_account !== accountNumber) { + return { + success: false, + error: `Transaktionen hör till ${txCa.ledger_account}, inte ${accountNumber}`, + } + } + } + + // Check for a bank account line on the SELECTED settlement account. The old + // "any 19xx line" check let a 1930 transaction link to a voucher that only + // touched 1931 — a cross-account link that silently hides a real imbalance. const { data: lines } = await supabase .from('journal_entry_lines') .select('debit_amount, credit_amount, account_number') .eq('journal_entry_id', journalEntryId) - .gte('account_number', '1900') - .lte('account_number', '1999') + .eq('account_number', accountNumber) if (!lines || lines.length === 0) { - return { success: false, error: 'Verifikationen saknar rad på bankkonto (19xx)' } + return { success: false, error: `Verifikationen saknar rad på ${accountNumber}` } } // Check that no other transaction is already linked to this entry diff --git a/lib/transactions/__tests__/ingest.test.ts b/lib/transactions/__tests__/ingest.test.ts index 680caed1..15c89071 100644 --- a/lib/transactions/__tests__/ingest.test.ts +++ b/lib/transactions/__tests__/ingest.test.ts @@ -39,6 +39,9 @@ vi.mock('@/lib/currency/riksbanken', () => ({ function createQueueMockSupabase() { const resultQueue: { data: unknown; error: unknown }[] = [] + // Captures .insert() payloads keyed by table, so tests can assert what was + // written (e.g. cash_account_id stamping). + const inserts: Record = {} /** * Push one or more results onto the queue. @@ -50,25 +53,31 @@ function createQueueMockSupabase() { } } - const buildChain = (): unknown => { + const buildChain = (table: string): unknown => { const handler: ProxyHandler = { get(_target, prop) { if (prop === 'then') { const next = resultQueue.shift() ?? { data: null, error: null } return (resolve: (v: unknown) => void) => resolve(next) } - return (..._args: unknown[]) => buildChain() + if (prop === 'insert') { + return (payload: unknown) => { + ;(inserts[table] ??= []).push(payload) + return buildChain(table) + } + } + return (..._args: unknown[]) => buildChain(table) }, } return new Proxy({}, handler) } const supabase = { - from: vi.fn().mockImplementation(() => buildChain()), - rpc: vi.fn().mockImplementation(() => buildChain()), + from: vi.fn().mockImplementation((table: string) => buildChain(table)), + rpc: vi.fn().mockImplementation(() => buildChain('rpc')), } - return { supabase, enqueue } + return { supabase, enqueue, inserts } } // --------------------------------------------------------------------------- @@ -154,6 +163,54 @@ describe('ingestTransactions', () => { expect(result.transaction_ids).toEqual(['tx-1']) }) + // ----------------------------------------------------------------------- + // 1c. Stamps cash_account_id from the settlement account + // ----------------------------------------------------------------------- + it('stamps cash_account_id on the insert when settlementAccount resolves', async () => { + const { supabase, enqueue, inserts } = createQueueMockSupabase() + const raw = makeRaw({ amount: -100 }) + const inserted = makeTransaction({ id: 'tx-1', external_id: raw.external_id }) + + enqueue({ data: [], error: null }) // booked map + enqueue({ data: [], error: null }) // unbooked map + enqueue({ data: [], error: null }) // supplier invoices + enqueue({ data: [], error: null }) // external_id dedup + enqueue({ data: { id: 'ca-1931' }, error: null }) // cash_accounts lookup + enqueue({ data: inserted, error: null }) // insert + mockEvaluateMappingRules.mockResolvedValue(makeMappingResult({ confidence: 0.5 })) + + const result = await ingestTransactions(supabase as never, COMPANY_ID, USER_ID, [raw], { + settlementAccount: '1931', + }) + + expect(result.imported).toBe(1) + expect(supabase.from).toHaveBeenCalledWith('cash_accounts') + const txInserts = inserts['transactions'] ?? [] + expect(txInserts).toHaveLength(1) + expect((txInserts[0] as { cash_account_id?: string | null }).cash_account_id).toBe('ca-1931') + }) + + it('inserts cash_account_id null when no settlementAccount is given', async () => { + const { supabase, enqueue, inserts } = createQueueMockSupabase() + const raw = makeRaw({ amount: -100 }) + const inserted = makeTransaction({ id: 'tx-1', external_id: raw.external_id }) + + enqueue({ data: [], error: null }) // booked map + enqueue({ data: [], error: null }) // unbooked map + enqueue({ data: [], error: null }) // supplier invoices + enqueue({ data: [], error: null }) // external_id dedup + // No cash_accounts lookup — settlementAccount omitted. + enqueue({ data: inserted, error: null }) // insert + mockEvaluateMappingRules.mockResolvedValue(makeMappingResult({ confidence: 0.5 })) + + const result = await ingestTransactions(supabase as never, COMPANY_ID, USER_ID, [raw]) + + expect(result.imported).toBe(1) + expect(supabase.from).not.toHaveBeenCalledWith('cash_accounts') + const txInserts = inserts['transactions'] ?? [] + expect((txInserts[0] as { cash_account_id?: string | null }).cash_account_id).toBeNull() + }) + // ----------------------------------------------------------------------- // 2. Detects duplicates // ----------------------------------------------------------------------- diff --git a/lib/transactions/ingest.ts b/lib/transactions/ingest.ts index acba5e22..dd187fcb 100644 --- a/lib/transactions/ingest.ts +++ b/lib/transactions/ingest.ts @@ -213,6 +213,25 @@ export async function ingestTransactions( data?.forEach(r => existingExternalIds.add(r.external_id)) } + // Resolve the cash account this batch settled on, once. Every row in one + // ingest call shares a settlement account: enable-banking calls this per + // account (settlementAccount = account.ledger_account), CSV import passes the + // single account the user picked. cash_accounts.ledger_account is unique per + // company, so this is a single-row lookup. Tolerate a miss — the row stays + // unbound (cash_account_id NULL) and reconciliation falls back to currency. + // We never auto-create a cash account here; that would race upsertFromPsd2's + // seed-promotion logic in lib/cash-accounts/service.ts. + let cashAccountId: string | null = null + if (options?.settlementAccount) { + const { data: ca } = await supabase + .from('cash_accounts') + .select('id') + .eq('company_id', companyId) + .eq('ledger_account', options.settlementAccount) + .maybeSingle() + cashAccountId = (ca?.id as string | undefined) ?? null + } + // Track already-matched invoice IDs within this ingestion batch // to prevent suggesting the same invoice for multiple transactions const matchedInvoiceIds = new Set() @@ -270,6 +289,7 @@ export async function ingestTransactions( company_id: companyId, user_id: userId, bank_connection_id: raw.bank_connection_id || null, + cash_account_id: cashAccountId, external_id: raw.external_id, date: raw.date, description: description, diff --git a/supabase/migrations/20260605120500_invoice_inbox_realtime_publication.sql b/supabase/migrations/20260605120500_invoice_inbox_realtime_publication.sql new file mode 100644 index 00000000..487daa73 --- /dev/null +++ b/supabase/migrations/20260605120500_invoice_inbox_realtime_publication.sql @@ -0,0 +1,39 @@ +-- Migration: stream invoice_inbox_items changes via Supabase realtime +-- +-- The dokumentinkorg (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 operation, the /pending +-- approval page committing one, or another browser tab booking it — none of +-- those paths call the component's fetchItems(). The booked underlag stayed +-- in "Att göra" until the user manually reloaded the page (issue #600). +-- Adding the table to supabase_realtime lets the browser subscribe via +-- supabase.channel('postgres_changes') and refresh as the +-- created_supplier_invoice_id / created_journal_entry_id FKs land. +-- +-- This mirrors 20260520120100_pending_ops_realtime_publication.sql, which +-- fixed the identical staleness on the /pending page. +-- +-- RLS already restricts invoice_inbox_items to company members +-- (20260223150836_invoice_inbox.sql, refreshed by the multi-tenant refactor +-- in 20260330130000), and realtime respects the same row-level access — a +-- member of company A only receives change events for rows where their RLS +-- predicate evaluates true. Default replica identity (primary key) is +-- sufficient: the client only refetches, never inspecting the old/new record. + +-- Idempotent: ALTER PUBLICATION ... ADD TABLE errors if the table is already a +-- member (SQLSTATE 42710). Guard so a re-apply is a no-op — e.g. a Supabase +-- preview branch that partially applied an earlier revision of this migration +-- (the ALTER ran, but the schema_migrations bookkeeping insert failed), leaving +-- the table already in the publication on the next attempt. +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_publication_tables + WHERE pubname = 'supabase_realtime' + AND schemaname = 'public' + AND tablename = 'invoice_inbox_items' + ) THEN + ALTER PUBLICATION supabase_realtime ADD TABLE public.invoice_inbox_items; + END IF; +END $$; diff --git a/supabase/migrations/20260606120000_transactions_cash_account_id.sql b/supabase/migrations/20260606120000_transactions_cash_account_id.sql new file mode 100644 index 00000000..b2e4a11a --- /dev/null +++ b/supabase/migrations/20260606120000_transactions_cash_account_id.sql @@ -0,0 +1,40 @@ +-- Migration: transactions.cash_account_id +-- +-- Binds each bank transaction to the specific cash account (cash_accounts row) +-- it settled on. Until now `transactions` only carried `currency` + +-- `bank_connection_id`, so the bank reconciliation (Rapporter → Bankavstämning) +-- filtered transactions by CURRENCY while it filtered GL lines by ACCOUNT +-- NUMBER. A company with two same-currency accounts (e.g. checking 1930 + a +-- savings account on another SEK code) therefore saw every SEK transaction on +-- every account, and the status card summed across both — the reported +-- "shows 1930 even when you switch / sums all transactions" bug (issue #604). +-- +-- This is the cash_account_id FK that app/api/transactions/route.ts already +-- referred to as "the cash_account_id backfill tracked as Tier 4". +-- +-- Nullable on purpose: legacy rows are backfilled best-effort in the paired +-- 20260606120100_transactions_cash_account_id_backfill.sql migration, and any +-- row that can't be resolved stays NULL. All reconciliation queries treat a +-- NULL cash_account_id as "matches the selected account's currency" so nothing +-- ever disappears from a report mid-backfill. +-- +-- ON DELETE SET NULL — never CASCADE: a bank transaction is räkenskaps- +-- information (BFL 7 kap) and must survive the deletion of a cash account. +-- Never RESTRICT: cash accounts are user-disable-able (and occasionally +-- deletable); the FK must not block that. In practice cash accounts are +-- disabled, not hard-deleted, so SET NULL is an edge path that degrades into +-- the same currency fallback as an un-backfilled row. + +ALTER TABLE public.transactions + ADD COLUMN IF NOT EXISTS cash_account_id UUID + REFERENCES public.cash_accounts(id) ON DELETE SET NULL; + +-- Partial index — in steady state most rows are bound, and the hot +-- reconciliation query is "unmatched rows for account X". The non-NULL slice +-- is exactly what that query needs and keeps the index small during the +-- transition while most rows are still NULL. +CREATE INDEX IF NOT EXISTS idx_transactions_cash_account + ON public.transactions (company_id, cash_account_id) + WHERE cash_account_id IS NOT NULL; + +NOTIFY pgrst, 'reload schema'; diff --git a/supabase/migrations/20260606120100_transactions_cash_account_id_backfill.sql b/supabase/migrations/20260606120100_transactions_cash_account_id_backfill.sql new file mode 100644 index 00000000..1a2abcd8 --- /dev/null +++ b/supabase/migrations/20260606120100_transactions_cash_account_id_backfill.sql @@ -0,0 +1,85 @@ +-- Migration: backfill transactions.cash_account_id +-- +-- Best-effort population of the cash_account_id added in +-- 20260606120000_transactions_cash_account_id.sql. Four passes, in descending +-- order of authority. Every pass touches ONLY rows that are still NULL, so the +-- migration is idempotent and safe to re-run / resume after an interruption. +-- +-- Rows that no pass resolves stay NULL — reconciliation queries fall back to +-- currency matching for those, exactly as before this feature, so nothing +-- disappears from any report. + +-- Pass (a) — Booked rows via the voucher's bank line. Most authoritative: +-- reflects where the money was actually booked. Map the journal entry's single +-- 19xx line to a cash account by (company_id, ledger_account). +-- +-- Vouchers with MORE than one bank-class (19xx) line are own-account transfers +-- (e.g. 1930 → 1931) and are ambiguous — which leg is "this transaction"? We +-- deliberately skip them (leave NULL) rather than guess wrong. +UPDATE public.transactions t +SET cash_account_id = ca.id +FROM public.journal_entry_lines jel +JOIN public.cash_accounts ca + ON ca.ledger_account = jel.account_number +WHERE t.cash_account_id IS NULL + AND t.journal_entry_id IS NOT NULL + AND jel.journal_entry_id = t.journal_entry_id + -- Relate the cash account to the target by company in WHERE, not in the JOIN + -- ON above: Postgres forbids referencing the UPDATE target (t) from a + -- FROM-clause join condition ("invalid reference to FROM-clause entry for t"). + AND ca.company_id = t.company_id + AND jel.account_number BETWEEN '1900' AND '1999' + AND ( + SELECT count(*) + FROM public.journal_entry_lines x + WHERE x.journal_entry_id = t.journal_entry_id + AND x.account_number BETWEEN '1900' AND '1999' + ) = 1; + +-- Pass (b) — PSD2 rows via the owned-account identity embedded in external_id. +-- Enable Banking writes external_id = 'eb__' +-- (extensions/general/enable-banking/lib/sync.ts). Match the prefix against the +-- cash account's iban or external_uid. The trailing '_' in the prefix makes +-- this an exact account match (prevents 'SE111' matching 'SE1112…'), and +-- starts_with avoids LIKE wildcard/metacharacter ambiguity entirely. +UPDATE public.transactions t +SET cash_account_id = ca.id +FROM public.cash_accounts ca +WHERE t.cash_account_id IS NULL + AND ca.company_id = t.company_id + AND t.external_id IS NOT NULL + AND ( + (ca.iban IS NOT NULL + AND starts_with(t.external_id, 'eb_' || ca.iban || '_')) + OR + (ca.external_uid IS NOT NULL + AND starts_with(t.external_id, 'eb_' || ca.external_uid || '_')) + ); + +-- Pass (c) — Single-account-of-currency fallback. If a company has exactly one +-- ENABLED cash account in the row's currency, the row unambiguously belongs to +-- it. Resolves the single-account majority (incl. CSV imports, which carry no +-- account identity). Deliberately does NOT fire for the 2-same-currency case +-- (HAVING count(*) = 1) — those rows stay NULL and rely on passes (a)/(b) or +-- on the currency fallback at query time. We must not guess between checking +-- and savings. +WITH single_ca AS ( + -- (array_agg(id))[1], not min(id): Postgres has no min() aggregate for uuid. + -- HAVING count(*) = 1 guarantees exactly one row per group, so the array has + -- a single element and which one we pick is moot. + SELECT company_id, currency, (array_agg(id))[1] AS cash_account_id + FROM public.cash_accounts + WHERE enabled = true + GROUP BY company_id, currency + HAVING count(*) = 1 +) +UPDATE public.transactions t +SET cash_account_id = s.cash_account_id +FROM single_ca s +WHERE t.cash_account_id IS NULL + AND s.company_id = t.company_id + AND s.currency = t.currency; + +-- Pass (d) — anything still NULL is left as-is (query-time currency fallback). + +NOTIFY pgrst, 'reload schema'; diff --git a/supabase/migrations/20260607120000_supplier_invoice_overdue_skip_paid_and_credit_notes.sql b/supabase/migrations/20260607120000_supplier_invoice_overdue_skip_paid_and_credit_notes.sql new file mode 100644 index 00000000..63d269b1 --- /dev/null +++ b/supabase/migrations/20260607120000_supplier_invoice_overdue_skip_paid_and_credit_notes.sql @@ -0,0 +1,66 @@ +-- Migration: supplier_invoice_overdue_skip_paid_and_credit_notes +-- +-- Fix: supplier invoices showing "Förfallen" (overdue) even though +-- "kvar att betala" (remaining_amount) is 0 kr. +-- +-- Root cause: update_overdue_supplier_invoices() (the daily pg_cron job from +-- 20260303145744_supplier_invoice_overdue_cron.sql) flipped EVERY row past its +-- due_date whose status was 'registered'/'approved' to 'overdue', without ever +-- looking at the outstanding balance. +-- +-- Credit notes (is_credit_note = true) are created with status='registered', +-- remaining_amount=0 and due_date=today (see the supplier-invoice credit +-- routes). A credit note is not a payable — there is nothing to pay and nothing +-- to fall due — but because it sits in 'registered' with a due_date of today, +-- the cron turned it 'overdue' the very next day. The same happens to any +-- regular invoice that was fully paid but left in 'registered'/'approved'. +-- +-- Two parts: +-- 1. Guard the cron so a row with no outstanding balance, or a credit note, +-- is never marked overdue. +-- 2. Backfill the rows already mis-flagged. + +-- 1. Guarded cron function --------------------------------------------------- +-- CREATE OR REPLACE rewrites the whole definition, so re-declare the +-- search_path that 20260304191528_set_search_path_on_functions.sql pinned. +CREATE OR REPLACE FUNCTION public.update_overdue_supplier_invoices() +RETURNS void +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +BEGIN + UPDATE supplier_invoices + SET status = 'overdue', + updated_at = NOW() + WHERE due_date < CURRENT_DATE + AND status IN ('registered', 'approved') + -- Nothing left to pay -> cannot be overdue. 0.005 mirrors the + -- "fully paid" threshold used by the payment/match paths. + AND remaining_amount > 0.005 + -- Credit notes (kreditfakturor) are not payables. + AND COALESCE(is_credit_note, false) = false; +END; +$$; + +-- 2. Backfill rows already mis-flagged by the old function ------------------- +-- Credit notes wrongly flipped to 'overdue' return to 'registered' (their +-- resting state — there is no payment flow that advances a credit note). +UPDATE public.supplier_invoices +SET status = 'registered', + updated_at = NOW() +WHERE status = 'overdue' + AND COALESCE(is_credit_note, false) = true; + +-- Regular invoices that are fully paid but stuck on 'overdue' are 'paid'. +-- paid_at is only stamped when it was missing, so a real payment timestamp is +-- never overwritten. +UPDATE public.supplier_invoices +SET status = 'paid', + paid_at = COALESCE(paid_at, NOW()), + updated_at = NOW() +WHERE status = 'overdue' + AND COALESCE(is_credit_note, false) = false + AND remaining_amount <= 0.005; + +NOTIFY pgrst, 'reload schema'; diff --git a/tests/helpers.ts b/tests/helpers.ts index f238f0c1..b4177a51 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -172,6 +172,7 @@ export function makeTransaction(overrides: Partial = {}): Transacti user_id: 'user-1', company_id: 'company-1', bank_connection_id: null, + cash_account_id: null, external_id: null, date: '2024-06-15', description: 'ICA MAXI STOCKHOLM', diff --git a/tests/pg/fixtures.ts b/tests/pg/fixtures.ts index bdff2e0f..2b00c89b 100644 --- a/tests/pg/fixtures.ts +++ b/tests/pg/fixtures.ts @@ -89,6 +89,78 @@ export async function seedCompany(overrides: { isClosed?: boolean } = {}): Promi return { userId, companyId, fiscalPeriodId } } +// Insert a cash account (cash_accounts row). ledger_account is unique per +// company; is_primary defaults false to avoid the one-primary partial index. +export async function insertCashAccount(params: { + companyId: string + ledgerAccount: string + currency?: string + iban?: string | null + externalUid?: string | null + isPrimary?: boolean + enabled?: boolean + source?: 'enable_banking' | 'manual' | 'sie_import' + bankConnectionId?: string | null +}): Promise { + const id = randomUUID() + await getPool().query( + `INSERT INTO public.cash_accounts + (id, company_id, ledger_account, currency, iban, external_uid, + is_primary, enabled, source, bank_connection_id) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`, + [ + id, + params.companyId, + params.ledgerAccount, + params.currency ?? 'SEK', + params.iban ?? null, + params.externalUid ?? null, + params.isPrimary ?? false, + params.enabled ?? true, + params.source ?? 'manual', + params.bankConnectionId ?? null, + ], + ) + return id +} + +// Insert a bank transaction row. cashAccountId/journalEntryId default null so +// tests can exercise the backfill and the NULL-fallback scoping. +export async function insertTransaction(params: { + companyId: string + userId: string + currency?: string + amount?: number + date?: string + description?: string + externalId?: string | null + journalEntryId?: string | null + cashAccountId?: string | null + isIgnored?: boolean +}): Promise { + const id = randomUUID() + await getPool().query( + `INSERT INTO public.transactions + (id, company_id, user_id, currency, amount, date, description, + external_id, journal_entry_id, cash_account_id, is_ignored, category) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, 'uncategorized')`, + [ + id, + params.companyId, + params.userId, + params.currency ?? 'SEK', + params.amount ?? -100, + params.date ?? '2026-06-01', + params.description ?? 'Test tx', + params.externalId ?? null, + params.journalEntryId ?? null, + params.cashAccountId ?? null, + params.isIgnored ?? false, + ], + ) + return id +} + // Insert a draft journal entry and return its id. Uses a placeholder // voucher_number=0 which commit_journal_entry() will overwrite on commit. export async function insertDraftJournalEntry(params: { diff --git a/tests/pg/supplier-invoice-overdue-cron.pg.test.ts b/tests/pg/supplier-invoice-overdue-cron.pg.test.ts new file mode 100644 index 00000000..a7059704 --- /dev/null +++ b/tests/pg/supplier-invoice-overdue-cron.pg.test.ts @@ -0,0 +1,204 @@ +import { randomUUID } from 'node:crypto' +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { seedCompany } from '@/tests/pg/fixtures' +import { getPool } from '@/tests/pg/setup' + +/** + * pg-real coverage for update_overdue_supplier_invoices() and its fix in + * 20260607120000_supplier_invoice_overdue_skip_paid_and_credit_notes.sql. + * + * Regression: supplier invoices (and credit notes) with remaining_amount = 0 + * were being flipped to 'overdue' by the daily cron — surfacing in the UI as + * "Förfallen" with "kvar att betala 0 kr". Credit notes are the systematic + * case: they are created status='registered', remaining_amount=0, + * due_date=today, so the cron caught them the next day. + * + * Locks in: + * - The function still marks a genuinely-unpaid, past-due invoice overdue. + * - It NEVER marks a credit note overdue. + * - It NEVER marks a fully-paid (remaining ~= 0) invoice overdue. + * - Not-yet-due invoices are untouched. + * - The one-off backfill corrects rows already mis-flagged. + * + * Tests write through the superuser pool (RLS bypassed); the function is + * SECURITY DEFINER. Dates are pinned far in the past/future so the result is + * independent of the wall-clock date the suite runs on. + */ + +const PAST = '2000-01-01' +const FUTURE = '2999-01-01' + +const MIGRATION_SQL = readFileSync( + join( + process.cwd(), + 'supabase/migrations/20260607120000_supplier_invoice_overdue_skip_paid_and_credit_notes.sql', + ), + 'utf8', +) + +async function insertSupplier(userId: string, companyId: string): Promise { + const id = randomUUID() + await getPool().query( + `INSERT INTO public.suppliers + (id, user_id, company_id, name, supplier_type, country, default_payment_terms, default_currency) + VALUES ($1, $2, $3, 'Leverantör AB', 'swedish_business', 'SE', 30, 'SEK')`, + [id, userId, companyId], + ) + return id +} + +async function insertSupplierInvoice(params: { + userId: string + companyId: string + supplierId: string + status: string + dueDate: string + total: number + remaining: number + paidAmount?: number + isCreditNote?: boolean + paidAt?: string | null +}): Promise { + const id = randomUUID() + const arrivalNumber = (Date.now() % 1_000_000_000) + Math.floor(Math.random() * 100_000) + await getPool().query( + `INSERT INTO public.supplier_invoices + (id, user_id, company_id, supplier_id, arrival_number, supplier_invoice_number, + invoice_date, due_date, received_date, status, currency, + subtotal, vat_amount, total, paid_amount, remaining_amount, paid_at, + vat_treatment, reverse_charge, is_credit_note) + VALUES ($1, $2, $3, $4, $5, $6, $7, $7, $7, $8, 'SEK', + $9, 0, $9, $10, $11, $12, 'standard_25', false, $13)`, + [ + id, + params.userId, + params.companyId, + params.supplierId, + arrivalNumber, + `LF-${arrivalNumber}`, + params.dueDate, + params.status, + params.total, + params.paidAmount ?? 0, + params.remaining, + params.paidAt ?? null, + params.isCreditNote ?? false, + ], + ) + return id +} + +async function statusOf(id: string): Promise { + const { rows } = await getPool().query( + 'SELECT status FROM public.supplier_invoices WHERE id = $1', + [id], + ) + return rows[0].status +} + +describe('update_overdue_supplier_invoices()', () => { + it('marks a genuinely-unpaid, past-due invoice overdue', async () => { + const { userId, companyId } = await seedCompany() + const supplierId = await insertSupplier(userId, companyId) + const id = await insertSupplierInvoice({ + userId, companyId, supplierId, + status: 'approved', dueDate: PAST, total: 1000, remaining: 1000, + }) + + await getPool().query('SELECT public.update_overdue_supplier_invoices()') + + expect(await statusOf(id)).toBe('overdue') + }) + + it('never marks a credit note overdue (remaining 0, status registered)', async () => { + const { userId, companyId } = await seedCompany() + const supplierId = await insertSupplier(userId, companyId) + // Mirrors how the credit routes create a credit note: registered, fully + // settled (remaining 0), due today (here: long past). + const id = await insertSupplierInvoice({ + userId, companyId, supplierId, + status: 'registered', dueDate: PAST, total: 1000, remaining: 0, + isCreditNote: true, + }) + + await getPool().query('SELECT public.update_overdue_supplier_invoices()') + + expect(await statusOf(id)).toBe('registered') + }) + + it('never marks a fully-paid (remaining ~0) invoice overdue', async () => { + const { userId, companyId } = await seedCompany() + const supplierId = await insertSupplier(userId, companyId) + const id = await insertSupplierInvoice({ + userId, companyId, supplierId, + status: 'approved', dueDate: PAST, total: 1000, remaining: 0, paidAmount: 1000, + }) + + await getPool().query('SELECT public.update_overdue_supplier_invoices()') + + expect(await statusOf(id)).toBe('approved') + }) + + it('leaves not-yet-due invoices untouched', async () => { + const { userId, companyId } = await seedCompany() + const supplierId = await insertSupplier(userId, companyId) + const id = await insertSupplierInvoice({ + userId, companyId, supplierId, + status: 'approved', dueDate: FUTURE, total: 1000, remaining: 1000, + }) + + await getPool().query('SELECT public.update_overdue_supplier_invoices()') + + expect(await statusOf(id)).toBe('approved') + }) +}) + +describe('overdue backfill (migration 20260607120000)', () => { + it('reverts a credit note wrongly stuck on overdue back to registered', async () => { + const { userId, companyId } = await seedCompany() + const supplierId = await insertSupplier(userId, companyId) + const id = await insertSupplierInvoice({ + userId, companyId, supplierId, + status: 'overdue', dueDate: PAST, total: 1000, remaining: 0, isCreditNote: true, + }) + + // Idempotent: re-running the migration only touches status='overdue' rows. + await getPool().query(MIGRATION_SQL) + + expect(await statusOf(id)).toBe('registered') + }) + + it('marks a fully-paid invoice stuck on overdue as paid (and stamps paid_at)', async () => { + const { userId, companyId } = await seedCompany() + const supplierId = await insertSupplier(userId, companyId) + const id = await insertSupplierInvoice({ + userId, companyId, supplierId, + status: 'overdue', dueDate: PAST, total: 1000, remaining: 0, paidAmount: 1000, + paidAt: null, + }) + + await getPool().query(MIGRATION_SQL) + + const { rows } = await getPool().query( + 'SELECT status, paid_at FROM public.supplier_invoices WHERE id = $1', + [id], + ) + expect(rows[0].status).toBe('paid') + expect(rows[0].paid_at).not.toBeNull() + }) + + it('leaves a genuinely-overdue unpaid invoice on overdue', async () => { + const { userId, companyId } = await seedCompany() + const supplierId = await insertSupplier(userId, companyId) + const id = await insertSupplierInvoice({ + userId, companyId, supplierId, + status: 'overdue', dueDate: PAST, total: 1000, remaining: 1000, + }) + + await getPool().query(MIGRATION_SQL) + + expect(await statusOf(id)).toBe('overdue') + }) +}) diff --git a/tests/pg/transactions-cash-account-id.pg.test.ts b/tests/pg/transactions-cash-account-id.pg.test.ts new file mode 100644 index 00000000..adf5c298 --- /dev/null +++ b/tests/pg/transactions-cash-account-id.pg.test.ts @@ -0,0 +1,253 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { + seedCompany, + insertCashAccount, + insertTransaction, + insertDraftJournalEntry, +} from '@/tests/pg/fixtures' +import { getPool } from '@/tests/pg/setup' + +/** + * pg-real coverage for transactions.cash_account_id + * (20260606120000_transactions_cash_account_id.sql + the paired backfill + * 20260606120100_..._backfill.sql). + * + * Locks in: + * - FK ON DELETE SET NULL — deleting a cash account never deletes the bank + * transaction (räkenskapsinformation, BFL 7 kap), only nulls the link. + * - The four backfill passes (booked single 19xx line, PSD2 external_id, + * single-account-of-currency, leave NULL) and their guards. + * - The headline isolation: a query scoped to one account (with the + * NULL→currency fallback) never returns another same-currency account's + * rows. This is the regression test for "shows 1930 even when you switch / + * sums all transactions". + * - Cross-company isolation of the backfill. + */ + +// Run the real backfill migration SQL (idempotent: only touches NULL rows) so +// the test exercises exactly what ships, not a re-implementation. +const BACKFILL_SQL = readFileSync( + join( + process.cwd(), + 'supabase/migrations/20260606120100_transactions_cash_account_id_backfill.sql', + ), + 'utf8', +) +async function runBackfill(): Promise { + await getPool().query(BACKFILL_SQL) +} + +// Insert a journal entry (draft) with the given bank-class line account +// numbers. One line per account at amount 100 (debit). Balance isn't required +// for a draft entry — the balance trigger only fires on draft→posted. +async function insertEntryWithBankLines(params: { + userId: string + companyId: string + fiscalPeriodId: string + bankAccounts: string[] +}): Promise { + const jeId = await insertDraftJournalEntry({ + userId: params.userId, + companyId: params.companyId, + fiscalPeriodId: params.fiscalPeriodId, + }) + for (const acct of params.bankAccounts) { + await getPool().query( + `INSERT INTO public.journal_entry_lines + (journal_entry_id, account_number, debit_amount, credit_amount) + VALUES ($1, $2, 100, 0)`, + [jeId, acct], + ) + } + return jeId +} + +async function getCashAccountId(txId: string): Promise { + const { rows } = await getPool().query( + `SELECT cash_account_id FROM public.transactions WHERE id = $1`, + [txId], + ) + return rows[0]?.cash_account_id ?? null +} + +describe('transactions.cash_account_id — schema + FK', () => { + it('ON DELETE SET NULL keeps the transaction when its cash account is deleted', async () => { + const { userId, companyId } = await seedCompany() + const caId = await insertCashAccount({ companyId, ledgerAccount: '1930' }) + const txId = await insertTransaction({ companyId, userId, cashAccountId: caId }) + + await getPool().query(`DELETE FROM public.cash_accounts WHERE id = $1`, [caId]) + + const { rows } = await getPool().query( + `SELECT id, cash_account_id FROM public.transactions WHERE id = $1`, + [txId], + ) + expect(rows).toHaveLength(1) // transaction survived + expect(rows[0].cash_account_id).toBeNull() // link was nulled, not cascaded + }) +}) + +describe('transactions.cash_account_id — backfill pass (a) booked rows', () => { + it('binds a booked transaction via its single bank line; skips multi-bank-line vouchers', async () => { + const { userId, companyId, fiscalPeriodId } = await seedCompany() + // Two SEK accounts so the single-account fallback (pass c) cannot fire. + await insertCashAccount({ companyId, ledgerAccount: '1930' }) + const ca1931 = await insertCashAccount({ companyId, ledgerAccount: '1931' }) + + // Single 1931 line → should bind to the 1931 cash account. + const jeSingle = await insertEntryWithBankLines({ + userId, + companyId, + fiscalPeriodId, + bankAccounts: ['1931'], + }) + const txSingle = await insertTransaction({ + companyId, + userId, + journalEntryId: jeSingle, + }) + + // Two bank lines (1930 + 1931) → ambiguous transfer, must stay NULL. + const jeTransfer = await insertEntryWithBankLines({ + userId, + companyId, + fiscalPeriodId, + bankAccounts: ['1930', '1931'], + }) + const txTransfer = await insertTransaction({ + companyId, + userId, + journalEntryId: jeTransfer, + }) + + await runBackfill() + + expect(await getCashAccountId(txSingle)).toBe(ca1931) + expect(await getCashAccountId(txTransfer)).toBeNull() + }) +}) + +describe('transactions.cash_account_id — backfill pass (b) PSD2 external_id', () => { + it('routes by IBAN and by external_uid embedded in external_id', async () => { + const { userId, companyId } = await seedCompany() + // Two SEK accounts → pass (c) cannot fire, so only the PSD2 identity binds. + const caIban = await insertCashAccount({ + companyId, + ledgerAccount: '1930', + iban: 'SE4550000000058398257466', + }) + const caUid = await insertCashAccount({ + companyId, + ledgerAccount: '1931', + externalUid: 'psd2-uid-b', + }) + + const txIban = await insertTransaction({ + companyId, + userId, + externalId: 'eb_SE4550000000058398257466_tx1', + }) + const txUid = await insertTransaction({ + companyId, + userId, + externalId: 'eb_psd2-uid-b_tx2', + }) + const txUnknown = await insertTransaction({ + companyId, + userId, + externalId: 'eb_nomatch_tx3', + }) + + await runBackfill() + + expect(await getCashAccountId(txIban)).toBe(caIban) + expect(await getCashAccountId(txUid)).toBe(caUid) + expect(await getCashAccountId(txUnknown)).toBeNull() + }) +}) + +describe('transactions.cash_account_id — backfill pass (c) single-account-of-currency', () => { + it('binds when the company has exactly one enabled account of the currency', async () => { + const { userId, companyId } = await seedCompany() + const ca = await insertCashAccount({ companyId, ledgerAccount: '1930', currency: 'SEK' }) + // CSV-style row: no external_id, unbooked. + const tx = await insertTransaction({ companyId, userId, currency: 'SEK' }) + + await runBackfill() + + expect(await getCashAccountId(tx)).toBe(ca) + }) + + it('leaves NULL when the company has two same-currency accounts', async () => { + const { userId, companyId } = await seedCompany() + await insertCashAccount({ companyId, ledgerAccount: '1930', currency: 'SEK' }) + await insertCashAccount({ companyId, ledgerAccount: '1931', currency: 'SEK' }) + const tx = await insertTransaction({ companyId, userId, currency: 'SEK' }) + + await runBackfill() + + expect(await getCashAccountId(tx)).toBeNull() + }) +}) + +describe('transactions.cash_account_id — account-scoped query isolation', () => { + it('scopes to one account with a NULL→currency fallback, never leaking same-currency rows', async () => { + const { userId, companyId } = await seedCompany() + const ca1930 = await insertCashAccount({ companyId, ledgerAccount: '1930', currency: 'SEK' }) + const ca1931 = await insertCashAccount({ companyId, ledgerAccount: '1931', currency: 'SEK' }) + + const tx1930 = await insertTransaction({ companyId, userId, currency: 'SEK', cashAccountId: ca1930 }) + const tx1931 = await insertTransaction({ companyId, userId, currency: 'SEK', cashAccountId: ca1931 }) + const txNullSek = await insertTransaction({ companyId, userId, currency: 'SEK' }) + const txNullEur = await insertTransaction({ companyId, userId, currency: 'EUR' }) + + // Mirror the runtime predicate: + // cash_account_id = X OR (cash_account_id IS NULL AND currency = cur) + const scoped = async (cashAccountId: string, currency: string): Promise => { + const { rows } = await getPool().query( + `SELECT id FROM public.transactions + WHERE company_id = $1 + AND (cash_account_id = $2 OR (cash_account_id IS NULL AND currency = $3))`, + [companyId, cashAccountId, currency], + ) + return rows.map((r) => r.id) + } + + const for1930 = await scoped(ca1930, 'SEK') + expect(for1930).toContain(tx1930) + expect(for1930).toContain(txNullSek) // legacy NULL row visible via fallback + expect(for1930).not.toContain(tx1931) // the other account never leaks + expect(for1930).not.toContain(txNullEur) // wrong-currency NULL excluded + + const for1931 = await scoped(ca1931, 'SEK') + expect(for1931).toContain(tx1931) + expect(for1931).toContain(txNullSek) + expect(for1931).not.toContain(tx1930) + }) +}) + +describe('transactions.cash_account_id — cross-company isolation', () => { + it('backfill never binds a transaction to another company\'s cash account', async () => { + const a = await seedCompany() + const b = await seedCompany() + + const caA = await insertCashAccount({ companyId: a.companyId, ledgerAccount: '1930' }) + const caB = await insertCashAccount({ companyId: b.companyId, ledgerAccount: '1930' }) + + const jeA = await insertEntryWithBankLines({ + userId: a.userId, + companyId: a.companyId, + fiscalPeriodId: a.fiscalPeriodId, + bankAccounts: ['1930'], + }) + const txA = await insertTransaction({ companyId: a.companyId, userId: a.userId, journalEntryId: jeA }) + + await runBackfill() + + const boundA = await getCashAccountId(txA) + expect(boundA).toBe(caA) + expect(boundA).not.toBe(caB) + }) +}) diff --git a/types/index.ts b/types/index.ts index 9b730922..be3f7227 100644 --- a/types/index.ts +++ b/types/index.ts @@ -402,6 +402,12 @@ export interface Transaction { bank_connection_id: string | null external_id: string | null // For deduplication + // The cash account (cash_accounts row) this transaction settled on. Drives + // per-account bank reconciliation isolation and the correct bank leg when + // booking. Null on legacy/unresolved rows — callers fall back to currency. + // See 20260606120000_transactions_cash_account_id.sql. + cash_account_id: string | null + // Details date: string description: string // Mutable working title — user-editable while unbooked (see PATCH /api/transactions/[id])