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
@@ -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 (