fix(import): refuse a Bokio connection that opens a different company (#1315)

A Bokio integration token is scoped to one Bokio company and the company id is typed in by hand, so credentials for the user's other company imported that company's customers, suppliers and invoices with no error at all. Probe /companies/{id} before storing, mirroring the Bjorn Lunden /details probe, and refuse on a confident org-number mismatch.

Also surface the inbox mail body when nothing was attached: it was captured in email_body_text and never read back, which made Gmail's forwarding-confirmation mail unreadable and the forward impossible to complete.
This commit is contained in:
Jakob Wennberg
2026-07-30 19:07:40 +02:00
committed by GitHub
parent 27ae59040e
commit 0f1c7c9365
9 changed files with 311 additions and 5 deletions
+2
View File
@@ -715,3 +715,5 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-07-30] HOVER_REVEAL_CLASS gains focus-within:opacity-100. focus-visible only matches the element itself, so using the constant on a non-focusable wrapper <span> (the skattekonto Matcha/Bokför cluster, which previously hand-rolled focus-within) left those buttons transparent while a keyboard user tabbed through them. Fixed in the shared constant rather than per call site, since the constant is documented as the one true row-control helper and every wrapper use has the same trap.
[2026-07-30] Report-vocabulary synonyms live in ReportDescriptor.searchTerms, NOT in the command-palette keywords, when they are words another report already owns. "stäm av"/"avstämning" on the huvudbok palette entry hijacked Enter from Bankavstämning, because the palette auto-selects the first hit and huvudbok is listed above it. The library search shows a list and has no such failure mode, so broad task-vocabulary belongs there.
[2026-07-30] A missing org number on either side of the Bokio connect probe does NOT block the connection; only a confident mismatch does. Accounted allows companies without an org number and a provider response can omit it, so blocking on absence would refuse legitimate connections to prevent a mismatch we have no evidence of. Absence instead falls through to labelling the consent with the company the credentials actually opened, which is what lets the user catch it. Same reasoning applied to keeping 429/5xx from the probe out of the invalid-credentials mapping: a provider outage must not read as "your token is wrong".
@@ -66,6 +66,11 @@ interface InboxItem {
email_from: string | null
email_subject: string | null
email_received_at: string | null
// Plain-text body of the received email. Always captured, but only worth
// showing when the mail carried no usable attachment: that is the case where
// the body IS the content (a forwarding-confirmation code from Gmail, an
// invoice pasted inline, a note from the sender).
email_body_text: string | null
document_id: string | null
extracted_data: InvoiceExtractionResult | null
matched_supplier_id: string | null
@@ -574,6 +579,7 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
email_from: null,
email_subject: null,
email_received_at: null,
email_body_text: null,
document_id: null,
extracted_data: null,
matched_supplier_id: null,
@@ -1769,6 +1775,7 @@ function FieldsRail({
const isResolved = isProcessed || isBookedDirectly
const [isUnmatchingTx, setIsUnmatchingTx] = useState(false)
const [isRetrying, setIsRetrying] = useState(false)
const t = useTranslations('inbox_workspace')
// Surface a quiet hint when extraction caught a supplier name but no existing
// supplier matched. The actual creation flow lives on the leverantörsfaktura
@@ -1856,6 +1863,27 @@ function FieldsRail({
</div>
)}
{/* Mail body. Shown only when nothing was attached: then the body IS the
delivered content, and without it the item is a dead end that says
"no attachments" and nothing more. This is what makes a Gmail forward
possible to set up, since Gmail sends its confirmation code as a
plain-text mail with no attachment. Rendered as selectable text so
the code can be copied out. */}
{item.source === 'email' && !item.document_id && (
<div className="border-b px-4 py-3">
<h3 className="text-xs uppercase tracking-wide text-muted-foreground font-medium mb-2">
{t('email_body_label')}
</h3>
{item.email_body_text?.trim() ? (
<pre className="max-h-64 overflow-y-auto whitespace-pre-wrap break-words font-sans text-xs leading-relaxed text-foreground">
{item.email_body_text}
</pre>
) : (
<p className="text-xs text-muted-foreground">{t('email_body_empty')}</p>
)}
</div>
)}
{/* Hint only: creation happens on the leverantörsfaktura form via "Skapa & välj" */}
{showNoMatchHint && (
<div className="border-b bg-muted/30 px-4 py-2 text-xs text-muted-foreground">
@@ -14,6 +14,7 @@ import {
resolveConsent,
fetchCompanyInfoDirect,
ProviderTokenInvalidError,
ProviderCompanyMismatchError,
ConsentNotFoundError,
} from './lib/provider-client'
import { providerSupportsSie, fetchProviderSieFiles, getAllowedFiscalYears } from './lib/sie-fetcher'
@@ -437,6 +438,18 @@ export const arcimMigrationExtension: Extension = {
details: { provider, reason: error.message },
})
}
// Valid credentials, wrong company: name both org numbers so the user
// can see at a glance which company the token actually opened.
if (error instanceof ProviderCompanyMismatchError) {
return errorResponseFromCode('PROVIDER_COMPANY_MISMATCH', moduleLog, {
details: {
provider,
expectedOrgNumber: error.expectedOrgNumber,
actualOrgNumber: error.actualOrgNumber,
actualCompanyName: error.actualCompanyName,
},
})
}
return errorResponseFromCode('PROVIDER_TOKEN_SUBMIT_FAILED', moduleLog, {
details: { reason: error instanceof Error ? error.message : 'unknown' },
})
@@ -1,7 +1,10 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { createQueuedMockSupabase } from '@/tests/helpers'
const { mockBlGet } = vi.hoisted(() => ({ mockBlGet: vi.fn() }))
const { mockBlGet, mockBokioGetCompany } = vi.hoisted(() => ({
mockBlGet: vi.fn(),
mockBokioGetCompany: vi.fn(),
}))
vi.mock('@/lib/supabase/server', () => ({
createServiceClient: vi.fn(),
@@ -29,11 +32,25 @@ vi.mock('@/lib/providers/bjornlunden/client', async (importOriginal) => {
}
})
// Same shape as the BL mock above: keep the real BokioApiError for the
// instanceof checks, swap the client so the /companies probe is controllable.
vi.mock('@/lib/providers/bokio/client', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/providers/bokio/client')>()
return {
...actual,
BokioClient: vi.fn().mockImplementation(function mockClient() {
return { getCompany: mockBokioGetCompany }
}),
}
})
import { createServiceClient } from '@/lib/supabase/server'
import { BjornLundenApiError } from '@/lib/providers/bjornlunden/client'
import { BokioApiError } from '@/lib/providers/bokio/client'
import {
submitProviderToken,
ProviderTokenInvalidError,
ProviderCompanyMismatchError,
ConsentNotFoundError,
} from '../provider-client'
@@ -65,12 +82,133 @@ describe('submitProviderToken', () => {
it('stores tokens when the consent belongs to the caller company', async () => {
mock.enqueue({ data: [{ id: 'consent-1' }] }) // ownership check
mock.enqueue({ data: { org_number: '5560125790' } }) // target company lookup
mock.enqueue({ data: null }) // consent label update
mock.enqueue({ data: null }) // token upsert
mockBokioGetCompany.mockResolvedValueOnce({
name: 'Testbolaget AB',
orgNumber: '5560125790',
})
const result = await submitProviderToken('consent-1', 'bokio', 'tok', 'bokio-guid', 'company-A')
expect(result).toEqual({ success: true, consentId: 'consent-1' })
expect(tablesTouched()).toEqual(['provider_consents', 'provider_consent_tokens'])
expect(tablesTouched()).toEqual([
'provider_consents',
'companies',
'provider_consents',
'provider_consent_tokens',
])
})
// ── Bokio company-identity guard ──────────────────────────────────
//
// The failure being prevented: a valid Bokio token plus a company id for the
// WRONG company imports a foreign legal entity's customers, suppliers and
// invoices into this ledger with no error at all.
it('refuses to store the token when the Bokio company org number differs from the target company', async () => {
mock.enqueue({ data: [{ id: 'consent-1' }] }) // ownership check
mock.enqueue({ data: { org_number: '5560125790' } }) // target company
mockBokioGetCompany.mockResolvedValueOnce({
name: 'Någon Annans Bolag AB',
orgNumber: '5566778899', // a different legal entity
})
const err: unknown = await submitProviderToken(
'consent-1',
'bokio',
'tok',
'bokio-guid',
'company-A',
).catch((e: unknown) => e)
expect(err).toBeInstanceOf(ProviderCompanyMismatchError)
expect(err).toMatchObject({
expectedOrgNumber: '5560125790',
actualOrgNumber: '5566778899',
actualCompanyName: 'Någon Annans Bolag AB',
})
// Nothing was stored and the consent was NOT labelled: the connection must
// not exist in any form, or the next step would import from it.
expect(tablesTouched()).not.toContain('provider_consent_tokens')
expect(tablesTouched()).toEqual(['provider_consents', 'companies'])
})
it('compares org numbers canonically, so formatting differences are not a mismatch', async () => {
mock.enqueue({ data: [{ id: 'consent-1' }] })
mock.enqueue({ data: { org_number: '5560125790' } }) // stored 10-digit
mock.enqueue({ data: null }) // consent label update
mock.enqueue({ data: null }) // token upsert
// Same company, hyphenated and with the century prefix Bokio may return.
mockBokioGetCompany.mockResolvedValueOnce({
name: 'Testbolaget AB',
orgNumber: '556012-5790',
})
await expect(
submitProviderToken('consent-1', 'bokio', 'tok', 'bokio-guid', 'company-A'),
).resolves.toEqual({ success: true, consentId: 'consent-1' })
expect(tablesTouched()).toContain('provider_consent_tokens')
})
it('still connects when an org number is missing on either side (absence is not evidence of mismatch)', async () => {
mock.enqueue({ data: [{ id: 'consent-1' }] })
mock.enqueue({ data: { org_number: null } }) // company has no org number
mock.enqueue({ data: null }) // consent label update
mock.enqueue({ data: null }) // token upsert
mockBokioGetCompany.mockResolvedValueOnce({
name: 'Testbolaget AB',
orgNumber: '5560125790',
})
await expect(
submitProviderToken('consent-1', 'bokio', 'tok', 'bokio-guid', 'company-A'),
).resolves.toEqual({ success: true, consentId: 'consent-1' })
// Labelled anyway: the wizard showing WHICH company was linked is what
// lets the user catch the mistake themselves in this case.
expect(tablesTouched()).toContain('provider_consent_tokens')
})
it('maps a 404 from the Bokio probe to invalid credentials', async () => {
mock.enqueue({ data: [{ id: 'consent-1' }] })
// getCompany() maps 404 to null: an unknown GUID, not an outage.
mockBokioGetCompany.mockResolvedValueOnce(null)
await expect(
submitProviderToken('consent-1', 'bokio', 'tok', 'bad-guid', 'company-A'),
).rejects.toBeInstanceOf(ProviderTokenInvalidError)
expect(tablesTouched()).not.toContain('provider_consent_tokens')
})
it('maps a 401 from the Bokio probe to invalid credentials', async () => {
mock.enqueue({ data: [{ id: 'consent-1' }] })
mockBokioGetCompany.mockRejectedValueOnce(new BokioApiError('Bokio API error: 401', 401))
await expect(
submitProviderToken('consent-1', 'bokio', 'tok', 'bokio-guid', 'company-A'),
).rejects.toBeInstanceOf(ProviderTokenInvalidError)
})
it('does NOT map a transient 503 from the Bokio probe to invalid credentials', async () => {
mock.enqueue({ data: [{ id: 'consent-1' }] })
mockBokioGetCompany.mockRejectedValueOnce(new BokioApiError('Bokio API error: 503', 503))
const err: unknown = await submitProviderToken(
'consent-1',
'bokio',
'tok',
'bokio-guid',
'company-A',
).catch((e: unknown) => e)
expect(err).toBeInstanceOf(BokioApiError)
expect(err).not.toBeInstanceOf(ProviderTokenInvalidError)
expect(tablesTouched()).not.toContain('provider_consent_tokens')
})
// ── BL /details probe error classification ────────────────────────
@@ -17,11 +17,16 @@ import { refreshBjornLundenToken } from '@/lib/providers/bjornlunden/oauth'
import { BjornLundenClient, BjornLundenApiError } from '@/lib/providers/bjornlunden/client'
import { exchangeBrioxCode } from '@/lib/providers/briox/oauth'
import { BrioxApiError } from '@/lib/providers/briox/client'
import { BokioClient, BokioApiError } from '@/lib/providers/bokio/client'
import { normalizeOrgNumber } from '@/lib/company-lookup/normalize-org-number'
import type { ConsentRecord, OtcResponse } from '../types'
// Singleton (holds the rate limiter): used to validate BL User-Keys at submit
const bjornLundenClient = new BjornLundenClient()
// Singleton (holds the rate limiter): used to verify Bokio company identity
const bokioClient = new BokioClient()
/**
* Thrown by submitProviderToken when the provider actively rejects the
* submitted credentials (as opposed to a transient failure). The route maps
@@ -48,6 +53,30 @@ export class ConsentNotFoundError extends Error {
}
}
/**
* Thrown when the credentials are valid but open a company whose org number is
* not the one being imported into. Reported as PROVIDER_COMPANY_MISMATCH (422).
*
* The failure this prevents is silent and expensive: a token that works plus a
* company id for the wrong company imports a FOREIGN legal entity's customers,
* suppliers and invoices into this ledger. Nothing errors, so the first signal
* is the user noticing their books are full of a stranger's data. Both org
* numbers are carried on the error so the wizard can name the two companies.
*/
export class ProviderCompanyMismatchError extends Error {
constructor(
public readonly expectedOrgNumber: string,
public readonly actualOrgNumber: string,
public readonly actualCompanyName: string | null,
) {
super(
`Provider company mismatch: credentials open ${actualOrgNumber}, ` +
`but the target company is ${expectedOrgNumber}`,
)
this.name = 'ProviderCompanyMismatchError'
}
}
// Re-export data fetching functions from the provider layer
export { resolveConsent } from '@/lib/providers/resolve-consent'
export {
@@ -439,6 +468,86 @@ export async function submitProviderToken(
}
}
// Bokio: the pasted integration token is scoped to ONE Bokio company, and the
// company GUID is typed in by hand. Nothing upstream ties either to the
// Accounted company being imported into, so a token/GUID for the user's other
// company imports that company's customers, suppliers and invoices here with
// no error at all. Probe /companies/{guid} before storing anything: it both
// proves the credentials work and returns the orgNumber to compare.
if (provider === 'bokio') {
if (!providerCompanyId) {
throw new ProviderTokenInvalidError('Bokio requires a company id')
}
let bokioCompany: Record<string, unknown> | null
try {
bokioCompany = await bokioClient.getCompany<Record<string, unknown>>(
accessToken,
providerCompanyId,
)
} catch (error) {
if (error instanceof BokioApiError) {
// 429/5xx are transient provider failures, not a verdict on the token:
// rethrow so the route reports a generic submit failure rather than
// telling the user their credentials are wrong. 401/403/404 mean the
// token or the GUID genuinely does not open this company.
if (error.statusCode === 429 || error.statusCode >= 500) {
throw error
}
throw new ProviderTokenInvalidError(
`Bokio rejected the credentials (HTTP ${error.statusCode})`,
)
}
throw error
}
// getCompany() maps 404 to null: an unknown GUID is a bad company id, not
// an outage.
if (!bokioCompany) {
throw new ProviderTokenInvalidError('Bokio does not know that company id')
}
const bokioName = typeof bokioCompany['name'] === 'string'
? (bokioCompany['name'] as string).trim()
: ''
const bokioOrgNumber = normalizeOrgNumber(bokioCompany['orgNumber'] as string | undefined)
const { data: targetCompany } = await supabase
.from('companies')
.select('org_number')
.eq('id', ownerCompanyId)
.maybeSingle()
const targetOrgNumber = normalizeOrgNumber(targetCompany?.org_number)
// Only a confident mismatch blocks. A missing org number on either side is
// not evidence of anything (Accounted allows companies without one, and a
// Bokio response could omit it), so those fall through to the labelling
// below: the wizard still shows WHICH Bokio company was linked, which is
// what lets the user catch it themselves.
if (bokioOrgNumber && targetOrgNumber && bokioOrgNumber !== targetOrgNumber) {
throw new ProviderCompanyMismatchError(
targetOrgNumber,
bokioOrgNumber,
bokioName || null,
)
}
// Label the consent with what the credentials actually opened. Written as
// an object literal (not a conditional spread) so the phantom-column guard
// can see which columns this touches. `undefined` is dropped by the JSON
// serialisation, so a field Bokio did not return is left alone rather than
// overwriting a value the user typed at connect time with null.
if (bokioName || bokioOrgNumber) {
await supabase
.from('provider_consents')
.update({
company_name: bokioName || undefined,
org_number: bokioOrgNumber || undefined,
})
.eq('id', consentId)
}
}
// Store tokens: consent stays at status 0 until migration/SIE import completes
await supabase
.from('provider_consent_tokens')
+2 -1
View File
@@ -515,7 +515,8 @@ export const invoiceInboxExtension: Extension = {
.select(`
id, status, source, created_at, extracted_data,
matched_supplier_id, document_id, email_from, email_subject,
email_received_at, error_message, created_supplier_invoice_id,
email_received_at, email_body_text, error_message,
created_supplier_invoice_id,
matched_transaction_id, created_journal_entry_id,
resend_email_id, extraction_skipped
`)
+11
View File
@@ -1813,6 +1813,17 @@ const PROVIDER_MIGRATION: Record<string, StructuredErrorEntry> = {
message_en:
'The provider rejected the credentials. Check that the account ID and application token are correct and try again.',
},
PROVIDER_COMPANY_MISMATCH: {
// 422, same reasoning as PROVIDER_TOKEN_INVALID: the credentials are valid,
// but they open a DIFFERENT legal entity than the one being imported into.
// Importing anyway mixes another company's ledger into this one, which is
// both a bookkeeping and a data-protection problem: refuse at the boundary.
httpStatus: 422,
message_sv:
'Uppgifterna gäller ett annat företag än det du importerar till. Kontrollera att du valt rätt företag hos leverantören och försök igen.',
message_en:
'These credentials belong to a different company than the one you are importing into. Check that you picked the right company at the provider and try again.',
},
PROVIDER_PREVIEW_FAILED: {
httpStatus: 500,
message_sv: 'Förhandsgranskningen från leverantören misslyckades.',
+3 -1
View File
@@ -2714,7 +2714,9 @@
"address_load_failed": "Could not read the inbox address, so we do not know whether the company already has one.",
"items_load_failed": "Could not load the inbox, so we do not know whether anything is waiting here.",
"document_loading": "Fetching the document…",
"document_load_failed": "The document is still stored but could not be shown right now."
"document_load_failed": "The document is still stored but could not be shown right now.",
"email_body_label": "Email content",
"email_body_empty": "The email had no text."
},
"tx_match_allocation": {
"title": "Split payment",
+3 -1
View File
@@ -2714,7 +2714,9 @@
"address_load_failed": "Kunde inte läsa av inkorgsadressen, så vi vet inte om bolaget redan har en.",
"items_load_failed": "Kunde inte läsa in inkorgen, så vi vet inte om det ligger något här.",
"document_loading": "Hämtar underlaget…",
"document_load_failed": "Underlaget finns kvar men kunde inte visas just nu."
"document_load_failed": "Underlaget finns kvar men kunde inte visas just nu.",
"email_body_label": "Mejlets innehåll",
"email_body_empty": "Mejlet hade ingen text."
},
"tx_match_allocation": {
"title": "Dela betalning",