feat: import Fortnox voucher attachments (#1541)
* feat: import Fortnox voucher attachments * fix: show Fortnox document import follow-up * fix: harden optional Fortnox document import * test: pin optional Fortnox import flow * fix: use browser timer handle type * fix: avoid serializing OAuth resume state
This commit is contained in:
@@ -0,0 +1,120 @@
|
||||
import { beforeEach, describe, expect, it, vi, type Mock } from 'vitest'
|
||||
import { createMockRequest, createMockSupabase, parseJsonResponse } from '@/tests/helpers'
|
||||
import type { ExtensionContext } from '@/lib/extensions/types'
|
||||
|
||||
vi.mock('../lib/import-documents', () => {
|
||||
class FortnoxDocumentScopesRequiredError extends Error {
|
||||
readonly code = 'PROVIDER_DOCUMENT_SCOPES_REQUIRED'
|
||||
|
||||
constructor() {
|
||||
super('Fortnox consent lacks archive/connectfile scope: reconnect required')
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
FortnoxDocumentScopesRequiredError,
|
||||
importProviderDocuments: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../lib/provider-client', () => ({
|
||||
createConsent: vi.fn(),
|
||||
getConsent: vi.fn(),
|
||||
listConsents: vi.fn(),
|
||||
generateOtc: vi.fn(),
|
||||
consumeOAuthState: vi.fn(),
|
||||
getAuthUrl: vi.fn(),
|
||||
exchangeAuthToken: vi.fn(),
|
||||
submitProviderToken: vi.fn(),
|
||||
acceptConsent: vi.fn(),
|
||||
deleteConsent: vi.fn(),
|
||||
resolveConsent: vi.fn(),
|
||||
fetchCompanyInfoDirect: vi.fn(),
|
||||
ProviderTokenInvalidError: class ProviderTokenInvalidError extends Error {},
|
||||
ProviderCompanyMismatchError: class ProviderCompanyMismatchError extends Error {},
|
||||
ConsentNotFoundError: class ConsentNotFoundError extends Error {},
|
||||
}))
|
||||
|
||||
import { arcimMigrationExtension } from '../index'
|
||||
import {
|
||||
FortnoxDocumentScopesRequiredError,
|
||||
importProviderDocuments,
|
||||
} from '../lib/import-documents'
|
||||
|
||||
const route = (arcimMigrationExtension.apiRoutes ?? []).find(
|
||||
(candidate) =>
|
||||
candidate.method === 'POST' && candidate.path === '/import-documents',
|
||||
)!
|
||||
|
||||
type RouteHandler = (request: Request, ctx?: ExtensionContext) => Promise<Response>
|
||||
const handler = route.handler as RouteHandler
|
||||
|
||||
function buildContext(): ExtensionContext {
|
||||
const { supabase } = createMockSupabase()
|
||||
;(supabase as unknown as { auth: unknown }).auth = {
|
||||
getUser: vi.fn().mockResolvedValue({ data: { user: { id: 'user-1' } } }),
|
||||
}
|
||||
return { supabase, companyId: 'company-1' } as unknown as ExtensionContext
|
||||
}
|
||||
|
||||
function request(dryRun: boolean) {
|
||||
return createMockRequest(
|
||||
'http://localhost/api/extensions/ext/arcim-migration/import-documents',
|
||||
{
|
||||
method: 'POST',
|
||||
body: { consentId: 'consent-1', dryRun },
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
describe('POST /import-documents', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('passes dry-run discovery through without storing documents', async () => {
|
||||
;(importProviderDocuments as Mock).mockResolvedValue({
|
||||
provider: 'fortnox',
|
||||
scanned: 4,
|
||||
linked: 3,
|
||||
skipped: 0,
|
||||
unmatched: 1,
|
||||
failed: 0,
|
||||
dryRun: true,
|
||||
unmatchedSamples: [],
|
||||
})
|
||||
|
||||
const response = await handler(request(true), buildContext())
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
success: boolean
|
||||
dryRun: boolean
|
||||
result: { scanned: number }
|
||||
}>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body).toMatchObject({ success: true, dryRun: true, result: { scanned: 4 } })
|
||||
expect(importProviderDocuments).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
companyId: 'company-1',
|
||||
consentId: 'consent-1',
|
||||
dryRun: true,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('returns an actionable 403 when Fortnox lacks archive/connectfile scopes', async () => {
|
||||
;(importProviderDocuments as Mock).mockRejectedValue(
|
||||
new FortnoxDocumentScopesRequiredError(),
|
||||
)
|
||||
|
||||
const response = await handler(request(false), buildContext())
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
error: { code: string; message: string; message_en?: string }
|
||||
}>(response)
|
||||
|
||||
expect(status).toBe(403)
|
||||
expect(body.error.code).toBe('PROVIDER_DOCUMENT_SCOPES_REQUIRED')
|
||||
expect(body.error.message).toContain('Koppla om Fortnox')
|
||||
expect(body.error.message_en).toContain('Reconnect Fortnox')
|
||||
})
|
||||
})
|
||||
@@ -8,18 +8,35 @@ import {
|
||||
downloadBokioUpload,
|
||||
type BokioVoucherRef,
|
||||
} from '@/lib/providers/bokio/attachments'
|
||||
import { FortnoxApiError } from '@/lib/providers/fortnox/client'
|
||||
import {
|
||||
downloadFortnoxArchiveFile,
|
||||
fetchFortnoxFileConnections,
|
||||
fetchFortnoxFinancialYears,
|
||||
type FortnoxFileConnection,
|
||||
type FortnoxFinancialYear,
|
||||
} from '@/lib/providers/fortnox/attachments'
|
||||
import { uploadDocument, computeSHA256, detectFileMagic } from '@/lib/core/documents/document-service'
|
||||
import { importProviderDocuments } from '../lib/import-documents'
|
||||
|
||||
// The Bokio client is constructed but never called directly (the attachments
|
||||
// module is mocked), so a bare stub avoids touching real config/rate-limiter.
|
||||
vi.mock('@/lib/providers/bokio/client', () => ({ BokioClient: class {} }))
|
||||
vi.mock('@/lib/providers/fortnox/client', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/lib/providers/fortnox/client')>()
|
||||
return { ...actual, FortnoxClient: class {} }
|
||||
})
|
||||
vi.mock('@/lib/providers/resolve-consent', () => ({ resolveConsent: vi.fn() }))
|
||||
vi.mock('@/lib/providers/bokio/attachments', () => ({
|
||||
fetchBokioUploads: vi.fn(),
|
||||
fetchBokioVoucherIndex: vi.fn(),
|
||||
downloadBokioUpload: vi.fn(),
|
||||
}))
|
||||
vi.mock('@/lib/providers/fortnox/attachments', () => ({
|
||||
fetchFortnoxFinancialYears: vi.fn(),
|
||||
fetchFortnoxFileConnections: vi.fn(),
|
||||
downloadFortnoxArchiveFile: vi.fn(),
|
||||
}))
|
||||
vi.mock('@/lib/core/documents/document-service', () => ({
|
||||
uploadDocument: vi.fn(),
|
||||
computeSHA256: vi.fn(),
|
||||
@@ -31,6 +48,9 @@ const mockResolveConsent = vi.mocked(resolveConsent)
|
||||
const mockFetchUploads = vi.mocked(fetchBokioUploads)
|
||||
const mockFetchVoucherIndex = vi.mocked(fetchBokioVoucherIndex)
|
||||
const mockDownload = vi.mocked(downloadBokioUpload)
|
||||
const mockFetchFortnoxFinancialYears = vi.mocked(fetchFortnoxFinancialYears)
|
||||
const mockFetchFortnoxFileConnections = vi.mocked(fetchFortnoxFileConnections)
|
||||
const mockDownloadFortnoxArchiveFile = vi.mocked(downloadFortnoxArchiveFile)
|
||||
const mockUpload = vi.mocked(uploadDocument)
|
||||
const mockSha256 = vi.mocked(computeSHA256)
|
||||
const mockDetectMagic = vi.mocked(detectFileMagic)
|
||||
@@ -76,9 +96,21 @@ beforeEach(() => {
|
||||
const VOUCHER_REF: BokioVoucherRef = { series: 'V', number: 33, date: '2021-03-01' }
|
||||
const PERIODS = [{ id: 'fp-2021', period_start: '2021-02-04', period_end: '2021-12-31' }]
|
||||
const GNUBOK_VOUCHERS = [
|
||||
{ id: 'je-1', fiscal_period_id: 'fp-2021', source_voucher_series: 'V', source_voucher_number: 33 },
|
||||
{ id: 'je-1', fiscal_period_id: 'fp-2021', entry_date: '2021-03-01', source_voucher_series: 'V', source_voucher_number: 33 },
|
||||
]
|
||||
const UPLOAD = { id: 'up-1', description: 'Kvitto', contentType: 'application/pdf', journalEntryId: 'bokio-je-1' }
|
||||
const FORTNOX_YEAR: FortnoxFinancialYear = {
|
||||
id: 3,
|
||||
fromDate: '2021-02-04',
|
||||
toDate: '2021-12-31',
|
||||
}
|
||||
const FORTNOX_CONNECTION: FortnoxFileConnection = {
|
||||
fileId: 'fortnox-file-1',
|
||||
name: 'kvitto.pdf',
|
||||
series: 'A',
|
||||
number: 12,
|
||||
financialYearId: 3,
|
||||
}
|
||||
|
||||
function wireBokio(
|
||||
opts: { existingAttachments?: { sha256_hash: string; journal_entry_id: string | null }[] } = {},
|
||||
@@ -93,6 +125,45 @@ function wireBokio(
|
||||
})
|
||||
}
|
||||
|
||||
function wireFortnox(
|
||||
opts: {
|
||||
years?: FortnoxFinancialYear[]
|
||||
connections?: FortnoxFileConnection[]
|
||||
periods?: typeof PERIODS
|
||||
vouchers?: typeof GNUBOK_VOUCHERS
|
||||
providerCompanyId?: string
|
||||
} = {},
|
||||
) {
|
||||
mockResolveConsent.mockResolvedValue({
|
||||
consent: { provider: 'fortnox' },
|
||||
accessToken: 'fortnox-token',
|
||||
providerCompanyId: opts.providerCompanyId,
|
||||
} as never)
|
||||
mockFetchFortnoxFinancialYears.mockResolvedValue(opts.years ?? [FORTNOX_YEAR])
|
||||
mockFetchFortnoxFileConnections.mockResolvedValue(
|
||||
opts.connections ?? [FORTNOX_CONNECTION],
|
||||
)
|
||||
mockDownloadFortnoxArchiveFile.mockResolvedValue({
|
||||
bytes: bytesOf('FORTNOX-PDF'),
|
||||
contentType: 'application/pdf',
|
||||
})
|
||||
return rangeMockSupabase({
|
||||
fiscal_periods: opts.periods ?? PERIODS,
|
||||
journal_entries:
|
||||
opts.vouchers ??
|
||||
[
|
||||
{
|
||||
id: 'je-1',
|
||||
fiscal_period_id: 'fp-2021',
|
||||
entry_date: '2021-03-01',
|
||||
source_voucher_series: 'A',
|
||||
source_voucher_number: 12,
|
||||
},
|
||||
],
|
||||
document_attachments: [],
|
||||
})
|
||||
}
|
||||
|
||||
describe('importProviderDocuments', () => {
|
||||
it('resolves a receipt to its verifikat and archives it linked via upload_source=api', async () => {
|
||||
const supabase = wireBokio()
|
||||
@@ -105,7 +176,11 @@ describe('importProviderDocuments', () => {
|
||||
expect(userId).toBe(USER)
|
||||
expect(companyId).toBe(COMPANY)
|
||||
expect(file).toMatchObject({ name: 'Kvitto.pdf', type: 'application/pdf' })
|
||||
expect(metadata).toEqual({ upload_source: 'api', journal_entry_id: 'je-1' })
|
||||
expect(metadata).toEqual({
|
||||
upload_source: 'api',
|
||||
journal_entry_id: 'je-1',
|
||||
idempotency_key: 'je-1',
|
||||
})
|
||||
})
|
||||
|
||||
it('skips a receipt already archived on the same verifikat (sha256 + journal entry idempotency)', async () => {
|
||||
@@ -159,17 +234,297 @@ describe('importProviderDocuments', () => {
|
||||
expect(mockUpload).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('is a no-op for non-Bokio providers in v1', async () => {
|
||||
it('imports a Fortnox receipt with its real filename and no provider company id', async () => {
|
||||
const supabase = wireFortnox()
|
||||
|
||||
const result = await importProviderDocuments({
|
||||
supabase,
|
||||
companyId: COMPANY,
|
||||
userId: USER,
|
||||
consentId: 'c1',
|
||||
})
|
||||
|
||||
expect(result).toMatchObject({
|
||||
provider: 'fortnox',
|
||||
scanned: 1,
|
||||
linked: 1,
|
||||
skipped: 0,
|
||||
unmatched: 0,
|
||||
failed: 0,
|
||||
})
|
||||
expect(mockFetchFortnoxFinancialYears).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'fortnox-token',
|
||||
)
|
||||
expect(mockFetchFortnoxFileConnections).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'fortnox-token',
|
||||
[3],
|
||||
)
|
||||
const [, , , file, metadata] = mockUpload.mock.calls[0]
|
||||
expect(file).toMatchObject({ name: 'kvitto.pdf', type: 'application/pdf' })
|
||||
expect(metadata).toEqual({
|
||||
upload_source: 'api',
|
||||
journal_entry_id: 'je-1',
|
||||
idempotency_key: 'je-1',
|
||||
})
|
||||
})
|
||||
|
||||
it('is a no-op for unsupported providers', async () => {
|
||||
mockResolveConsent.mockResolvedValue({
|
||||
consent: { provider: 'fortnox' },
|
||||
consent: { provider: 'visma' },
|
||||
accessToken: 'tok',
|
||||
providerCompanyId: 'co',
|
||||
} as never)
|
||||
|
||||
const result = await importProviderDocuments({ supabase: rangeMockSupabase({}), companyId: COMPANY, userId: USER, consentId: 'c1' })
|
||||
|
||||
expect(result).toMatchObject({ provider: 'fortnox', scanned: 0, linked: 0 })
|
||||
expect(result).toMatchObject({ provider: 'visma', scanned: 0, linked: 0 })
|
||||
expect(mockFetchUploads).not.toHaveBeenCalled()
|
||||
expect(mockFetchFortnoxFinancialYears).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('counts a Fortnox connection with an unknown financial year as unmatched', async () => {
|
||||
const supabase = wireFortnox({
|
||||
connections: [{ ...FORTNOX_CONNECTION, financialYearId: 99 }],
|
||||
})
|
||||
|
||||
const result = await importProviderDocuments({
|
||||
supabase,
|
||||
companyId: COMPANY,
|
||||
userId: USER,
|
||||
consentId: 'c1',
|
||||
})
|
||||
|
||||
expect(result).toMatchObject({ scanned: 1, linked: 0, unmatched: 1, failed: 0 })
|
||||
expect(result.unmatchedSamples[0]).toEqual({
|
||||
uploadId: 'fortnox-file-1',
|
||||
voucher: '(unresolved)',
|
||||
date: '',
|
||||
})
|
||||
expect(mockDownloadFortnoxArchiveFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('counts a Fortnox financial year outside every local period as unmatched', async () => {
|
||||
const supabase = wireFortnox({
|
||||
years: [{ id: 3, fromDate: '2020-01-01', toDate: '2020-12-31' }],
|
||||
})
|
||||
|
||||
const result = await importProviderDocuments({
|
||||
supabase,
|
||||
companyId: COMPANY,
|
||||
userId: USER,
|
||||
consentId: 'c1',
|
||||
})
|
||||
|
||||
expect(result).toMatchObject({ scanned: 1, linked: 0, unmatched: 1, failed: 0 })
|
||||
expect(result.unmatchedSamples[0]).toMatchObject({ voucher: 'A12', date: '2020-01-01' })
|
||||
expect(mockDownloadFortnoxArchiveFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('dry-runs Fortnox matches without downloading or writing', async () => {
|
||||
const supabase = wireFortnox()
|
||||
|
||||
const result = await importProviderDocuments({
|
||||
supabase,
|
||||
companyId: COMPANY,
|
||||
userId: USER,
|
||||
consentId: 'c1',
|
||||
dryRun: true,
|
||||
})
|
||||
|
||||
expect(result).toMatchObject({ dryRun: true, scanned: 1, linked: 1, failed: 0 })
|
||||
expect(mockDownloadFortnoxArchiveFile).not.toHaveBeenCalled()
|
||||
expect(mockUpload).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('matches a Fortnox voucher by its booking date across split local periods', async () => {
|
||||
const supabase = wireFortnox({
|
||||
periods: [
|
||||
{ id: 'fp-2021-h1', period_start: '2021-02-04', period_end: '2021-06-30' },
|
||||
{ id: 'fp-2021-h2', period_start: '2021-07-01', period_end: '2021-12-31' },
|
||||
],
|
||||
vouchers: [
|
||||
{
|
||||
id: 'je-h2',
|
||||
fiscal_period_id: 'fp-2021-h2',
|
||||
entry_date: '2021-09-15',
|
||||
source_voucher_series: 'A',
|
||||
source_voucher_number: 12,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const result = await importProviderDocuments({
|
||||
supabase,
|
||||
companyId: COMPANY,
|
||||
userId: USER,
|
||||
consentId: 'c1',
|
||||
dryRun: true,
|
||||
})
|
||||
|
||||
expect(result).toMatchObject({ scanned: 1, linked: 1, unmatched: 0 })
|
||||
})
|
||||
|
||||
it('keeps a dotted Bokio description as a basename and appends the effective extension', async () => {
|
||||
const supabase = wireBokio()
|
||||
mockFetchUploads.mockResolvedValue([{ ...UPLOAD, description: 'Kvitto 1.2' }] as never)
|
||||
|
||||
await importProviderDocuments({
|
||||
supabase,
|
||||
companyId: COMPANY,
|
||||
userId: USER,
|
||||
consentId: 'c1',
|
||||
})
|
||||
|
||||
const [, , , file] = mockUpload.mock.calls[0]
|
||||
expect(file).toMatchObject({ name: 'Kvitto 1.2.pdf', type: 'application/pdf' })
|
||||
})
|
||||
|
||||
it('sanitizes an extensionless Fortnox filename and appends the effective extension', async () => {
|
||||
const supabase = wireFortnox({
|
||||
connections: [{ ...FORTNOX_CONNECTION, name: '../A12' }],
|
||||
})
|
||||
|
||||
await importProviderDocuments({
|
||||
supabase,
|
||||
companyId: COMPANY,
|
||||
userId: USER,
|
||||
consentId: 'c1',
|
||||
})
|
||||
|
||||
const [, , , file] = mockUpload.mock.calls[0]
|
||||
expect(file).toMatchObject({ name: 'A12.pdf', type: 'application/pdf' })
|
||||
})
|
||||
|
||||
it('re-resolves consent once after a Fortnox 401 and retries the same attachment', async () => {
|
||||
const supabase = wireFortnox()
|
||||
mockResolveConsent
|
||||
.mockResolvedValueOnce({
|
||||
consent: { provider: 'fortnox' },
|
||||
accessToken: 'expired-token',
|
||||
} as never)
|
||||
.mockResolvedValueOnce({
|
||||
consent: { provider: 'fortnox' },
|
||||
accessToken: 'fresh-token',
|
||||
} as never)
|
||||
mockDownloadFortnoxArchiveFile
|
||||
.mockRejectedValueOnce(new FortnoxApiError('unauthorized', 401))
|
||||
.mockResolvedValueOnce({ bytes: bytesOf('FORTNOX-PDF'), contentType: 'application/pdf' })
|
||||
|
||||
const result = await importProviderDocuments({
|
||||
supabase,
|
||||
companyId: COMPANY,
|
||||
userId: USER,
|
||||
consentId: 'c1',
|
||||
})
|
||||
|
||||
expect(result).toMatchObject({ scanned: 1, linked: 1, failed: 0 })
|
||||
expect(mockResolveConsent).toHaveBeenCalledTimes(2)
|
||||
expect(mockDownloadFortnoxArchiveFile).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.anything(),
|
||||
'expired-token',
|
||||
'fortnox-file-1',
|
||||
)
|
||||
expect(mockDownloadFortnoxArchiveFile).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.anything(),
|
||||
'fresh-token',
|
||||
'fortnox-file-1',
|
||||
)
|
||||
})
|
||||
|
||||
it('counts a repeated Fortnox 401 as failed after the one refresh attempt', async () => {
|
||||
const supabase = wireFortnox()
|
||||
mockResolveConsent
|
||||
.mockResolvedValueOnce({
|
||||
consent: { provider: 'fortnox' },
|
||||
accessToken: 'expired-token',
|
||||
} as never)
|
||||
.mockResolvedValueOnce({
|
||||
consent: { provider: 'fortnox' },
|
||||
accessToken: 'still-invalid-token',
|
||||
} as never)
|
||||
mockDownloadFortnoxArchiveFile.mockRejectedValue(
|
||||
new FortnoxApiError('unauthorized', 401),
|
||||
)
|
||||
|
||||
const result = await importProviderDocuments({
|
||||
supabase,
|
||||
companyId: COMPANY,
|
||||
userId: USER,
|
||||
consentId: 'c1',
|
||||
})
|
||||
|
||||
expect(result).toMatchObject({ scanned: 1, linked: 0, failed: 1 })
|
||||
expect(mockResolveConsent).toHaveBeenCalledTimes(2)
|
||||
expect(mockDownloadFortnoxArchiveFile).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('explains that a Fortnox 403 requires reconnecting with attachment scopes', async () => {
|
||||
const supabase = wireFortnox()
|
||||
mockFetchFortnoxFinancialYears.mockRejectedValue(
|
||||
new FortnoxApiError('forbidden', 403),
|
||||
)
|
||||
|
||||
await expect(
|
||||
importProviderDocuments({
|
||||
supabase,
|
||||
companyId: COMPANY,
|
||||
userId: USER,
|
||||
consentId: 'c1',
|
||||
}),
|
||||
).rejects.toThrow('Fortnox consent lacks archive/connectfile scope: reconnect required')
|
||||
})
|
||||
|
||||
it('explains that a Fortnox archive-download 403 requires reconnecting', async () => {
|
||||
const supabase = wireFortnox()
|
||||
mockDownloadFortnoxArchiveFile.mockRejectedValue(
|
||||
new FortnoxApiError('forbidden', 403),
|
||||
)
|
||||
|
||||
await expect(
|
||||
importProviderDocuments({
|
||||
supabase,
|
||||
companyId: COMPANY,
|
||||
userId: USER,
|
||||
consentId: 'c1',
|
||||
}),
|
||||
).rejects.toThrow('Fortnox consent lacks archive/connectfile scope: reconnect required')
|
||||
})
|
||||
|
||||
it('does not WORM-link a receipt when the local source voucher identity is ambiguous', async () => {
|
||||
const supabase = wireFortnox({
|
||||
vouchers: [
|
||||
{
|
||||
id: 'je-1',
|
||||
fiscal_period_id: 'fp-2021',
|
||||
entry_date: '2021-03-01',
|
||||
source_voucher_series: 'A',
|
||||
source_voucher_number: 12,
|
||||
},
|
||||
{
|
||||
id: 'je-duplicate',
|
||||
fiscal_period_id: 'fp-2021',
|
||||
entry_date: '2021-03-01',
|
||||
source_voucher_series: 'A',
|
||||
source_voucher_number: 12,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const result = await importProviderDocuments({
|
||||
supabase,
|
||||
companyId: COMPANY,
|
||||
userId: USER,
|
||||
consentId: 'c1',
|
||||
})
|
||||
|
||||
expect(result).toMatchObject({ scanned: 1, linked: 0, unmatched: 1, failed: 0 })
|
||||
expect(result.unmatchedSamples[0]).toMatchObject({ voucher: 'A12' })
|
||||
expect(mockDownloadFortnoxArchiveFile).not.toHaveBeenCalled()
|
||||
expect(mockUpload).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('counts a receipt as unmatched when its journalEntryId is not in the Bokio voucher index', async () => {
|
||||
|
||||
@@ -251,6 +251,26 @@ describe('GET /callback: full-page fallback when there is no opener', () => {
|
||||
expect(target.searchParams.get('migration')).toBe('error')
|
||||
expect(target.searchParams.get('reason')).toContain(GENERIC_REJECTION)
|
||||
})
|
||||
|
||||
it('includes the consent id when a full-page provider error can be resumed', async () => {
|
||||
;(consumeOAuthState as Mock).mockResolvedValue({
|
||||
consentId: 'consent-1',
|
||||
provider: 'fortnox',
|
||||
})
|
||||
|
||||
const res = await callbackHandler(
|
||||
callbackRequest({
|
||||
error: 'access_denied',
|
||||
error_description: 'User denied consent',
|
||||
state: 'one-time-token',
|
||||
}),
|
||||
)
|
||||
const target = fallbackNavigation(await res.text())
|
||||
|
||||
expect(target.searchParams.get('migration')).toBe('error')
|
||||
expect(target.searchParams.get('consentId')).toBe('consent-1')
|
||||
expect(exchangeAuthToken).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
|
||||
@@ -20,7 +20,10 @@ import {
|
||||
import { providerSupportsSie, fetchProviderSieFiles, getAllowedFiscalYears } from './lib/sie-fetcher'
|
||||
import { mapCompanyInfo } from './lib/entity-mapper'
|
||||
import { executeMigration } from './lib/migration-orchestrator'
|
||||
import { importProviderDocuments } from './lib/import-documents'
|
||||
import {
|
||||
FortnoxDocumentScopesRequiredError,
|
||||
importProviderDocuments,
|
||||
} from './lib/import-documents'
|
||||
import { reconcileSupplierInvoiceVouchers } from '@/lib/invoices/bulk-reconcile-supplier-vouchers'
|
||||
import type { ArcimProvider } from './types'
|
||||
import { ARCIM_PROVIDERS } from './types'
|
||||
@@ -510,10 +513,11 @@ export const arcimMigrationExtension: Extension = {
|
||||
const jsLiteral = (value: unknown) =>
|
||||
JSON.stringify(value ?? '').replace(/</g, '\\u003c')
|
||||
|
||||
const respondWithError = (reason: string) => {
|
||||
const respondWithError = (reason: string, consentId?: string) => {
|
||||
const fallbackUrl = new URL(`${appUrl}/import`)
|
||||
fallbackUrl.searchParams.set('migration', 'error')
|
||||
fallbackUrl.searchParams.set('reason', reason)
|
||||
if (consentId) fallbackUrl.searchParams.set('consentId', consentId)
|
||||
|
||||
const escapedReason = reason
|
||||
.replace(/&/g, '&')
|
||||
@@ -550,7 +554,18 @@ export const arcimMigrationExtension: Extension = {
|
||||
hasCode: !!code,
|
||||
hasState: !!stateRaw,
|
||||
})
|
||||
return respondWithError(translateOAuthError(oauthError, oauthErrorDescription))
|
||||
let consentId: string | undefined
|
||||
if (stateRaw) {
|
||||
try {
|
||||
consentId = (await consumeOAuthState(stateRaw))?.consentId
|
||||
} catch (error) {
|
||||
log.error('OAuth callback could not resolve failed consent', error)
|
||||
}
|
||||
}
|
||||
return respondWithError(
|
||||
translateOAuthError(oauthError, oauthErrorDescription),
|
||||
consentId,
|
||||
)
|
||||
}
|
||||
|
||||
if (!code || !stateRaw) {
|
||||
@@ -562,6 +577,7 @@ export const arcimMigrationExtension: Extension = {
|
||||
return respondWithError('Återanropet saknade code eller state. Försök igen.')
|
||||
}
|
||||
|
||||
let callbackConsentId: string | undefined
|
||||
try {
|
||||
// Single source of truth for who this callback belongs to: the
|
||||
// server-written provider_otc row, consumed atomically here. The row
|
||||
@@ -584,6 +600,7 @@ export const arcimMigrationExtension: Extension = {
|
||||
}
|
||||
|
||||
const { consentId, provider } = resolvedState
|
||||
callbackConsentId = consentId
|
||||
|
||||
// Must match the redirect_uri the authorization request was built
|
||||
// with, so both come from resolveArcimCallbackUrl.
|
||||
@@ -610,7 +627,7 @@ export const arcimMigrationExtension: Extension = {
|
||||
} catch (error) {
|
||||
log.error('OAuth callback exchange failed', error)
|
||||
const reason = error instanceof Error ? error.message : 'Okänt fel vid tokenutbyte.'
|
||||
return respondWithError(reason)
|
||||
return respondWithError(reason, callbackConsentId)
|
||||
}
|
||||
},
|
||||
},
|
||||
@@ -1252,13 +1269,12 @@ export const arcimMigrationExtension: Extension = {
|
||||
},
|
||||
|
||||
// ── Import provider underlag (receipts) and link to verifikat ──
|
||||
// Best-effort, re-runnable. Kept off the migration's critical path: the
|
||||
// Bokio document API is rate-limited (200 req/60s) and a full receipt
|
||||
// sweep issues hundreds of download calls, which would blow the 300s
|
||||
// migration window. Pages /uploads, resolves each receipt's verifikat via
|
||||
// the SIE-preserved Bokio voucher number, and archives it idempotently
|
||||
// (skips content already stored for the company). Pass { dryRun: true } to
|
||||
// preview the match plan without downloading or writing.
|
||||
// Best-effort, re-runnable. Kept off the migration's critical path because
|
||||
// the Bokio and Fortnox APIs are rate-limited and a full receipt sweep can
|
||||
// issue hundreds of download calls. Resolves each receipt's verifikat via
|
||||
// the SIE-preserved provider voucher number and archives it idempotently.
|
||||
// Fortnox consents need archive and connectfile scopes. Pass
|
||||
// { dryRun: true } to preview the match plan without downloading or writing.
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/import-documents',
|
||||
@@ -1307,6 +1323,13 @@ export const arcimMigrationExtension: Extension = {
|
||||
return NextResponse.json({ success: true, dryRun, result })
|
||||
} catch (error) {
|
||||
log.error('arcim import-documents failed', error as Error)
|
||||
if (error instanceof FortnoxDocumentScopesRequiredError) {
|
||||
return errorResponseFromCode(
|
||||
'PROVIDER_DOCUMENT_SCOPES_REQUIRED',
|
||||
moduleLog,
|
||||
{ status: 403 },
|
||||
)
|
||||
}
|
||||
return errorResponseFromCode('PROVIDER_IMPORT_DOCUMENTS_FAILED', moduleLog, {
|
||||
details: { reason: error instanceof Error ? error.message : 'unknown' },
|
||||
})
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
*
|
||||
* The migration imports the GL via SIE and the entity registers via the
|
||||
* provider API, but the receipts/underlag attached to each verifikat are not
|
||||
* carried by either. This step closes that gap for Bokio: it pages the Bokio
|
||||
* `/uploads`, resolves each receipt's target gnubok verifikat from the
|
||||
* SIE-preserved Bokio voucher number, and stores it through the document
|
||||
* service (storage + document_attachments), linked to the journal entry.
|
||||
* carried by either. This step closes that gap for Bokio and Fortnox: it
|
||||
* resolves each receipt's target gnubok verifikat from the SIE-preserved
|
||||
* provider voucher number, and stores it through the document service
|
||||
* (storage + document_attachments), linked to the journal entry.
|
||||
*
|
||||
* Guarantees:
|
||||
* - Idempotent: a receipt already archived for this verifikat (same content
|
||||
@@ -21,20 +21,26 @@
|
||||
* so one bad download can't abort the sweep.
|
||||
*
|
||||
* Driven from its own /import-documents route rather than the migration's
|
||||
* critical path: the Bokio document API is rate-limited (200 req/60s) and a
|
||||
* full sweep can issue hundreds of download calls.
|
||||
* critical path: provider document APIs are rate-limited and a full sweep can
|
||||
* issue hundreds of download calls. Fortnox requires archive and connectfile
|
||||
* scopes; existing consents must reconnect before this import can run.
|
||||
*/
|
||||
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { resolveConsent } from '@/lib/providers/resolve-consent'
|
||||
import { resolveConsent, type ResolvedConsent } from '@/lib/providers/resolve-consent'
|
||||
import { BokioClient } from '@/lib/providers/bokio/client'
|
||||
import {
|
||||
fetchBokioUploads,
|
||||
fetchBokioVoucherIndex,
|
||||
downloadBokioUpload,
|
||||
type BokioUpload,
|
||||
type BokioVoucherRef,
|
||||
} from '@/lib/providers/bokio/attachments'
|
||||
import { FortnoxApiError, FortnoxClient } from '@/lib/providers/fortnox/client'
|
||||
import {
|
||||
downloadFortnoxArchiveFile,
|
||||
fetchFortnoxFileConnections,
|
||||
fetchFortnoxFinancialYears,
|
||||
} from '@/lib/providers/fortnox/attachments'
|
||||
import {
|
||||
uploadDocument,
|
||||
computeSHA256,
|
||||
@@ -46,6 +52,15 @@ import { createLogger } from '@/lib/logger'
|
||||
|
||||
const log = createLogger('extensions/arcim-migration/import-documents')
|
||||
|
||||
export class FortnoxDocumentScopesRequiredError extends Error {
|
||||
readonly code = 'PROVIDER_DOCUMENT_SCOPES_REQUIRED'
|
||||
|
||||
constructor() {
|
||||
super('Fortnox consent lacks archive/connectfile scope: reconnect required')
|
||||
this.name = 'FortnoxDocumentScopesRequiredError'
|
||||
}
|
||||
}
|
||||
|
||||
export interface ImportDocumentsOptions {
|
||||
supabase: SupabaseClient
|
||||
companyId: string
|
||||
@@ -57,13 +72,13 @@ export interface ImportDocumentsOptions {
|
||||
|
||||
export interface ImportDocumentsResult {
|
||||
provider: string
|
||||
/** Uploads carrying a journalEntryId that were considered. */
|
||||
/** Provider attachments linked to a voucher that were considered. */
|
||||
scanned: number
|
||||
/** Receipts newly archived and linked to their verifikat. */
|
||||
linked: number
|
||||
/** Receipts already archived for this verifikat (sha256 + journal entry match): re-run skip. */
|
||||
skipped: number
|
||||
/** Uploads whose Bokio voucher number resolved to no gnubok verifikat. */
|
||||
/** Attachments whose provider voucher resolved to no gnubok verifikat. */
|
||||
unmatched: number
|
||||
/** Receipts that failed to download/validate/store (counted, not thrown). */
|
||||
failed: number
|
||||
@@ -81,10 +96,24 @@ interface FiscalPeriodRow {
|
||||
interface VoucherRow {
|
||||
id: string
|
||||
fiscal_period_id: string
|
||||
entry_date: string
|
||||
source_voucher_series: string | null
|
||||
source_voucher_number: number | null
|
||||
}
|
||||
|
||||
interface ProviderAttachment {
|
||||
id: string
|
||||
fileName: string | null
|
||||
fileNameIsBaseName: boolean
|
||||
declaredContentType: string | null
|
||||
ref: { series: string; number: number; date: string; dateTo?: string } | null
|
||||
}
|
||||
|
||||
interface ProviderAttachmentSource {
|
||||
list(): Promise<ProviderAttachment[]>
|
||||
download(id: string): Promise<{ bytes: ArrayBuffer; contentType: string | null }>
|
||||
}
|
||||
|
||||
const EXTENSION_BY_TYPE: Record<string, string> = {
|
||||
'application/pdf': 'pdf',
|
||||
'image/jpeg': 'jpg',
|
||||
@@ -100,17 +129,122 @@ function periodIdForDate(periods: FiscalPeriodRow[], date: string): string | nul
|
||||
|
||||
/**
|
||||
* In-memory key for a verifikat: fiscal period + series + number. Scoping by
|
||||
* period is essential: Bokio reuses voucher numbers across fiscal years.
|
||||
* period is essential: providers may reuse voucher numbers across fiscal years.
|
||||
*/
|
||||
function voucherKey(periodId: string, series: string, number: number): string {
|
||||
return `${periodId}|${series}|${number}`
|
||||
}
|
||||
|
||||
/** Synthesise a readable filename: the Bokio uploads list carries none. */
|
||||
function fileNameFor(upload: BokioUpload, ref: BokioVoucherRef, contentType: string | null): string {
|
||||
/** Remove path/control characters while retaining a readable archive name. */
|
||||
function sanitizeProviderFileName(fileName: string): string {
|
||||
return (
|
||||
fileName
|
||||
.trim()
|
||||
.replace(/[\u0000-\u001f\u007f<>:"/\\|?*]/g, '_')
|
||||
.replace(/_+/g, '_')
|
||||
.replace(/^[._ ]+|[. _]+$/g, '')
|
||||
.slice(0, 180) || 'file'
|
||||
)
|
||||
}
|
||||
|
||||
function normalizedContentType(contentType: string | null): string | null {
|
||||
const normalized = contentType?.split(';', 1)[0]?.trim().toLowerCase()
|
||||
return normalized || null
|
||||
}
|
||||
|
||||
/** Keep a provider filename when available, otherwise synthesize one. */
|
||||
function fileNameFor(
|
||||
attachment: ProviderAttachment,
|
||||
ref: NonNullable<ProviderAttachment['ref']>,
|
||||
contentType: string | null,
|
||||
): string {
|
||||
const ext = (contentType && EXTENSION_BY_TYPE[contentType]) || 'bin'
|
||||
const label = upload.description?.trim() || `${ref.series}${ref.number}`
|
||||
return `${label}.${ext}`
|
||||
const providerName = attachment.fileName?.trim()
|
||||
if (!providerName) return `${ref.series}${ref.number}.${ext}`
|
||||
|
||||
const sanitized = sanitizeProviderFileName(providerName)
|
||||
return !attachment.fileNameIsBaseName && /\.[A-Za-z0-9]+$/.test(sanitized)
|
||||
? sanitized
|
||||
: `${sanitized}.${ext}`
|
||||
}
|
||||
|
||||
function bokioSource(
|
||||
client: BokioClient,
|
||||
resolved: ResolvedConsent,
|
||||
): ProviderAttachmentSource {
|
||||
const { accessToken, providerCompanyId } = resolved
|
||||
if (!providerCompanyId) {
|
||||
throw new Error('Consent has no provider_company_id: cannot fetch Bokio uploads')
|
||||
}
|
||||
|
||||
return {
|
||||
async list() {
|
||||
const [uploads, voucherIndex] = await Promise.all([
|
||||
fetchBokioUploads(client, accessToken, providerCompanyId),
|
||||
fetchBokioVoucherIndex(client, accessToken, providerCompanyId),
|
||||
])
|
||||
|
||||
return uploads
|
||||
.filter((upload) => upload.journalEntryId != null)
|
||||
.map((upload: BokioUpload): ProviderAttachment => ({
|
||||
id: upload.id,
|
||||
// Bokio exposes a description rather than a real filename. Treat it
|
||||
// as the preferred basename so the adapter preserves current names.
|
||||
fileName: upload.description?.trim() || null,
|
||||
fileNameIsBaseName: true,
|
||||
declaredContentType: upload.contentType,
|
||||
ref: voucherIndex.get(upload.journalEntryId as string) ?? null,
|
||||
}))
|
||||
},
|
||||
download(id) {
|
||||
return downloadBokioUpload(client, accessToken, providerCompanyId, id)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function fortnoxSource(
|
||||
client: FortnoxClient,
|
||||
accessToken: string,
|
||||
): ProviderAttachmentSource {
|
||||
return {
|
||||
async list() {
|
||||
try {
|
||||
const financialYears = await fetchFortnoxFinancialYears(client, accessToken)
|
||||
const connections = await fetchFortnoxFileConnections(
|
||||
client,
|
||||
accessToken,
|
||||
financialYears.map((year) => year.id),
|
||||
)
|
||||
const financialYearById = new Map(financialYears.map((year) => [year.id, year]))
|
||||
|
||||
return connections.map((connection): ProviderAttachment => {
|
||||
const financialYear = financialYearById.get(connection.financialYearId)
|
||||
return {
|
||||
id: connection.fileId,
|
||||
fileName: connection.name,
|
||||
fileNameIsBaseName: false,
|
||||
declaredContentType: null,
|
||||
ref: financialYear
|
||||
? {
|
||||
series: connection.series,
|
||||
number: connection.number,
|
||||
date: financialYear.fromDate,
|
||||
dateTo: financialYear.toDate,
|
||||
}
|
||||
: null,
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof FortnoxApiError && error.statusCode === 403) {
|
||||
throw new FortnoxDocumentScopesRequiredError()
|
||||
}
|
||||
throw error
|
||||
}
|
||||
},
|
||||
download(id) {
|
||||
return downloadFortnoxArchiveFile(client, accessToken, id)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export async function importProviderDocuments(
|
||||
@@ -118,7 +252,7 @@ export async function importProviderDocuments(
|
||||
): Promise<ImportDocumentsResult> {
|
||||
const { supabase, companyId, userId, consentId, dryRun = false } = opts
|
||||
|
||||
const resolved = await resolveConsent(companyId, consentId)
|
||||
let resolved = await resolveConsent(companyId, consentId)
|
||||
const provider = resolved.consent.provider as string
|
||||
|
||||
const result: ImportDocumentsResult = {
|
||||
@@ -132,24 +266,23 @@ export async function importProviderDocuments(
|
||||
unmatchedSamples: [],
|
||||
}
|
||||
|
||||
// v1 supports Bokio only. Other providers are a no-op rather than an error
|
||||
// Unsupported providers are a no-op rather than an error
|
||||
// so a mixed-provider caller can invoke this unconditionally.
|
||||
if (provider !== 'bokio') {
|
||||
log.info('document import skipped: provider not supported in v1', { provider })
|
||||
if (provider !== 'bokio' && provider !== 'fortnox') {
|
||||
log.info('document import skipped: provider not supported', { provider })
|
||||
return result
|
||||
}
|
||||
|
||||
const { accessToken, providerCompanyId } = resolved
|
||||
if (!providerCompanyId) {
|
||||
throw new Error('Consent has no provider_company_id: cannot fetch Bokio uploads')
|
||||
}
|
||||
|
||||
const client = new BokioClient()
|
||||
const bokioClient = provider === 'bokio' ? new BokioClient() : null
|
||||
const fortnoxClient = provider === 'fortnox' ? new FortnoxClient() : null
|
||||
const source = (): ProviderAttachmentSource =>
|
||||
provider === 'bokio'
|
||||
? bokioSource(bokioClient as BokioClient, resolved)
|
||||
: fortnoxSource(fortnoxClient as FortnoxClient, resolved.accessToken)
|
||||
|
||||
// ── Bulk reads (one round of paged requests each, no per-item N+1) ──
|
||||
const [uploads, voucherIndex, periods, vouchers, existingAttachments] = await Promise.all([
|
||||
fetchBokioUploads(client, accessToken, providerCompanyId),
|
||||
fetchBokioVoucherIndex(client, accessToken, providerCompanyId),
|
||||
const [attachments, periods, vouchers, existingAttachments] = await Promise.all([
|
||||
source().list(),
|
||||
// A stable `.order('id')` is required: fetchAllRows pages with `.range()`,
|
||||
// and PostgREST paging without a deterministic order can skip or repeat
|
||||
// rows once a table exceeds one page (journal_entries crosses 1000 once
|
||||
@@ -166,7 +299,7 @@ export async function importProviderDocuments(
|
||||
fetchAllRows<VoucherRow>(({ from, to }) =>
|
||||
supabase
|
||||
.from('journal_entries')
|
||||
.select('id, fiscal_period_id, source_voucher_series, source_voucher_number')
|
||||
.select('id, fiscal_period_id, entry_date, source_voucher_series, source_voucher_number')
|
||||
.eq('company_id', companyId)
|
||||
.not('source_voucher_number', 'is', null)
|
||||
.order('id', { ascending: true })
|
||||
@@ -184,12 +317,22 @@ export async function importProviderDocuments(
|
||||
|
||||
// Index gnubok verifikat by (period, series, number) for in-memory resolution.
|
||||
const journalEntryByKey = new Map<string, string>()
|
||||
const journalEntriesBySourceRef = new Map<string, VoucherRow[]>()
|
||||
const ambiguousVoucherKeys = new Set<string>()
|
||||
for (const v of vouchers) {
|
||||
if (v.source_voucher_series == null || v.source_voucher_number == null) continue
|
||||
journalEntryByKey.set(
|
||||
voucherKey(v.fiscal_period_id, v.source_voucher_series, v.source_voucher_number),
|
||||
v.id,
|
||||
)
|
||||
const sourceRef = `${v.source_voucher_series}|${v.source_voucher_number}`
|
||||
journalEntriesBySourceRef.set(sourceRef, [
|
||||
...(journalEntriesBySourceRef.get(sourceRef) ?? []),
|
||||
v,
|
||||
])
|
||||
const key = voucherKey(v.fiscal_period_id, v.source_voucher_series, v.source_voucher_number)
|
||||
if (journalEntryByKey.has(key)) {
|
||||
journalEntryByKey.delete(key)
|
||||
ambiguousVoucherKeys.add(key)
|
||||
} else if (!ambiguousVoucherKeys.has(key)) {
|
||||
journalEntryByKey.set(key, v.id)
|
||||
}
|
||||
}
|
||||
|
||||
// (content, verifikat) pairs already archived → idempotent skip set. Keyed
|
||||
@@ -202,12 +345,6 @@ export async function importProviderDocuments(
|
||||
.map((r) => attachmentKey(r.sha256_hash, r.journal_entry_id as string)),
|
||||
)
|
||||
|
||||
// Every upload that carries a journalEntryId is a receipt we're responsible
|
||||
// for. Keep them all in scope (don't pre-filter on a resolvable voucher ref)
|
||||
// so an upload whose Bokio entry number didn't parse, or resolves to no
|
||||
// verifikat, is counted as unmatched rather than silently dropped.
|
||||
const linkedUploads = uploads.filter((u) => u.journalEntryId != null)
|
||||
|
||||
const recordUnmatched = (uploadId: string, voucher: string, date: string) => {
|
||||
result.unmatched++
|
||||
if (result.unmatchedSamples.length < 20) {
|
||||
@@ -215,24 +352,31 @@ export async function importProviderDocuments(
|
||||
}
|
||||
}
|
||||
|
||||
for (const upload of linkedUploads) {
|
||||
let refreshedAfterUnauthorized = false
|
||||
|
||||
for (const attachment of attachments) {
|
||||
result.scanned++
|
||||
const ref = voucherIndex.get(upload.journalEntryId as string)
|
||||
const ref = attachment.ref
|
||||
|
||||
if (!ref) {
|
||||
// journalEntryId not in the Bokio voucher index (unparseable number, or
|
||||
// an entry the API didn't return): can't resolve a target verifikat.
|
||||
recordUnmatched(upload.id, '(unresolved)', '')
|
||||
recordUnmatched(attachment.id, '(unresolved)', '')
|
||||
continue
|
||||
}
|
||||
|
||||
const periodId = periodIdForDate(periods, ref.date)
|
||||
const journalEntryId = periodId
|
||||
? journalEntryByKey.get(voucherKey(periodId, ref.series, ref.number))
|
||||
: undefined
|
||||
let journalEntryId: string | undefined
|
||||
if (ref.dateTo) {
|
||||
const candidates = (journalEntriesBySourceRef.get(`${ref.series}|${ref.number}`) ?? [])
|
||||
.filter((voucher) => ref.date <= voucher.entry_date && voucher.entry_date <= ref.dateTo!)
|
||||
journalEntryId = candidates.length === 1 ? candidates[0].id : undefined
|
||||
} else {
|
||||
const periodId = periodIdForDate(periods, ref.date)
|
||||
journalEntryId = periodId
|
||||
? journalEntryByKey.get(voucherKey(periodId, ref.series, ref.number))
|
||||
: undefined
|
||||
}
|
||||
|
||||
if (!journalEntryId) {
|
||||
recordUnmatched(upload.id, `${ref.series}${ref.number}`, ref.date)
|
||||
recordUnmatched(attachment.id, `${ref.series}${ref.number}`, ref.date)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -243,47 +387,82 @@ export async function importProviderDocuments(
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
const { bytes } = await downloadBokioUpload(
|
||||
client,
|
||||
accessToken,
|
||||
providerCompanyId,
|
||||
upload.id,
|
||||
)
|
||||
const importAttachment = async () => {
|
||||
const { bytes, contentType } = await source().download(attachment.id)
|
||||
|
||||
const sha256 = await computeSHA256(bytes)
|
||||
if (seenAttachments.has(attachmentKey(sha256, journalEntryId))) {
|
||||
result.skipped++
|
||||
continue
|
||||
return
|
||||
}
|
||||
|
||||
// Trust the bytes over Bokio's metadata: the uploads list occasionally
|
||||
// declares the wrong contentType (a JPEG stored as image/png), which
|
||||
// Trust the bytes over provider metadata: APIs occasionally declare the
|
||||
// wrong content type (a JPEG stored as image/png), which
|
||||
// would fail magic validation. Sniff the real format first and fall
|
||||
// back to the declared type only when no signature is recognised; if
|
||||
// neither yields an allowed type, store without a declared type so
|
||||
// uploadDocument skips magic validation rather than rejecting.
|
||||
const declaredType = normalizedContentType(
|
||||
attachment.declaredContentType ?? contentType,
|
||||
)
|
||||
const sniffedType = detectFileMagic(new Uint8Array(bytes))
|
||||
const effectiveType =
|
||||
sniffedType ??
|
||||
(upload.contentType && ALLOWED_DOCUMENT_TYPES.includes(upload.contentType)
|
||||
? upload.contentType
|
||||
(declaredType && ALLOWED_DOCUMENT_TYPES.includes(declaredType)
|
||||
? declaredType
|
||||
: undefined)
|
||||
|
||||
await uploadDocument(
|
||||
supabase,
|
||||
userId,
|
||||
companyId,
|
||||
{ name: fileNameFor(upload, ref, effectiveType ?? upload.contentType), buffer: bytes, type: effectiveType },
|
||||
{ upload_source: 'api', journal_entry_id: journalEntryId },
|
||||
{
|
||||
name: fileNameFor(attachment, ref, effectiveType ?? declaredType),
|
||||
buffer: bytes,
|
||||
type: effectiveType,
|
||||
},
|
||||
{
|
||||
upload_source: 'api',
|
||||
journal_entry_id: journalEntryId,
|
||||
idempotency_key: journalEntryId,
|
||||
},
|
||||
)
|
||||
|
||||
seenAttachments.add(attachmentKey(sha256, journalEntryId))
|
||||
result.linked++
|
||||
} catch (err) {
|
||||
}
|
||||
|
||||
try {
|
||||
await importAttachment()
|
||||
} catch (error) {
|
||||
let finalError = error
|
||||
if (
|
||||
provider === 'fortnox' &&
|
||||
error instanceof FortnoxApiError &&
|
||||
error.statusCode === 401 &&
|
||||
!refreshedAfterUnauthorized
|
||||
) {
|
||||
refreshedAfterUnauthorized = true
|
||||
try {
|
||||
resolved = await resolveConsent(companyId, consentId)
|
||||
await importAttachment()
|
||||
continue
|
||||
} catch (retryError) {
|
||||
finalError = retryError
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
provider === 'fortnox' &&
|
||||
finalError instanceof FortnoxApiError &&
|
||||
finalError.statusCode === 403
|
||||
) {
|
||||
throw new FortnoxDocumentScopesRequiredError()
|
||||
}
|
||||
|
||||
result.failed++
|
||||
log.error('failed to import a receipt', err as Error, {
|
||||
uploadId: upload.id,
|
||||
log.error('failed to import a receipt', finalError as Error, {
|
||||
uploadId: attachment.id,
|
||||
voucher: `${ref.series}${ref.number}`,
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user