Add/bokslut (#718)

* feat(arcim-migration): Briox provider with SIE-over-API import

- Briox auth via account ID + application token (no app-level
  credentials); both tokens rotate on refresh and are persisted
- New sie-fetcher pulls the general ledger as SIE through the
  provider API for Fortnox, Briox and Bjorn Lunden
- Wizard stops on a failed SIE import and surfaces the real errors
  instead of proceeding to the misleading migrate-guard message
- PROVIDER_SIE_ONLY_FORTNOX renamed to PROVIDER_SIE_NOT_SUPPORTED;
  new PROVIDER_TOKEN_INVALID for rejected provider credentials

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(bookkeeping): per-line accruals (periodisering) on invoices and supplier invoices

Defer revenue/costs per invoice line to 29xx/17xx interim accounts with
automatic monthly dissolution (nightly cron + catch-up at registration),
schedule cancellation on credit, year-end auto-detect exclusion for
already-scheduled invoices, invoice-inbox service-period extraction for
prefill, and an MCP tool to list schedules.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(bokslut): iXBRL arsredovisning generation and Bolagsverket digital filing

Generate the annual report as iXBRL from a generated taxonomy registry
(K2 element lists, taxonomy:generate/check scripts + CI guard), expose it
via the fiscal-period API, and add the bolagsverket extension for digital
submission to eget utrymme with webhook-driven status tracking
(submissions table + pg tests, lifecycle events, year-end wizard UI).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(mcp): raise origin-guard test timeout to 20s

The dynamic import pulls in the full server module; the parse alone
flirts with the 5s default under full-suite parallel load.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Add new scripts and documentation for K2 AB taxonomy generation and validation

- Introduced `generate-taxonomy-registry.ts` to automate the generation of the iXBRL taxonomy concept registry from official element lists and tuple models.
- Added `validate-ixbrl.mjs` for validating generated iXBRL reports against the official taxonomy package using Arelle.
- Included new documentation files:
  - `k2-ab-arsredovisning-elementlista-2024-09-12_rev20250312_sv.xlsx`
  - `tuple-innehallsmodell-arsredovisning-k2-2024-09-12.xlsx`
  - `taxonomi-paket-2024-09-12_rev20250312.zip`

* Add tests for bookkeeping accruals dissolution and supplier invoices

- Implement tests for the POST /api/bookkeeping/accruals/[id]/dissolve route, covering success and error scenarios.
- Add tests for the DELETE /api/supplier-invoices/[id] route, including authentication checks and validation of invoice deletion conditions.
- Introduce tests for the Arcim migration provider client, ensuring token handling and error classification.
- Create tests for the Bolagsverket extension, validating submission role enforcement and environment settings.
- Add Zod schemas for Bolagsverket response payloads to ensure proper validation.
- Implement tests for MCP server's list accrual schedules, confirming registration and scope mapping.
- Add consistency tests for IXBRL document generation, ensuring duplicate facts and XML escaping are handled correctly.
- Introduce typed domain errors for accrual schedules to improve error handling in the service.
- Add tests for resolving consent with Briox token refresh concurrency, ensuring proper token management and error handling.

* fix(tests): update payload size guard comments to reflect recent changes in tool descriptions and ceiling adjustments

* fix(gitattributes): mark generated JSON files in bokslut taxonomy as linguist-generated

* feat(migrations): add backfill for invoices.journal_entry_id and fallback for next_voucher_number user_id

* feat(bokslut): enhance compliance and financial processing features with new submission details and security measures

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-06-12 16:35:30 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 8e8b63a200
commit db8983ba9e
131 changed files with 33796 additions and 340 deletions
@@ -0,0 +1,101 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { createQueuedMockSupabase } from '@/tests/helpers';
vi.mock('@/lib/supabase/server', () => ({
createServiceClient: vi.fn(),
}));
vi.mock('@/lib/providers/briox/oauth', () => ({
refreshBrioxToken: vi.fn(),
}));
import { createServiceClient } from '@/lib/supabase/server';
import { refreshBrioxToken } from '@/lib/providers/briox/oauth';
import { resolveConsent } from '../resolve-consent';
const consentRow = { id: 'c1', company_id: 'co1', provider: 'briox', status: 1 };
const expiredTokens = {
access_token: 'old-access',
refresh_token: 'old-refresh',
token_expires_at: '2020-01-01T00:00:00.000Z',
provider_company_id: 'acct-1',
};
describe('resolveConsent — Briox token refresh concurrency', () => {
let mock: ReturnType<typeof createQueuedMockSupabase>;
beforeEach(() => {
vi.clearAllMocks();
mock = createQueuedMockSupabase();
vi.mocked(createServiceClient).mockReturnValue(mock.supabase as never);
vi.mocked(refreshBrioxToken).mockResolvedValue({
access_token: 'new-access',
refresh_token: 'new-refresh',
token_type: 'Bearer',
expires_in: 3600,
});
});
it('returns the stored token without refreshing when not expired', async () => {
mock.enqueue({ data: [consentRow] });
mock.enqueue({
data: [{ ...expiredTokens, token_expires_at: new Date(Date.now() + 3_600_000).toISOString() }],
});
const result = await resolveConsent('co1', 'c1');
expect(result.accessToken).toBe('old-access');
expect(refreshBrioxToken).not.toHaveBeenCalled();
});
it('persists the rotated pair when the guarded update wins the race', async () => {
mock.enqueue({ data: [consentRow] }); // consent lookup
mock.enqueue({ data: [expiredTokens] }); // expired token row
mock.enqueue({ data: [{ id: 't1' }] }); // guarded update matched 1 row
const result = await resolveConsent('co1', 'c1');
expect(result.accessToken).toBe('new-access');
expect(refreshBrioxToken).toHaveBeenCalledTimes(1);
expect(refreshBrioxToken).toHaveBeenCalledWith('old-refresh', 'old-access');
});
it('adopts the concurrent winner\'s tokens when the guarded update matches 0 rows (lost race)', async () => {
mock.enqueue({ data: [consentRow] }); // consent lookup
mock.enqueue({ data: [expiredTokens] }); // expired token row (both requests read this)
mock.enqueue({ data: [] }); // guarded update: another request already rotated
mock.enqueue({
// re-read returns the winner's freshly persisted pair
data: [
{
access_token: 'winner-access',
refresh_token: 'winner-refresh',
token_expires_at: new Date(Date.now() + 3_600_000).toISOString(),
provider_company_id: 'acct-1',
},
],
});
const result = await resolveConsent('co1', 'c1');
// Must use the persisted fresh tokens, NOT call Briox /tokenrefresh again
// — a second rotation would invalidate the winner's pair.
expect(result.accessToken).toBe('winner-access');
expect(result.providerCompanyId).toBe('acct-1');
expect(refreshBrioxToken).toHaveBeenCalledTimes(1);
});
it('fails loudly with re-enter guidance when the rotated pair cannot be persisted', async () => {
mock.enqueue({ data: [consentRow] }); // consent lookup
mock.enqueue({ data: [expiredTokens] }); // expired token row
mock.enqueue({ data: null, error: { message: 'connection reset' } }); // update failed
// Briox has already rotated the tokens at this point — the stored pair is
// dead, so the user must reconnect with fresh credentials.
await expect(resolveConsent('co1', 'c1')).rejects.toMatchObject({
status: 500,
message: expect.stringContaining('re-enter the credentials'),
});
});
});
@@ -0,0 +1,174 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { BjornLundenClient, BjornLundenApiError } from '../client'
/**
* Mocked-fetch tests for the BL HTTP client. Sandbox-verified behaviors under
* guard here:
* - pagination uses `page` + `rows` (a `pageRequested` request param is
* IGNORED by the API — sending it loops on page 1 forever)
* - every call carries Authorization Bearer + the per-company User-Key
* - 429/5xx retry, 401/403/404 fail fast (a wrong User-Key surfaces as 500)
*/
const TOKEN = 'access-token'
const USER_KEY = '69f15a2d-0000-0000-0000-000000000000'
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
})
}
describe('BjornLundenClient', () => {
let fetchSpy: ReturnType<typeof vi.spyOn>
let client: BjornLundenClient
beforeEach(() => {
fetchSpy = vi.spyOn(globalThis, 'fetch')
client = new BjornLundenClient()
})
afterEach(() => {
fetchSpy.mockRestore()
})
describe('headers', () => {
it('sends Authorization Bearer and User-Key on get()', async () => {
fetchSpy.mockResolvedValueOnce(jsonResponse({ name: 'Arcim TEST' }))
await client.get(TOKEN, USER_KEY, '/details')
expect(fetchSpy).toHaveBeenCalledTimes(1)
const [url, opts] = fetchSpy.mock.calls[0]
expect(String(url)).toBe('https://apigateway.blinfo.se/bla-api/v1/sp/details')
expect((opts as RequestInit).headers).toMatchObject({
Authorization: `Bearer ${TOKEN}`,
'User-Key': USER_KEY,
Accept: 'application/json',
})
})
it('sends the same auth headers on getBytes()', async () => {
fetchSpy.mockResolvedValueOnce(new Response(new Uint8Array([0x23, 0x46]).buffer as ArrayBuffer, { status: 200 }))
const buf = await client.getBytes(TOKEN, USER_KEY, '/sie/export/2025-01-01/2025-12-31')
expect(new Uint8Array(buf)).toEqual(new Uint8Array([0x23, 0x46]))
const [, opts] = fetchSpy.mock.calls[0]
expect((opts as RequestInit).headers).toMatchObject({
Authorization: `Bearer ${TOKEN}`,
'User-Key': USER_KEY,
})
})
})
describe('pagination params', () => {
it('getPage sends page + rows and NOT pageRequested/rowsRequested', async () => {
fetchSpy.mockResolvedValueOnce(
jsonResponse({ pageRequested: 2, totalPages: 5, totalRows: 230, data: [{ id: 1 }] }),
)
const result = await client.getPage(TOKEN, USER_KEY, '/customerinvoice/batch', {
page: 2,
pageSize: 50,
})
const url = new URL(String(fetchSpy.mock.calls[0][0]))
expect(url.searchParams.get('page')).toBe('2')
expect(url.searchParams.get('rows')).toBe('50')
expect(url.searchParams.has('pageRequested')).toBe(false)
expect(url.searchParams.has('rowsRequested')).toBe(false)
expect(result.page).toBe(2)
expect(result.totalPages).toBe(5)
expect(result.totalCount).toBe(230)
expect(result.items).toEqual([{ id: 1 }])
})
it('getPage tolerates a missing data array', async () => {
fetchSpy.mockResolvedValueOnce(jsonResponse({ pageRequested: 99, totalPages: 5, totalRows: 230 }))
const result = await client.getPage(TOKEN, USER_KEY, '/customerinvoice/batch', { page: 99 })
expect(result.items).toEqual([])
})
})
describe('getAll', () => {
it('accepts a plain array response (e.g. /customer, /financialyear)', async () => {
fetchSpy.mockResolvedValueOnce(jsonResponse([{ id: '1000' }, { id: '1001' }]))
const items = await client.getAll(TOKEN, USER_KEY, '/customer')
expect(items).toHaveLength(2)
})
it('unwraps a paginated envelope when one is returned', async () => {
fetchSpy.mockResolvedValueOnce(
jsonResponse({ pageRequested: 1, totalPages: 1, totalRows: 1, data: [{ id: 'x' }] }),
)
const items = await client.getAll(TOKEN, USER_KEY, '/whatever')
expect(items).toEqual([{ id: 'x' }])
})
})
describe('listFinancialYears', () => {
it('returns the plain /financialyear array', async () => {
fetchSpy.mockResolvedValueOnce(
jsonResponse([
{ entityId: 9, id: '202501', fromDate: '2025-01-01', toDate: '2025-12-31', open: true },
]),
)
const years = await client.listFinancialYears(TOKEN, USER_KEY)
expect(years).toHaveLength(1)
expect(years[0].fromDate).toBe('2025-01-01')
expect(String(fetchSpy.mock.calls[0][0])).toContain('/financialyear')
})
})
describe('retry semantics', () => {
it('retries on 429 and succeeds', async () => {
fetchSpy
.mockResolvedValueOnce(new Response('slow down', { status: 429 }))
.mockResolvedValueOnce(jsonResponse({ ok: true }))
const result = await client.get<{ ok: boolean }>(TOKEN, USER_KEY, '/details')
expect(result).toEqual({ ok: true })
expect(fetchSpy).toHaveBeenCalledTimes(2)
}, 10_000)
it('does NOT retry on 401', async () => {
fetchSpy.mockResolvedValue(new Response('unauthorized', { status: 401 }))
await expect(client.get(TOKEN, USER_KEY, '/details')).rejects.toMatchObject({
name: 'BjornLundenApiError',
statusCode: 401,
})
expect(fetchSpy).toHaveBeenCalledTimes(1)
})
it('does NOT retry on 404', async () => {
fetchSpy.mockResolvedValue(new Response('not found', { status: 404 }))
await expect(client.get(TOKEN, USER_KEY, '/nope')).rejects.toBeInstanceOf(BjornLundenApiError)
expect(fetchSpy).toHaveBeenCalledTimes(1)
})
it('exposes status and body on the error for upstream classification', async () => {
// Fresh Response per attempt — a Response body can only be read once
fetchSpy.mockImplementation(() =>
Promise.resolve(new Response('{"status":"INTERNAL_SERVER_ERROR"}', { status: 500 })),
)
// 500 IS retried (transient by contract) — all attempts exhausted here.
// A wrong User-Key also answers 500; submit-token validation relies on
// the BjornLundenApiError shape to classify it.
await expect(client.get(TOKEN, USER_KEY, '/details')).rejects.toMatchObject({
statusCode: 500,
body: '{"status":"INTERNAL_SERVER_ERROR"}',
})
expect(fetchSpy).toHaveBeenCalledTimes(3)
}, 15_000)
})
})
@@ -0,0 +1,314 @@
import { describe, it, expect } from 'vitest'
import {
mapBLToSalesInvoice,
mapBLToSupplierInvoice,
mapBLToCustomer,
mapBLToSupplier,
mapBLToJournal,
mapBLToAccountingAccount,
mapBLToCompanyInformation,
} from '../mapper'
/**
* Fixture-driven tests against payload shapes captured from the BL sandbox
* (dev_docs/bjorn_lunden/fixtures/, 2026-06). The shapes matter more than the
* values: `status` is an ARRAY of numeric codes, customer and supplier use
* different field names for the same concepts, and journal `amount` carries
* the debit/credit sign.
*/
// Trimmed + anonymized /customerinvoice/batch item
function salesInvoiceRaw(over: Record<string, unknown> = {}): Record<string, unknown> {
return {
entityId: 1,
amount: 1250.0,
amountInOriginalCurrency: 1250.0,
amountInLocalCurrency: 1250.0,
amountPaidInOriginalCurrency: 0.0,
amountPaidInLocalCurrency: 0.0,
currency: 'SEK',
customerId: '1000',
customerName: 'Test Kund AB',
dueDate: '2026-03-13',
invoiceNumber: 1, // BL sends a NUMBER on the customer side
invoiceDate: '2026-02-11',
ocrRef: '133',
paid: false,
preliminary: false,
status: [1],
...over,
}
}
// Trimmed + anonymized /supplierinvoice/batch item
function supplierInvoiceRaw(over: Record<string, unknown> = {}): Record<string, unknown> {
return {
entityId: 7,
amountInLocalCurrency: 1000.0,
amountPaidInLocalCurrency: 0.0,
amountRemainingInLocalCurrency: 1000.0,
currency: 'SEK',
dueDate: '2025-12-01',
invoiceDate: '2025-11-01',
invoiceNumber: '1', // string on the supplier side
paid: false,
preliminary: false,
supplierId: '1000',
supplierName: 'Leverantör AB',
status: [1],
...over,
}
}
describe('deriveBLInvoiceStatus via mapBLToSalesInvoice', () => {
it('paid=false with overdue code [1] → overdue', () => {
expect(mapBLToSalesInvoice(salesInvoiceRaw()).status).toBe('overdue')
})
it('unpaid without codes → booked', () => {
expect(mapBLToSalesInvoice(salesInvoiceRaw({ status: [0] })).status).toBe('booked')
expect(mapBLToSalesInvoice(salesInvoiceRaw({ status: [] })).status).toBe('booked')
expect(mapBLToSalesInvoice(salesInvoiceRaw({ status: undefined })).status).toBe('booked')
})
it('paid=true → paid', () => {
const dto = mapBLToSalesInvoice(salesInvoiceRaw({ paid: true, amountPaidInLocalCurrency: 1250.0 }))
expect(dto.status).toBe('paid')
expect(dto.paymentStatus.paid).toBe(true)
expect(dto.paymentStatus.balance.value).toBe(0)
})
it('fully-paid code [2] → paid even when the paid flag lags', () => {
expect(mapBLToSalesInvoice(salesInvoiceRaw({ status: [2] })).status).toBe('paid')
expect(mapBLToSalesInvoice(salesInvoiceRaw({ status: [4] })).status).toBe('paid') // overpaid
})
it('deleted [5] and customer loss [6] are terminal — win over paid', () => {
expect(mapBLToSalesInvoice(salesInvoiceRaw({ status: [5], paid: true })).status).toBe('cancelled')
expect(mapBLToSalesInvoice(salesInvoiceRaw({ status: [6] })).status).toBe('cancelled')
})
it('collection codes [7]/[8] → overdue', () => {
expect(mapBLToSalesInvoice(salesInvoiceRaw({ status: [7] })).status).toBe('overdue')
expect(mapBLToSalesInvoice(salesInvoiceRaw({ status: [8] })).status).toBe('overdue')
})
it('preliminary → draft', () => {
expect(mapBLToSalesInvoice(salesInvoiceRaw({ preliminary: true, status: [0] })).status).toBe('draft')
})
it('maps amounts, customer identification and number-typed invoiceNumber', () => {
const dto = mapBLToSalesInvoice(salesInvoiceRaw({ amountPaidInLocalCurrency: 250.0 }))
expect(dto.invoiceNumber).toBe('1')
expect(dto.id).toBe('1')
expect(dto.issueDate).toBe('2026-02-11')
expect(dto.dueDate).toBe('2026-03-13')
expect(dto.legalMonetaryTotal.payableAmount.value).toBe(1250)
expect(dto.paymentStatus.balance.value).toBe(1000) // 1250 - 250
expect(dto.customer.name).toBe('Test Kund AB')
expect(dto.customer.identifications).toEqual([{ id: '1000', schemeId: 'BL:CUSTOMER_ID' }])
})
})
describe('mapBLToSupplierInvoice', () => {
it('balance comes from amountRemainingInLocalCurrency', () => {
const dto = mapBLToSupplierInvoice(
supplierInvoiceRaw({ amountPaidInLocalCurrency: 400.0, amountRemainingInLocalCurrency: 600.0 }),
)
expect(dto.paymentStatus.balance.value).toBe(600)
expect(dto.paymentStatus.paid).toBe(false)
})
it('falls back to total − paid when amountRemaining is absent', () => {
const dto = mapBLToSupplierInvoice(
supplierInvoiceRaw({ amountPaidInLocalCurrency: 400.0, amountRemainingInLocalCurrency: undefined }),
)
expect(dto.paymentStatus.balance.value).toBe(600)
})
it('maps supplier identification and entityId as id', () => {
const dto = mapBLToSupplierInvoice(supplierInvoiceRaw())
expect(dto.id).toBe('7')
expect(dto.invoiceNumber).toBe('1')
expect(dto.supplier.name).toBe('Leverantör AB')
expect(dto.supplier.identifications).toEqual([{ id: '1000', schemeId: 'BL:SUPPLIER_ID' }])
expect(dto.status).toBe('overdue') // status [1] + unpaid
})
it('deleted code [5] → cancelled', () => {
expect(mapBLToSupplierInvoice(supplierInvoiceRaw({ status: [5] })).status).toBe('cancelled')
})
})
describe('mapBLToCustomer — customer-side field names', () => {
// Trimmed /customer item: organisationNumber + zip (NOT organisationId/zipCode)
const raw: Record<string, unknown> = {
entityId: 1,
id: '1000',
name: 'test gubbe',
organisationNumber: '1234567890',
street: '',
box: 'Chillsgatan 26A',
zip: '11539',
city: 'Stockholm',
country: 'Sverige',
phone: '0700000000',
email: 'kund@example.com',
currency: 'SEK',
vatNumber: '',
paymentTerms: '30', // string in BL
closed: false,
}
it('maps id, orgnr, address, contact and payment terms', () => {
const dto = mapBLToCustomer(raw)
expect(dto.id).toBe('1000')
expect(dto.customerNumber).toBe('1000')
expect(dto.party.name).toBe('test gubbe')
expect(dto.party.identifications).toEqual([{ id: '1234567890', schemeId: 'SE:ORGNR' }])
expect(dto.party.postalAddress?.postalZone).toBe('11539')
expect(dto.party.postalAddress?.additionalStreetName).toBe('Chillsgatan 26A')
expect(dto.party.contact?.email).toBe('kund@example.com')
expect(dto.defaultPaymentTermsDays).toBe(30)
expect(dto.active).toBe(true)
})
it('closed → inactive', () => {
expect(mapBLToCustomer({ ...raw, closed: true }).active).toBe(false)
})
})
describe('mapBLToSupplier — supplier-side field names', () => {
// Trimmed /supplier item: organisationId + zipCode + vatNr (NOT organisationNumber/zip/vatNumber)
const raw: Record<string, unknown> = {
entityId: 1,
id: '1000',
name: 'Leverantör AB',
organisationId: '5512345678',
address1: 'Östermalmsgatan 26 A',
address2: 'C/O Test',
zipCode: '11426',
city: 'STOCKHOLM',
countryCode: 'SE',
email: 'leverantor@example.com',
bg: '55555555',
pg: null,
iban: null,
vatNr: 'SE551234567801',
paymentTerms: '30',
closed: false,
}
it('maps orgnr, address, giro and VAT number from supplier-flavored fields', () => {
const dto = mapBLToSupplier(raw)
expect(dto.id).toBe('1000')
expect(dto.party.identifications).toEqual([{ id: '5512345678', schemeId: 'SE:ORGNR' }])
expect(dto.party.postalAddress?.streetName).toBe('Östermalmsgatan 26 A')
expect(dto.party.postalAddress?.postalZone).toBe('11426')
expect(dto.party.postalAddress?.countryCode).toBe('SE')
expect(dto.vatNumber).toBe('SE551234567801')
expect(dto.bankGiro).toBe('55555555')
expect(dto.plusGiro).toBeNull()
expect(dto.defaultPaymentTermsDays).toBe(30)
})
})
describe('mapBLToJournal — amount sign convention', () => {
it('positive amount → debit, negative → credit', () => {
const dto = mapBLToJournal({
entityId: 40748,
journalId: 'B',
journalEntryId: 3,
journalEntryDate: '2025-01-08',
journalEntryText: 'Banköverföring',
financialYearId: 9,
ledgerEntries: [
{ accountId: '1930', amount: -21600.0, text: '' },
{ accountId: '1681', amount: 21600.0, text: '' },
],
totalDebitSum: 21600.0,
totalCreditSum: 21600.0,
})
expect(dto.entries).toHaveLength(2)
expect(dto.entries[0]).toMatchObject({ accountNumber: '1930', debit: 0, credit: 21600 })
expect(dto.entries[1]).toMatchObject({ accountNumber: '1681', debit: 21600, credit: 0 })
expect(dto.totalDebit?.value).toBe(21600)
expect(dto.totalCredit?.value).toBe(21600)
})
it('tolerates empty ledgerEntries (sandbox has bare journal entries)', () => {
const dto = mapBLToJournal({
entityId: 1,
journalEntryId: 206,
journalEntryDate: '2025-02-11',
ledgerEntries: [],
})
expect(dto.entries).toEqual([])
expect(dto.totalDebit?.value).toBe(0)
})
})
describe('mapBLToAccountingAccount', () => {
it("prefers BL's own type field (income→revenue, cost→expense)", () => {
expect(mapBLToAccountingAccount({ id: '3010', name: 'Försäljning', type: 'income' }).type).toBe('revenue')
expect(mapBLToAccountingAccount({ id: '8999', name: 'Årets resultat', type: 'cost' }).type).toBe('expense')
expect(mapBLToAccountingAccount({ id: '1930', name: 'Företagskonto', type: 'asset' }).type).toBe('asset')
expect(mapBLToAccountingAccount({ id: '2440', name: 'Leverantörsskulder', type: 'liability' }).type).toBe('liability')
})
it('covers off-plan accounts via the type field where ranges cannot', () => {
// 0099 "Konvertering" is below the 1xxx asset range
expect(mapBLToAccountingAccount({ id: '0099', name: 'Konvertering', type: 'asset' }).type).toBe('asset')
})
it('falls back to BAS ranges when type is missing', () => {
expect(mapBLToAccountingAccount({ id: '1510', name: 'Kundfordringar' }).type).toBe('asset')
expect(mapBLToAccountingAccount({ id: '2610', name: 'Utgående moms' }).type).toBe('liability')
expect(mapBLToAccountingAccount({ id: '3010', name: 'Försäljning' }).type).toBe('revenue')
expect(mapBLToAccountingAccount({ id: '6570', name: 'Bankkostnader' }).type).toBe('expense')
})
it('maps vatCode, sruCode and closed flag', () => {
const dto = mapBLToAccountingAccount({
id: '1930',
name: 'Företagskonto',
vatCode: '0',
sruCode: '7281',
closed: true,
type: 'asset',
})
expect(dto.accountNumber).toBe('1930')
expect(dto.vatCode).toBe('0')
expect(dto.sruCode).toBe('7281')
expect(dto.active).toBe(false)
})
})
describe('mapBLToCompanyInformation — /details shape', () => {
it('maps name, orgnr and preferredSettings.currency', () => {
const dto = mapBLToCompanyInformation({
entityId: 1,
name: 'Arcim TEST',
orgNumber: '5595386219',
street: '',
box: 'Östermalmsgatan 26A',
zip: '114 26',
city: 'Stockholm',
country: 'Sverige',
email: 'info@example.com',
vatNumber: '',
preferredSettings: { currency: 'SEK', activeFinancialYear: '202501' },
})
expect(dto.companyName).toBe('Arcim TEST')
expect(dto.organizationNumber).toBe('5595386219')
expect(dto.baseCurrency).toBe('SEK')
expect(dto.address?.postalZone).toBe('114 26')
expect(dto.legalEntity?.companyIdSchemeId).toBe('SE:ORGNR')
})
it('defaults currency to SEK when preferredSettings is absent', () => {
expect(mapBLToCompanyInformation({ name: 'X' }).baseCurrency).toBe('SEK')
})
})
+74 -4
View File
@@ -4,6 +4,9 @@ import { BL_BASE_URL, BL_RATE_LIMIT } from './config';
import { isTimeoutError } from '@/lib/http/fetch-with-timeout';
const FETCH_TIMEOUT_MS = 15_000;
// The SIE export renders a whole fiscal year server-side (megabytes for an
// active company) — give it more room than ordinary CRUD reads.
const SIE_FETCH_TIMEOUT_MS = 60_000;
export class BjornLundenApiError extends Error {
constructor(
@@ -34,6 +37,15 @@ interface BLPaginatedResponse<T> {
data: T[];
}
export interface BLFinancialYear {
entityId: number;
/** BL's period key, e.g. "202501" */
id?: string;
fromDate: string;
toDate: string;
open?: boolean;
}
export class BjornLundenClient {
private readonly rateLimiter: TokenBucketRateLimiter;
private readonly baseUrl: string;
@@ -43,7 +55,18 @@ export class BjornLundenClient {
this.rateLimiter = new TokenBucketRateLimiter(BL_RATE_LIMIT, 'ratelimit:bjornlunden');
}
async get<T>(accessToken: string, userKey: string, path: string): Promise<T> {
/**
* @param options.retry Set to false to fail fast on the first error instead
* of retrying. Used by credential probes, where a bad User-Key answers
* HTTP 500 (a "retryable" status) and would otherwise burn the full retry
* budget with backoff before reporting the bad key.
*/
async get<T>(
accessToken: string,
userKey: string,
path: string,
options?: { retry?: boolean },
): Promise<T> {
return withRetry(
async () => {
await this.rateLimiter.acquire();
@@ -69,7 +92,7 @@ export class BjornLundenClient {
return response.json() as Promise<T>;
},
{
maxAttempts: 3,
maxAttempts: options?.retry === false ? 1 : 3,
initialDelayMs: 1000,
shouldRetry: isRetryableError,
},
@@ -82,9 +105,12 @@ export class BjornLundenClient {
relativePath: string,
options?: { page?: number; pageSize?: number },
): Promise<{ items: T[]; page: number; totalPages: number; totalCount: number }> {
// Sandbox-verified: the batch endpoints honor `page` and `rows`. The
// response envelope echoes `pageRequested`, but a `pageRequested` REQUEST
// param is silently ignored (as is `rowsRequested`) — sending those would
// re-fetch page 1 forever.
const params = new URLSearchParams();
params.set('pageRequested', String(options?.page ?? 1));
params.set('rowsRequested', String(options?.pageSize ?? 50));
params.set('page', String(options?.page ?? 1));
params.set('rows', String(options?.pageSize ?? 50));
const path = `${relativePath}?${params.toString()}`;
@@ -106,6 +132,50 @@ export class BjornLundenClient {
return Array.isArray(response.data) ? response.data : [];
}
/**
* Fetch a binary resource with the same rate-limit/retry behavior as get().
* Used for the SIE export, which BL serves as raw bytes
* (Content-Type: text/vnd.sie-gruppen.si, typically CP437-encoded) despite
* the swagger declaring a base64 string — callers must run the bytes
* through detectEncoding()/decodeBuffer().
*/
async getBytes(accessToken: string, userKey: string, path: string): Promise<ArrayBuffer> {
return withRetry(
async () => {
await this.rateLimiter.acquire();
const url = `${this.baseUrl}${path}`;
const response = await fetch(url, {
headers: {
Authorization: `Bearer ${accessToken}`,
'User-Key': userKey,
},
signal: AbortSignal.timeout(SIE_FETCH_TIMEOUT_MS),
});
if (!response.ok) {
const body = await response.text().catch(() => '');
throw new BjornLundenApiError(
`Björn Lunden API error: ${response.status} ${response.statusText}`,
response.status,
body,
);
}
return response.arrayBuffer();
},
{
maxAttempts: 3,
initialDelayMs: 1000,
shouldRetry: isRetryableError,
},
);
}
/** All financial years registered in BL for the company behind the User-Key. */
async listFinancialYears(accessToken: string, userKey: string): Promise<BLFinancialYear[]> {
return this.getAll<BLFinancialYear>(accessToken, userKey, '/financialyear');
}
async getDetail<T>(accessToken: string, userKey: string, path: string): Promise<T> {
return this.get<T>(accessToken, userKey, path);
}
+40 -8
View File
@@ -1,5 +1,5 @@
import type {
SalesInvoiceDto, SalesInvoiceLineDto, InvoiceStatusCode,
SalesInvoiceDto, InvoiceStatusCode,
LegalMonetaryTotalDto, PaymentStatusDto,
SupplierInvoiceDto,
CustomerDto,
@@ -14,10 +14,30 @@ function amount(value: number | undefined | null, currency: string = 'SEK'): Amo
return { value: value ?? 0, currencyCode: currency };
}
/**
* Derive an invoice lifecycle status from BL's fields.
*
* Sandbox-verified: `status` is an ARRAY of numeric codes. Documented for
* customer invoices (supplier invoices observed to follow the same scheme):
* 0 Unpaid, 1 Overdue, 2 Fully paid, 3 Partially paid, 4 Overpaid,
* 5 Deleted, 6 Customer loss, 7 Marked for collection, 8 Sent for collection.
* Codes ≥40 are ROT/RUT, factoring ("Invoier") and e-invoice transport noise —
* ignored here. The `paid`/`preliminary` booleans are the primary signals;
* the codes refine terminal states the booleans can't express.
*/
function deriveBLInvoiceStatus(raw: Record<string, unknown>): InvoiceStatusCode {
const codes = Array.isArray(raw['status'])
? (raw['status'] as unknown[]).map(Number).filter(Number.isFinite)
: [];
// Terminal states win over payment flags: a deleted (makulerad) or
// written-off (kundförlust) invoice must not be imported as open/paid.
if (codes.includes(5) || codes.includes(6)) return 'cancelled';
if (raw['paid'] === true) return 'paid';
if (raw['preliminary'] === true) return 'draft';
const status = raw['status'] != null ? String(raw['status']).toLowerCase() : undefined;
if (codes.includes(2) || codes.includes(4)) return 'paid';
if (codes.includes(1) || codes.includes(7) || codes.includes(8)) return 'overdue';
// Defensive: handle a plain string status should BL ever send one
const status = typeof raw['status'] === 'string' ? raw['status'].toLowerCase() : undefined;
if (status === 'cancelled') return 'cancelled';
if (status === 'credited') return 'credited';
if (status === 'sent') return 'sent';
@@ -238,20 +258,32 @@ export function mapBLToJournal(raw: Record<string, unknown>): JournalDto {
};
}
/** BL's own account `type` values (sandbox-verified) → our AccountType. */
const BL_ACCOUNT_TYPE_MAP: Record<string, AccountType> = {
asset: 'asset',
liability: 'liability',
income: 'revenue',
cost: 'expense',
};
/**
* Map BL Account to AccountingAccountDto.
*
* BL fields: entityId, id (account number), name, vatCode, sruCode, closed, type
* Type derived from BAS plan number ranges.
* BL sends an explicit `type` (asset|liability|income|cost) — prefer it, since
* it also covers off-plan accounts like 0099 "Konvertering"; fall back to BAS
* number ranges when absent.
*/
export function mapBLToAccountingAccount(raw: Record<string, unknown>): AccountingAccountDto {
const num = Number(raw['id']);
let type: AccountType | undefined;
if (num >= 1000 && num < 2000) type = 'asset';
else if (num >= 2000 && num < 3000) type = 'liability';
else if (num >= 3000 && num < 4000) type = 'revenue';
else if (num >= 4000 && num < 9000) type = 'expense';
let type: AccountType | undefined = BL_ACCOUNT_TYPE_MAP[String(raw['type'] ?? '').toLowerCase()];
if (!type) {
if (num >= 1000 && num < 2000) type = 'asset';
else if (num >= 2000 && num < 3000) type = 'liability';
else if (num >= 3000 && num < 4000) type = 'revenue';
else if (num >= 4000 && num < 9000) type = 'expense';
}
return {
accountNumber: String(raw['id'] ?? ''),
@@ -0,0 +1,220 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { BrioxClient, BrioxApiError } from '../client';
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
});
}
describe('BrioxClient', () => {
let fetchSpy: ReturnType<typeof vi.spyOn>;
let setTimeoutSpy: ReturnType<typeof vi.spyOn> | null = null;
beforeEach(() => {
fetchSpy = vi.spyOn(globalThis, 'fetch');
});
afterEach(() => {
fetchSpy.mockRestore();
setTimeoutSpy?.mockRestore();
setTimeoutSpy = null;
});
/** Short-circuit withRetry backoff delays so retry tests run instantly. */
function interceptRetryDelays() {
const realSetTimeout = globalThis.setTimeout;
setTimeoutSpy = vi.spyOn(globalThis, 'setTimeout').mockImplementation(((
fn: () => void,
ms?: number,
) => {
if (ms && ms >= 500 && ms < 120_000) {
if (typeof fn === 'function') fn();
return 0 as unknown as ReturnType<typeof setTimeout>;
}
return realSetTimeout(fn, ms);
}) as typeof setTimeout);
}
describe('auth header', () => {
it('sends the RAW access token in Authorization — no Bearer prefix', async () => {
fetchSpy.mockResolvedValueOnce(jsonResponse({ data: {} }));
const client = new BrioxClient();
await client.get('raw-token-123', '/account');
const [, init] = fetchSpy.mock.calls[0];
expect((init as RequestInit).headers).toMatchObject({
Authorization: 'raw-token-123',
});
});
});
describe('pagination', () => {
it('getPaginated loops all pages via data.metainformation', async () => {
const page = (n: number, totalPages: number, items: unknown[]) =>
jsonResponse({
data: {
invoices: items,
metainformation: { total_pages: totalPages, current_page: n, total_count: 3 },
},
});
fetchSpy
.mockResolvedValueOnce(page(1, 2, [{ id: 1 }, { id: 2 }]))
.mockResolvedValueOnce(page(2, 2, [{ id: 3 }]));
const client = new BrioxClient();
const items = await client.getPaginated<{ id: number }>('t', '/customerinvoice', 'invoices');
expect(items.map((i) => i.id)).toEqual([1, 2, 3]);
expect(fetchSpy).toHaveBeenCalledTimes(2);
expect(String(fetchSpy.mock.calls[0][0])).toContain('/customerinvoice?page=1');
expect(String(fetchSpy.mock.calls[1][0])).toContain('/customerinvoice?page=2');
});
it('getPage forwards limit and frommodifieddate params', async () => {
fetchSpy.mockResolvedValueOnce(
jsonResponse({
data: { customers: [], metainformation: { total_pages: 1, current_page: 1, total_count: 0 } },
}),
);
const client = new BrioxClient();
await client.getPage('t', '/customer', 'customers', {
page: 2,
pageSize: 50,
fromModifiedDate: '2026-01-01',
});
const url = String(fetchSpy.mock.calls[0][0]);
expect(url).toContain('page=2');
expect(url).toContain('limit=50');
expect(url).toContain('frommodifieddate=2026-01-01');
});
it('getPage tolerates a missing metainformation block', async () => {
fetchSpy.mockResolvedValueOnce(jsonResponse({ data: { customers: [{ id: 1 }] } }));
const client = new BrioxClient();
const result = await client.getPage<{ id: number }>('t', '/customer', 'customers');
expect(result.items).toHaveLength(1);
expect(result.totalPages).toBe(1);
});
});
describe('retry', () => {
it('retries on 500 and succeeds', async () => {
interceptRetryDelays();
fetchSpy
.mockResolvedValueOnce(new Response('boom', { status: 500 }))
.mockResolvedValueOnce(jsonResponse({ data: { ok: true } }));
const client = new BrioxClient();
const result = await client.get<{ data: { ok: boolean } }>('t', '/account');
expect(result.data.ok).toBe(true);
expect(fetchSpy).toHaveBeenCalledTimes(2);
});
it('retries on 429 and succeeds', async () => {
interceptRetryDelays();
fetchSpy
.mockResolvedValueOnce(new Response('slow down', { status: 429 }))
.mockResolvedValueOnce(jsonResponse({ data: { ok: true } }));
const client = new BrioxClient();
const result = await client.get<{ data: { ok: boolean } }>('t', '/account');
expect(result.data.ok).toBe(true);
expect(fetchSpy).toHaveBeenCalledTimes(2);
});
it('does NOT retry on 401', async () => {
fetchSpy.mockResolvedValueOnce(new Response('unauthorized', { status: 401 }));
const client = new BrioxClient();
const err = await client.get('t', '/account').catch((e: unknown) => e);
expect(err).toBeInstanceOf(BrioxApiError);
expect((err as BrioxApiError).statusCode).toBe(401);
expect(fetchSpy).toHaveBeenCalledTimes(1);
});
it('does NOT retry on 404', async () => {
fetchSpy.mockResolvedValueOnce(new Response('not found', { status: 404 }));
const client = new BrioxClient();
const err = await client.get('t', '/account').catch((e: unknown) => e);
expect((err as BrioxApiError).statusCode).toBe(404);
expect(fetchSpy).toHaveBeenCalledTimes(1);
});
});
describe('getBytes (SIE octet-stream)', () => {
it('returns the raw bytes and authenticates with the raw token', async () => {
const bytes = new Uint8Array([0x23, 0x46, 0x4c, 0x41, 0x47, 0x47, 0x41]); // "#FLAGGA"
fetchSpy.mockResolvedValueOnce(
new Response(bytes, { status: 200, headers: { 'Content-Type': 'application/octet-stream' } }),
);
const client = new BrioxClient();
const buffer = await client.getBytes('raw-token-123', '/sie/10/4');
expect(new Uint8Array(buffer)).toEqual(bytes);
const [url, init] = fetchSpy.mock.calls[0];
expect(String(url)).toContain('/sie/10/4');
expect((init as RequestInit).headers).toMatchObject({ Authorization: 'raw-token-123' });
});
it('throws BrioxApiError with status on failure', async () => {
fetchSpy.mockResolvedValueOnce(new Response('nope', { status: 404 }));
const client = new BrioxClient();
const err = await client.getBytes('t', '/sie/99/4').catch((e: unknown) => e);
expect((err as BrioxApiError).statusCode).toBe(404);
});
});
describe('financial years', () => {
it('listFinancialYears unwraps data.financialyears', async () => {
fetchSpy.mockResolvedValueOnce(
jsonResponse({
data: {
financialyears: [
{ id: '9', fromdate: '2024-01-01', todate: '2024-12-31' },
{ id: '10', fromdate: '2025-01-01', todate: '2025-12-31' },
],
},
}),
);
const client = new BrioxClient();
const years = await client.listFinancialYears('t');
expect(years).toHaveLength(2);
expect(years[1]).toEqual({ id: '10', fromdate: '2025-01-01', todate: '2025-12-31' });
});
it('getCurrentFinancialYear picks the last completed year', async () => {
fetchSpy.mockResolvedValueOnce(
jsonResponse({
data: {
financialyears: [
{ id: '9', fromdate: '2024-01-01', todate: '2024-12-31' },
{ id: '10', fromdate: '2025-01-01', todate: '2025-12-31' },
{ id: '11', fromdate: '2099-01-01', todate: '2099-12-31' },
],
},
}),
);
const client = new BrioxClient();
await expect(client.getCurrentFinancialYear('t')).resolves.toBe('10');
});
});
});
@@ -0,0 +1,278 @@
import { describe, it, expect } from 'vitest';
import {
mapBrioxToSalesInvoice,
mapBrioxToSupplierInvoice,
mapBrioxToCustomer,
mapBrioxToAccountingAccount,
mapBrioxToCompanyInformation,
} from '../mapper';
/**
* Mirrors lib/providers/fortnox/__tests__/mapper-payment-status.test.ts:
* deriveInvoiceStatus and paymentStatus.paid share one isFullyPaid() source
* of truth, so status === 'paid' iff paymentStatus.paid. An ABSENT balance
* must never be read as paid. Field names follow the Briox docs (snake_case)
* — re-verify against sandbox payloads (plan Phase 3).
*/
function salesRaw(over: Record<string, unknown> = {}): Record<string, unknown> {
return {
id: 7,
invoice_number: '1001',
invoice_date: '2026-01-10',
due_date: '2026-02-10',
total_amount: 1000,
net_amount: 800,
customer_name: 'Kund AB',
booked: true,
...over,
};
}
function supplierRaw(over: Record<string, unknown> = {}): Record<string, unknown> {
return {
id: 9,
invoice_number: 'L-55',
invoice_date: '2026-01-10',
total_amount: 1000,
supplier_name: 'Leverantör AB',
booked: true,
...over,
};
}
describe('Briox mapper — paid-status consistency', () => {
it('sales: absent balance is NOT paid (defaults to full total, not 0)', () => {
const dto = mapBrioxToSalesInvoice(salesRaw({})); // no balance key
expect(dto.status).toBe('booked');
expect(dto.paymentStatus.paid).toBe(false);
expect(dto.paymentStatus.balance.value).toBe(1000);
});
it('sales: balance 0 with positive total → paid and status paid', () => {
const dto = mapBrioxToSalesInvoice(salesRaw({ balance: 0 }));
expect(dto.status).toBe('paid');
expect(dto.paymentStatus.paid).toBe(true);
expect(dto.paymentStatus.balance.value).toBe(0);
});
it('sales: positive balance → unpaid', () => {
const dto = mapBrioxToSalesInvoice(salesRaw({ balance: 250 }));
expect(dto.status).toBe('booked');
expect(dto.paymentStatus.paid).toBe(false);
expect(dto.paymentStatus.balance.value).toBe(250);
});
it('sales: string amounts ("0.00", "250.00") are coerced', () => {
const paid = mapBrioxToSalesInvoice(salesRaw({ total_amount: '1000.00', balance: '0.00' }));
expect(paid.status).toBe('paid');
expect(paid.paymentStatus.paid).toBe(true);
const open = mapBrioxToSalesInvoice(salesRaw({ total_amount: '1000.00', balance: '250.00' }));
expect(open.paymentStatus.paid).toBe(false);
expect(open.paymentStatus.balance.value).toBe(250);
expect(open.legalMonetaryTotal.taxInclusiveAmount?.value).toBe(1000);
});
it('sales: fully_paid flag with absent balance keeps status and paid consistent', () => {
const dto = mapBrioxToSalesInvoice(salesRaw({ fully_paid: true }));
expect(dto.status).toBe('paid');
expect(dto.paymentStatus.paid).toBe(true);
// paid ⇒ no outstanding balance even though the payload omits balance
expect(dto.paymentStatus.balance.value).toBe(0);
});
it('sales: explicit status "paid" wins even without amounts', () => {
const dto = mapBrioxToSalesInvoice(salesRaw({ status: 'paid' }));
expect(dto.status).toBe('paid');
expect(dto.paymentStatus.paid).toBe(true);
});
it('sales: zero-total invoice with balance 0 is NOT marked paid', () => {
const dto = mapBrioxToSalesInvoice(salesRaw({ total_amount: 0, balance: 0, booked: false }));
expect(dto.status).toBe('draft');
expect(dto.paymentStatus.paid).toBe(false);
});
it('sales: cancelled/credited status outranks settled amounts', () => {
expect(mapBrioxToSalesInvoice(salesRaw({ status: 'cancelled', balance: 0 })).status).toBe('cancelled');
expect(mapBrioxToSalesInvoice(salesRaw({ status: 'credited', balance: 0 })).status).toBe('credited');
});
it('sales: sent and overdue statuses derive when unpaid', () => {
expect(mapBrioxToSalesInvoice(salesRaw({ booked: false, sent: true, balance: 1000 })).status).toBe('sent');
expect(mapBrioxToSalesInvoice(salesRaw({ booked: false, status: 'overdue', balance: 1000 })).status).toBe('overdue');
});
it('supplier: absent balance is NOT paid (no false-paid on the supplier path)', () => {
const dto = mapBrioxToSupplierInvoice(supplierRaw({}));
expect(dto.status).toBe('booked');
expect(dto.paymentStatus.paid).toBe(false);
expect(dto.paymentStatus.balance.value).toBe(1000);
});
it('supplier: balance 0 → paid with zero balance', () => {
const dto = mapBrioxToSupplierInvoice(supplierRaw({ balance: 0 }));
expect(dto.status).toBe('paid');
expect(dto.paymentStatus.paid).toBe(true);
expect(dto.paymentStatus.balance.value).toBe(0);
});
});
describe('Briox mapper — line mapping', () => {
it('maps invoice rows to lines with stringified account numbers', () => {
const dto = mapBrioxToSalesInvoice(salesRaw({
rows: [
{
id: 1,
description: 'Konsulttimmar',
quantity: 10,
unit: 'h',
price: 80,
total: 800,
vat_rate: 25,
account_number: 3041,
article_number: 'A-1',
},
{ description: 'Frakt', total: 200 },
],
}));
expect(dto.lines).toHaveLength(2);
expect(dto.lines[0]).toMatchObject({
id: '1',
description: 'Konsulttimmar',
quantity: 10,
unitCode: 'h',
taxPercent: 25,
accountNumber: '3041',
articleNumber: 'A-1',
});
expect(dto.lines[0].lineExtensionAmount.value).toBe(800);
// Row without id falls back to its 1-based index
expect(dto.lines[1].id).toBe('2');
expect(dto.lines[1].accountNumber).toBeUndefined();
});
it('coerces string line-level price/total/quantity/vat_rate (Briox serializes numbers as strings)', () => {
const dto = mapBrioxToSalesInvoice(salesRaw({
rows: [
{
id: 1,
description: 'Konsulttimmar',
quantity: '10',
price: '250.00',
total: '2500.00',
vat_rate: '25',
},
],
}));
expect(dto.lines[0].quantity).toBe(10);
expect(dto.lines[0].unitPrice?.value).toBe(250);
expect(dto.lines[0].lineExtensionAmount.value).toBe(2500);
expect(dto.lines[0].taxPercent).toBe(25);
});
it('supplier: coerces string line-level amounts too', () => {
const dto = mapBrioxToSupplierInvoice(supplierRaw({
rows: [
{ id: 1, description: 'Material', quantity: '2', price: '125.50', total: '251.00' },
],
}));
expect(dto.lines[0].quantity).toBe(2);
expect(dto.lines[0].unitPrice?.value).toBe(125.5);
expect(dto.lines[0].lineExtensionAmount.value).toBe(251);
});
});
describe('Briox mapper — customers', () => {
it('maps org number, payment terms and address', () => {
const dto = mapBrioxToCustomer({
id: 12,
customer_number: 'K100',
name: 'Kund AB',
org_number: '5560000000',
payment_terms_days: '30',
address1: 'Storgatan 1',
zip_code: '111 22',
city: 'Stockholm',
email: 'kund@example.se',
modified_date: '2026-01-01',
});
expect(dto.customerNumber).toBe('K100');
expect(dto.party.identifications[0]).toEqual({ id: '5560000000', schemeId: 'SE:ORGNR' });
expect(dto.defaultPaymentTermsDays).toBe(30);
expect(dto.party.postalAddress?.streetName).toBe('Storgatan 1');
expect(dto.active).toBe(true);
});
});
describe('Briox mapper — accounts', () => {
it('uses id as account number, description as name, incoming_balance as opening balance', () => {
const dto = mapBrioxToAccountingAccount({
id: 1930,
description: 'Företagskonto',
active: '1',
incoming_balance: '12500.50',
});
expect(dto.accountNumber).toBe('1930');
expect(dto.name).toBe('Företagskonto');
expect(dto.type).toBe('asset');
expect(dto.active).toBe(true);
expect(dto.balanceCarriedForward).toBe(12500.5);
});
it('treats active "0" (string) as inactive', () => {
expect(mapBrioxToAccountingAccount({ id: 3041, description: 'Försäljning', active: '0' }).active).toBe(false);
expect(mapBrioxToAccountingAccount({ id: 3041, description: 'Försäljning', active: '1' }).active).toBe(true);
expect(mapBrioxToAccountingAccount({ id: 3041, description: 'Försäljning', active: false }).active).toBe(false);
});
it('derives the account type from the BAS class', () => {
expect(mapBrioxToAccountingAccount({ id: 1510, description: 'Kundfordringar' }).type).toBe('asset');
expect(mapBrioxToAccountingAccount({ id: 2440, description: 'Leverantörsskulder' }).type).toBe('liability');
expect(mapBrioxToAccountingAccount({ id: 3041, description: 'Försäljning' }).type).toBe('revenue');
expect(mapBrioxToAccountingAccount({ id: 6570, description: 'Bankkostnader' }).type).toBe('expense');
});
});
describe('Briox mapper — company information', () => {
it('unwraps the /user/info envelope and reads accounts[0]', () => {
const dto = mapBrioxToCompanyInformation({
info: {
company_name: 'Testbolaget AB',
accounts: [
{
database_label: 'Testbolaget AB',
organization_number: '5560000000',
email: 'info@testbolaget.se',
address: {
addressline1: 'Storgatan 1',
zip: '111 22',
city: 'Stockholm',
countrycode: 'SE',
},
},
],
},
});
expect(dto.companyName).toBe('Testbolaget AB');
expect(dto.organizationNumber).toBe('5560000000');
expect(dto.address?.streetName).toBe('Storgatan 1');
expect(dto.address?.postalZone).toBe('111 22');
expect(dto.address?.countryCode).toBe('SE');
expect(dto.contact?.email).toBe('info@testbolaget.se');
});
it('falls back to database_label when company_name is missing', () => {
const dto = mapBrioxToCompanyInformation({
info: { accounts: [{ database_label: 'Mitt Bolag', organization_number: '5561111111' }] },
});
expect(dto.companyName).toBe('Mitt Bolag');
});
});
+113
View File
@@ -0,0 +1,113 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { exchangeBrioxCode, refreshBrioxToken } from '../oauth';
import { BrioxApiError } from '../client';
/**
* Guards the token-exchange contract against the Briox swagger:
* POST /token?clientid={accountId}&token={applicationToken}
* POST /tokenrefresh?refreshtoken={refreshToken}&token={currentAccessToken}
*
* The refresh test pins the exact param mapping — the original implementation
* sent the refresh token for BOTH params (and took the account id as an
* unused first argument), which Briox rejects.
*/
function tokenResponse(over: Record<string, unknown> = {}): Response {
return new Response(
JSON.stringify({
data: {
client_id: '35649125',
access_token: 'access-1',
refresh_token: 'refresh-1',
expire_date: '2026-06-10 12:00:00',
// Swagger declares expire_timestamp as a STRING
expire_timestamp: String(Math.floor(Date.now() / 1000) + 7200),
...over,
},
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
);
}
describe('briox oauth', () => {
let fetchSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
fetchSpy = vi.spyOn(globalThis, 'fetch');
});
afterEach(() => {
fetchSpy.mockRestore();
});
it('exchange POSTs /token?clientid={accountId}&token={applicationToken}', async () => {
fetchSpy.mockResolvedValueOnce(tokenResponse());
const result = await exchangeBrioxCode('35649125', 'app-token-åäö');
expect(fetchSpy).toHaveBeenCalledTimes(1);
const [url, init] = fetchSpy.mock.calls[0];
expect(String(url)).toBe(
'https://api-se.briox.services/v2/token?clientid=35649125&token=app-token-%C3%A5%C3%A4%C3%B6',
);
expect((init as RequestInit).method).toBe('POST');
expect(result.access_token).toBe('access-1');
expect(result.refresh_token).toBe('refresh-1');
});
it('refresh POSTs /tokenrefresh?refreshtoken={refreshToken}&token={currentAccessToken}', async () => {
fetchSpy.mockResolvedValueOnce(
tokenResponse({ access_token: 'access-2', refresh_token: 'refresh-2' }),
);
const result = await refreshBrioxToken('refresh-1', 'expired-access-1');
const [url, init] = fetchSpy.mock.calls[0];
expect(String(url)).toBe(
'https://api-se.briox.services/v2/tokenrefresh?refreshtoken=refresh-1&token=expired-access-1',
);
expect((init as RequestInit).method).toBe('POST');
// Briox rotates both tokens — the new pair must be surfaced so the
// caller persists the rotated refresh token.
expect(result.access_token).toBe('access-2');
expect(result.refresh_token).toBe('refresh-2');
});
it('derives expires_in from a string expire_timestamp', async () => {
const now = Math.floor(Date.now() / 1000);
fetchSpy.mockResolvedValueOnce(tokenResponse({ expire_timestamp: String(now + 1800) }));
const result = await exchangeBrioxCode('1', 't');
expect(result.expires_in).toBeGreaterThan(1700);
expect(result.expires_in).toBeLessThanOrEqual(1800);
});
it('falls back to 3600 when expire_timestamp is in the past or unparseable', async () => {
const now = Math.floor(Date.now() / 1000);
fetchSpy.mockResolvedValueOnce(tokenResponse({ expire_timestamp: String(now - 60) }));
expect((await exchangeBrioxCode('1', 't')).expires_in).toBe(3600);
fetchSpy.mockResolvedValueOnce(tokenResponse({ expire_timestamp: 'not-a-number' }));
expect((await exchangeBrioxCode('1', 't')).expires_in).toBe(3600);
});
it('exchange throws BrioxApiError carrying the HTTP status on rejection', async () => {
fetchSpy.mockResolvedValueOnce(new Response('Unauthorized', { status: 401 }));
const err = await exchangeBrioxCode('1', 'wrong-token').catch((e: unknown) => e);
expect(err).toBeInstanceOf(BrioxApiError);
expect((err as BrioxApiError).statusCode).toBe(401);
});
it('refresh throws BrioxApiError carrying the HTTP status on rejection', async () => {
fetchSpy.mockResolvedValueOnce(new Response('Bad request', { status: 400 }));
const err = await refreshBrioxToken('r', 'a').catch((e: unknown) => e);
expect(err).toBeInstanceOf(BrioxApiError);
expect((err as BrioxApiError).statusCode).toBe(400);
});
});
+61 -8
View File
@@ -37,6 +37,12 @@ interface BrioxListResponse {
};
}
export interface BrioxFinancialYear {
id: string;
fromdate: string;
todate: string;
}
export class BrioxClient {
private readonly rateLimiter: TokenBucketRateLimiter;
private readonly baseUrl: string;
@@ -46,17 +52,24 @@ export class BrioxClient {
this.rateLimiter = new TokenBucketRateLimiter(BRIOX_RATE_LIMIT, 'ratelimit:briox');
}
// The Authorization header carries the RAW access token — no "Bearer "
// prefix. The swagger securityScheme is `apiKey` in header "Authorization"
// (the Swagger UI pastes the bare token), not an OAuth bearer scheme.
private authHeaders(accessToken: string): Record<string, string> {
return {
Authorization: accessToken,
Accept: 'application/json',
'Content-Type': 'application/json',
};
}
async get<T>(accessToken: string, path: string): Promise<T> {
return withRetry(
async () => {
await this.rateLimiter.acquire();
const url = `${this.baseUrl}${path}`;
const response = await fetch(url, {
headers: {
Authorization: accessToken,
Accept: 'application/json',
'Content-Type': 'application/json',
},
headers: this.authHeaders(accessToken),
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
});
@@ -79,6 +92,41 @@ export class BrioxClient {
);
}
/**
* Fetch a binary resource (e.g. the SIE export, served as an octet-stream)
* with the same rate-limit/retry behavior as get(). Returns the raw bytes —
* SIE files must go through detectEncoding()/decodeBuffer() since Briox may
* emit CP437, Windows-1252 or UTF-8.
*/
async getBytes(accessToken: string, path: string): Promise<ArrayBuffer> {
return withRetry(
async () => {
await this.rateLimiter.acquire();
const url = `${this.baseUrl}${path}`;
const response = await fetch(url, {
headers: { Authorization: accessToken },
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
});
if (!response.ok) {
const body = await response.text().catch(() => '');
throw new BrioxApiError(
`Briox API error: ${response.status} ${response.statusText}`,
response.status,
body,
);
}
return response.arrayBuffer();
},
{
maxAttempts: 3,
initialDelayMs: 1000,
shouldRetry: isRetryableError,
},
);
}
async getPage<T>(
accessToken: string,
path: string,
@@ -146,14 +194,19 @@ export class BrioxClient {
return allItems;
}
async getCurrentFinancialYear(accessToken: string): Promise<string> {
/** All financial years registered in Briox, oldest first (API order). */
async listFinancialYears(accessToken: string): Promise<BrioxFinancialYear[]> {
const response = await this.get<{
data: {
financialyears: { id: string; fromdate: string; todate: string }[];
financialyears: BrioxFinancialYear[];
};
}>(accessToken, '/financialyear');
const years = response.data?.financialyears ?? [];
return response.data?.financialyears ?? [];
}
async getCurrentFinancialYear(accessToken: string): Promise<string> {
const years = await this.listFinancialYears(accessToken);
if (years.length === 0) {
throw new BrioxApiError('No financial years found in Briox', 404);
}
+48 -16
View File
@@ -13,11 +13,32 @@ function amount(value: number | undefined | null, currency: string = 'SEK'): Amo
return { value: value ?? 0, currencyCode: currency };
}
/** Briox often serializes numbers as strings ("250.00") — coerce defensively. */
function num(value: unknown): number | undefined {
if (value == null || value === '') return undefined;
const n = Number(value);
return Number.isFinite(n) ? n : undefined;
}
/**
* Single source of truth for "is this invoice fully settled?", used by BOTH
* deriveInvoiceStatus and the paymentStatus.paid flag so they can never
* diverge (mirrors the Fortnox mapper). An ABSENT balance is treated as NOT
* paid — only an explicit paid status/flag, or a present non-positive balance
* on a positive-total invoice, counts as paid.
*/
function isFullyPaid(raw: Record<string, unknown>): boolean {
if (raw['status'] === 'paid' || raw['fully_paid'] === true) return true;
const total = num(raw['total_amount']);
const balance = num(raw['balance']);
return total != null && total > 0 && balance != null && balance <= 0;
}
function deriveInvoiceStatus(raw: Record<string, unknown>): InvoiceStatusCode {
const status = raw['status'] as string | undefined;
if (status === 'cancelled') return 'cancelled';
if (status === 'credited') return 'credited';
if (status === 'paid' || raw['fully_paid'] === true) return 'paid';
if (isFullyPaid(raw)) return 'paid';
if (status === 'booked' || raw['booked'] === true) return 'booked';
if (status === 'sent' || raw['sent'] === true) return 'sent';
if (status === 'overdue') return 'overdue';
@@ -49,31 +70,37 @@ function buildParty(name: string, orgNumber?: string, raw?: Record<string, unkno
export function mapBrioxToSalesInvoice(raw: Record<string, unknown>): SalesInvoiceDto {
const currency = (raw['currency_code'] as string) ?? 'SEK';
const total = raw['total_amount'] as number ?? 0;
const balance = raw['balance'] as number ?? 0;
const total = num(raw['total_amount']) ?? 0;
// Default an ABSENT balance to the full total (= fully unpaid), never 0, so
// a missing balance never silently reads as paid. When paid, force balance
// to 0 so the DTO is internally consistent (paid ⇒ nothing outstanding).
const paid = isFullyPaid(raw);
const balance = paid ? 0 : (num(raw['balance']) ?? total);
const rows = (raw['rows'] as Record<string, unknown>[] | undefined) ?? [];
// Line-level amounts arrive from the same string-serializing API as the
// header amounts — coerce ALL numerics through num(), never blind casts.
const lines: SalesInvoiceLineDto[] = rows.map((row, idx) => ({
id: String(row['id'] ?? idx + 1),
description: row['description'] as string | undefined,
quantity: row['quantity'] as number | undefined,
quantity: num(row['quantity']),
unitCode: row['unit'] as string | undefined,
unitPrice: row['price'] != null ? amount(row['price'] as number, currency) : undefined,
lineExtensionAmount: amount(row['total'] as number ?? 0, currency),
taxPercent: row['vat_rate'] as number | undefined,
unitPrice: row['price'] != null ? amount(num(row['price']), currency) : undefined,
lineExtensionAmount: amount(num(row['total']), currency),
taxPercent: num(row['vat_rate']),
accountNumber: row['account_number'] != null ? String(row['account_number']) : undefined,
articleNumber: row['article_number'] as string | undefined,
itemName: row['description'] as string | undefined,
}));
const legalMonetaryTotal: LegalMonetaryTotalDto = {
lineExtensionAmount: amount(raw['net_amount'] as number ?? total, currency),
lineExtensionAmount: amount(num(raw['net_amount']) ?? total, currency),
taxInclusiveAmount: amount(total, currency),
payableAmount: amount(total, currency),
};
const paymentStatus: PaymentStatusDto = {
paid: balance === 0 && total > 0,
paid,
balance: amount(balance, currency),
};
@@ -103,27 +130,32 @@ export function mapBrioxToSalesInvoice(raw: Record<string, unknown>): SalesInvoi
export function mapBrioxToSupplierInvoice(raw: Record<string, unknown>): SupplierInvoiceDto {
const currency = (raw['currency_code'] as string) ?? 'SEK';
const total = raw['total_amount'] as number ?? 0;
const balance = raw['balance'] as number ?? 0;
const total = num(raw['total_amount']) ?? 0;
// Same absent-balance hardening as the sales path: missing balance reads as
// fully unpaid, paid forces balance to 0.
const paid = isFullyPaid(raw);
const balance = paid ? 0 : (num(raw['balance']) ?? total);
const rows = (raw['rows'] as Record<string, unknown>[] | undefined) ?? [];
// Same string-coercion hardening as the sales path (Briox serializes
// numbers as strings) — route every numeric line field through num().
const lines: SupplierInvoiceLineDto[] = rows.map((row, idx) => ({
id: String(row['id'] ?? idx + 1),
description: row['description'] as string | undefined,
quantity: row['quantity'] as number | undefined,
unitPrice: row['price'] != null ? amount(row['price'] as number, currency) : undefined,
lineExtensionAmount: amount(row['total'] as number ?? 0, currency),
quantity: num(row['quantity']),
unitPrice: row['price'] != null ? amount(num(row['price']), currency) : undefined,
lineExtensionAmount: amount(num(row['total']), currency),
accountNumber: row['account_number'] != null ? String(row['account_number']) : undefined,
}));
const legalMonetaryTotal: LegalMonetaryTotalDto = {
lineExtensionAmount: amount(raw['net_amount'] as number ?? total, currency),
lineExtensionAmount: amount(num(raw['net_amount']) ?? total, currency),
taxInclusiveAmount: amount(total, currency),
payableAmount: amount(total, currency),
};
const paymentStatus: PaymentStatusDto = {
paid: balance === 0 && total > 0,
paid,
balance: amount(balance, currency),
};
+48 -46
View File
@@ -1,4 +1,5 @@
import { BRIOX_TOKEN_URL, BRIOX_REFRESH_URL } from './config';
import { BrioxApiError } from './client';
import type { TokenResponse } from '../types';
import {
fetchWithTimeout,
@@ -8,9 +9,11 @@ import {
interface BrioxTokenData {
access_token: string;
refresh_token: string;
client_id: number;
// Swagger declares both as strings ("35649125", "1640772931") but be
// tolerant of numbers — toTokenResponse coerces before arithmetic.
client_id: string | number;
expire_date: string;
expire_timestamp: number;
expire_timestamp: string | number;
}
interface BrioxTokenApiResponse {
@@ -18,63 +21,62 @@ interface BrioxTokenApiResponse {
}
function toTokenResponse(brioxData: BrioxTokenData): TokenResponse {
const expiresIn = brioxData.expire_timestamp - Math.floor(Date.now() / 1000);
const expiresIn = Number(brioxData.expire_timestamp) - Math.floor(Date.now() / 1000);
return {
access_token: brioxData.access_token,
refresh_token: brioxData.refresh_token,
token_type: 'Bearer',
expires_in: expiresIn > 0 ? expiresIn : 3600,
expires_in: Number.isFinite(expiresIn) && expiresIn > 0 ? expiresIn : 3600,
};
}
async function postForToken(url: string, description: string): Promise<TokenResponse> {
const response = await fetchWithTimeout(
url,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
},
{ timeoutMs: OAUTH_TIMEOUT_MS, description },
);
if (!response.ok) {
const body = await response.text().catch(() => '');
// BrioxApiError carries statusCode so callers can tell wrong credentials
// (400/401/404 from /token) apart from transient upstream failures.
throw new BrioxApiError(`${description} failed: ${response.status} ${body}`, response.status, body);
}
const result = await response.json() as BrioxTokenApiResponse;
return toTokenResponse(result.data);
}
/**
* Exchange the user's account ID + application token for an access/refresh
* token pair. The "clientid" is the user's Briox account ID (the long number
* next to the company name under "Your Account"), NOT an app-level credential
* — Briox has no developer client id/secret on our side.
*/
export async function exchangeBrioxCode(
clientId: string,
accountId: string,
applicationToken: string,
): Promise<TokenResponse> {
const url = `${BRIOX_TOKEN_URL}?clientid=${encodeURIComponent(clientId)}&token=${encodeURIComponent(applicationToken)}`;
const response = await fetchWithTimeout(
url,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
},
{ timeoutMs: OAUTH_TIMEOUT_MS, description: 'Briox token exchange' },
);
if (!response.ok) {
const body = await response.text().catch(() => '');
throw new Error(`Briox token exchange failed: ${response.status} ${body}`);
}
const result = await response.json() as BrioxTokenApiResponse;
return toTokenResponse(result.data);
const url = `${BRIOX_TOKEN_URL}?clientid=${encodeURIComponent(accountId)}&token=${encodeURIComponent(applicationToken)}`;
return postForToken(url, 'Briox token exchange');
}
/**
* Refresh an expired access token. Per the swagger, /tokenrefresh takes the
* refresh token as `refreshtoken` and the CURRENT (expired) access token as
* `token`. Briox rotates both tokens — the caller must persist the new
* refresh_token from the response or the next refresh will fail.
*/
export async function refreshBrioxToken(
clientId: string,
refreshToken: string,
currentAccessToken: string,
): Promise<TokenResponse> {
const url = `${BRIOX_REFRESH_URL}?refreshtoken=${encodeURIComponent(refreshToken)}&token=${encodeURIComponent(refreshToken)}`;
const response = await fetchWithTimeout(
url,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
},
{ timeoutMs: OAUTH_TIMEOUT_MS, description: 'Briox token refresh' },
);
if (!response.ok) {
const body = await response.text().catch(() => '');
throw new Error(`Briox token refresh failed: ${response.status} ${body}`);
}
const result = await response.json() as BrioxTokenApiResponse;
return toTokenResponse(result.data);
const url = `${BRIOX_REFRESH_URL}?refreshtoken=${encodeURIComponent(refreshToken)}&token=${encodeURIComponent(currentAccessToken)}`;
return postForToken(url, 'Briox token refresh');
}
+50
View File
@@ -127,6 +127,56 @@ export class FortnoxClient {
);
}
/**
* Fetch a binary resource with the same rate-limit/retry behavior as get().
* Used for the SIE export: response.text() would blind-decode as UTF-8 and
* irrecoverably turn CP437 å/ä/ö into U+FFFD, so callers must run the raw
* bytes through detectEncoding()/decodeBuffer() (mirrors Briox/BL clients).
*/
async getBytes(accessToken: string, path: string): Promise<ArrayBuffer> {
return withRetry(
async () => {
await this.rateLimiter.acquire();
const url = `${this.baseUrl}${path}`;
const response = await fetch(url, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
});
if (!response.ok) {
const body = await response.text().catch(() => '');
let retryAfterMs: number | undefined;
if (response.status === 429) {
const retryAfter = response.headers.get('Retry-After');
retryAfterMs = retryAfter ? Math.ceil(parseFloat(retryAfter)) * 1000 : undefined;
}
throw new FortnoxApiError(
`Fortnox API error: ${response.status} ${response.statusText}`,
response.status,
body,
retryAfterMs,
);
}
return response.arrayBuffer();
},
{
maxAttempts: 6,
initialDelayMs: 2000,
maxDelayMs: 60_000,
shouldRetry: isRetryableError,
getDelayMs: (error) => {
if (error instanceof FortnoxApiError && error.retryAfterMs) {
return error.retryAfterMs;
}
return undefined;
},
},
);
}
async getPage<T>(
accessToken: string,
path: string,
+3 -1
View File
@@ -16,8 +16,10 @@ export function getOAuthConfig(provider: string): OAuthConfig {
};
}
if (provider === 'briox') {
// No app-level credentials: the user's account ID + application token are
// exchanged per consent (exchangeBrioxCode). No env vars needed.
return {
clientId: process.env.BRIOX_CLIENT_ID ?? '',
clientId: '',
clientSecret: '',
redirectUri: '',
};
+71 -8
View File
@@ -5,6 +5,9 @@ import { refreshFortnoxToken } from './fortnox/oauth';
import { refreshVismaToken } from './visma/oauth';
import { refreshBrioxToken } from './briox/oauth';
import { refreshBjornLundenToken } from './bjornlunden/oauth';
import { createLogger } from '@/lib/logger';
const log = createLogger('providers/resolve-consent');
export interface ResolvedConsent {
consent: Record<string, unknown>;
@@ -93,30 +96,90 @@ export async function resolveConsent(companyId: string, consentId: string): Prom
throw { status: 401, message: 'Access token expired and no refresh token available' };
}
const config = getOAuthConfig(consent.provider as string);
let refreshed: TokenResponse;
if (consent.provider === 'fortnox') {
refreshed = await refreshFortnoxToken(config, tokens.refresh_token as string);
refreshed = await refreshFortnoxToken(getOAuthConfig('fortnox'), tokens.refresh_token as string);
} else if (consent.provider === 'briox') {
refreshed = await refreshBrioxToken(config.clientId, tokens.refresh_token as string);
// Briox /tokenrefresh wants the (expired) access token alongside the
// refresh token; no app-level config involved. Both tokens rotate —
// the new refresh_token is persisted below.
refreshed = await refreshBrioxToken(tokens.refresh_token as string, tokens.access_token as string);
} else {
refreshed = await refreshVismaToken(config, tokens.refresh_token as string);
refreshed = await refreshVismaToken(getOAuthConfig(consent.provider as string), tokens.refresh_token as string);
}
const newExpiresAt = new Date(Date.now() + refreshed.expires_in * 1000).toISOString();
await supabase
// Optimistic concurrency guard: providers like Briox and Fortnox rotate
// BOTH tokens on refresh, so two concurrent requests refreshing the same
// expired pair must not both persist — the second write would overwrite
// the pair the first request just stored with a possibly-dead one. Key
// the UPDATE on the token_expires_at we read above: if another request
// already rotated, zero rows match and we adopt the stored fresh tokens.
const { data: updatedRows, error: updateError } = await supabase
.from('provider_consent_tokens')
.update({
access_token: refreshed.access_token,
refresh_token: refreshed.refresh_token,
token_expires_at: newExpiresAt,
})
.eq('consent_id', consentId);
.eq('consent_id', consentId)
.eq('token_expires_at', tokens.token_expires_at as string)
.select('id');
return { consent, accessToken: refreshed.access_token };
if (updateError) {
// The provider has ALREADY rotated the tokens but we failed to persist
// the new pair — the stored pair is now dead and every later call on
// this consent will fail. Log loudly; the only recovery is to
// disconnect and re-enter the provider credentials.
log.error(
`Failed to persist rotated ${consent.provider} tokens for consent ${consentId} — ` +
'the stored credentials are now invalid and the consent will break',
{ reason: updateError.message },
);
throw {
status: 500,
message:
'Token refresh succeeded at the provider but the rotated tokens could not be saved. ' +
'The stored credentials are no longer valid — disconnect the provider and re-enter the credentials.',
};
}
if (!updatedRows || updatedRows.length === 0) {
// Lost the refresh race: a concurrent request already rotated and
// persisted a fresh pair. Use those tokens as-is — calling the provider
// refresh endpoint again here would invalidate the winner's pair.
const { data: freshRows } = await supabase
.from('provider_consent_tokens')
.select('*')
.eq('consent_id', consentId)
.limit(1);
const fresh = freshRows?.[0];
if (fresh?.access_token) {
return {
consent,
accessToken: fresh.access_token as string,
providerCompanyId: (fresh.provider_company_id ?? tokens.provider_company_id) as
| string
| undefined,
};
}
// Token row vanished mid-flight (disconnect?) — fall through to our own
// refreshed pair, which the provider still considers the latest one.
}
return {
consent,
accessToken: refreshed.access_token,
providerCompanyId: tokens.provider_company_id as string | undefined,
};
}
return { consent, accessToken: tokens.access_token as string };
return {
consent,
accessToken: tokens.access_token as string,
providerCompanyId: tokens.provider_company_id as string | undefined,
};
}