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:
co-authored by
Claude Fable 5
parent
8e8b63a200
commit
db8983ba9e
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: '',
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user