47f03f4238
* feat(arcim): import Bokio underlag and link to verifikat Adds an optional, re-runnable step that pages the Bokio /uploads, resolves each receipt's target verifikat via the SIE-preserved voucher number, and archives it through the document service linked to the journal entry. Closes the gap where neither the SIE GL import nor the entity import carries the receipts/underlag attached to each verifikat. - lib/providers/bokio: getBytes() binary download + an attachments resource module (uploads list, GUID->voucher index, per-upload download); pageSize capped at 100, file type taken from the upload's contentType since the download is octet-stream - importProviderDocuments: bulk in-memory resolution keyed on (fiscal period, series, number) — scoped per fiscal year because Bokio restarts numbering at V1 each year; idempotent on (company_id, sha256) so re-runs don't duplicate the undeletable BFL-linked rows - POST /import-documents route, kept off the migration critical path because the Bokio document API is rate-limited (200 req/60s) - journal_entry_id link only for v1; reuses the document-service link path (same module as #804) rather than forking it Closes #786 Signed-off-by: Jonas Hagberg <jonas@lindan.se> * fix(arcim): stable pagination order + account for unresolvable receipts Addresses two findings from a Codex review pass on the import step: - Add .order('id') to the paged journal_entries / document_attachments / fiscal_periods reads. fetchAllRows pages with .range(), and PostgREST paging without a deterministic order can skip/repeat rows once a table exceeds one page (journal_entries crosses 1000 across several migrated years), which would defeat both voucher resolution and the sha256 dedup. - Keep every upload carrying a journalEntryId in scope instead of pre-filtering on a resolvable voucher ref, so a receipt whose Bokio entry number didn't parse (or resolves to no verifikat) is counted as unmatched rather than silently dropped from the best-effort report. Tests: add unresolvable-ref and zero-uploads cases; mock now supports .order(). Signed-off-by: Jonas Hagberg <jonas@lindan.se> --------- Signed-off-by: Jonas Hagberg <jonas@lindan.se>
206 lines
8.7 KiB
TypeScript
206 lines
8.7 KiB
TypeScript
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
|
import type { SupabaseClient } from '@supabase/supabase-js'
|
|
|
|
import { resolveConsent } from '@/lib/providers/resolve-consent'
|
|
import {
|
|
fetchBokioUploads,
|
|
fetchBokioVoucherIndex,
|
|
downloadBokioUpload,
|
|
type BokioVoucherRef,
|
|
} from '@/lib/providers/bokio/attachments'
|
|
import { uploadDocument, computeSHA256 } 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/resolve-consent', () => ({ resolveConsent: vi.fn() }))
|
|
vi.mock('@/lib/providers/bokio/attachments', () => ({
|
|
fetchBokioUploads: vi.fn(),
|
|
fetchBokioVoucherIndex: vi.fn(),
|
|
downloadBokioUpload: vi.fn(),
|
|
}))
|
|
vi.mock('@/lib/core/documents/document-service', () => ({
|
|
uploadDocument: vi.fn(),
|
|
computeSHA256: vi.fn(),
|
|
ALLOWED_DOCUMENT_TYPES: ['application/pdf', 'image/jpeg', 'image/png', 'image/webp'],
|
|
}))
|
|
|
|
const mockResolveConsent = vi.mocked(resolveConsent)
|
|
const mockFetchUploads = vi.mocked(fetchBokioUploads)
|
|
const mockFetchVoucherIndex = vi.mocked(fetchBokioVoucherIndex)
|
|
const mockDownload = vi.mocked(downloadBokioUpload)
|
|
const mockUpload = vi.mocked(uploadDocument)
|
|
const mockSha256 = vi.mocked(computeSHA256)
|
|
|
|
const COMPANY = 'company-1'
|
|
const USER = 'user-1'
|
|
|
|
/** Supabase mock whose chained `.range()` resolves to the rows for that table. */
|
|
function rangeMockSupabase(byTable: Record<string, unknown[]>): SupabaseClient {
|
|
const builder = (table: string) => {
|
|
const node = {
|
|
select: () => node,
|
|
eq: () => node,
|
|
not: () => node,
|
|
order: () => node,
|
|
range: () => Promise.resolve({ data: byTable[table] ?? [], error: null }),
|
|
}
|
|
return node
|
|
}
|
|
return { from: (table: string) => builder(table) } as unknown as SupabaseClient
|
|
}
|
|
|
|
function bytesOf(text: string): ArrayBuffer {
|
|
return new TextEncoder().encode(text).buffer as ArrayBuffer
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
// Default: a Bokio consent.
|
|
mockResolveConsent.mockResolvedValue({
|
|
consent: { provider: 'bokio' },
|
|
accessToken: 'tok',
|
|
providerCompanyId: 'bokio-co',
|
|
} as never)
|
|
// Default: sha256 derived from the bytes so dedup is deterministic.
|
|
mockSha256.mockImplementation(async (buf: ArrayBuffer) => 'sha-' + Buffer.from(buf).toString('utf8'))
|
|
mockUpload.mockResolvedValue({ id: 'doc-1' } as never)
|
|
})
|
|
|
|
// A receipt linked to Bokio entry "V33", dated inside FY2021.
|
|
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 },
|
|
]
|
|
const UPLOAD = { id: 'up-1', description: 'Kvitto', contentType: 'application/pdf', journalEntryId: 'bokio-je-1' }
|
|
|
|
function wireBokio(opts: { existingHashes?: { sha256_hash: string }[] } = {}) {
|
|
mockFetchUploads.mockResolvedValue([UPLOAD] as never)
|
|
mockFetchVoucherIndex.mockResolvedValue(new Map([['bokio-je-1', VOUCHER_REF]]))
|
|
mockDownload.mockResolvedValue({ bytes: bytesOf('PDFBYTES'), contentType: 'application/octet-stream' })
|
|
return rangeMockSupabase({
|
|
fiscal_periods: PERIODS,
|
|
journal_entries: GNUBOK_VOUCHERS,
|
|
document_attachments: opts.existingHashes ?? [],
|
|
})
|
|
}
|
|
|
|
describe('importProviderDocuments', () => {
|
|
it('resolves a receipt to its verifikat and archives it linked via upload_source=api', async () => {
|
|
const supabase = wireBokio()
|
|
|
|
const result = await importProviderDocuments({ supabase, companyId: COMPANY, userId: USER, consentId: 'c1' })
|
|
|
|
expect(result).toMatchObject({ provider: 'bokio', scanned: 1, linked: 1, skipped: 0, unmatched: 0, failed: 0 })
|
|
expect(mockUpload).toHaveBeenCalledTimes(1)
|
|
const [, userId, companyId, file, metadata] = mockUpload.mock.calls[0]
|
|
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' })
|
|
})
|
|
|
|
it('skips a receipt already archived for the company (sha256 idempotency)', async () => {
|
|
const supabase = wireBokio({ existingHashes: [{ sha256_hash: 'sha-PDFBYTES' }] })
|
|
|
|
const result = await importProviderDocuments({ supabase, companyId: COMPANY, userId: USER, consentId: 'c1' })
|
|
|
|
expect(result).toMatchObject({ scanned: 1, linked: 0, skipped: 1 })
|
|
expect(mockUpload).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('counts a receipt as unmatched when no gnubok verifikat resolves', async () => {
|
|
// gnubok has the number in a DIFFERENT fiscal period — must not match.
|
|
const supabase = rangeMockSupabase({
|
|
fiscal_periods: PERIODS,
|
|
journal_entries: [
|
|
{ id: 'je-x', fiscal_period_id: 'fp-2020', source_voucher_series: 'V', source_voucher_number: 33 },
|
|
],
|
|
document_attachments: [],
|
|
})
|
|
mockFetchUploads.mockResolvedValue([UPLOAD] as never)
|
|
mockFetchVoucherIndex.mockResolvedValue(new Map([['bokio-je-1', VOUCHER_REF]]))
|
|
|
|
const result = await importProviderDocuments({ supabase, companyId: COMPANY, userId: USER, consentId: 'c1' })
|
|
|
|
expect(result).toMatchObject({ scanned: 1, linked: 0, unmatched: 1 })
|
|
expect(result.unmatchedSamples[0]).toMatchObject({ voucher: 'V33', date: '2021-03-01' })
|
|
expect(mockDownload).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('dry run resolves the plan without downloading or writing', async () => {
|
|
const supabase = wireBokio()
|
|
|
|
const result = await importProviderDocuments({ supabase, companyId: COMPANY, userId: USER, consentId: 'c1', dryRun: true })
|
|
|
|
expect(result).toMatchObject({ dryRun: true, scanned: 1, linked: 1 })
|
|
expect(mockDownload).not.toHaveBeenCalled()
|
|
expect(mockUpload).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('is a no-op for non-Bokio providers in v1', async () => {
|
|
mockResolveConsent.mockResolvedValue({
|
|
consent: { provider: 'fortnox' },
|
|
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(mockFetchUploads).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('counts a receipt as unmatched when its journalEntryId is not in the Bokio voucher index', async () => {
|
|
// e.g. an unparseable journalEntryNumber — must be reported, not dropped.
|
|
const supabase = wireBokio()
|
|
mockFetchVoucherIndex.mockResolvedValue(new Map<string, BokioVoucherRef>())
|
|
|
|
const result = await importProviderDocuments({ supabase, companyId: COMPANY, userId: USER, consentId: 'c1' })
|
|
|
|
expect(result).toMatchObject({ scanned: 1, linked: 0, unmatched: 1 })
|
|
expect(result.unmatchedSamples[0]).toMatchObject({ uploadId: 'up-1', voucher: '(unresolved)' })
|
|
expect(mockDownload).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('handles a company with no uploads as an all-zero no-op', async () => {
|
|
const supabase = wireBokio()
|
|
mockFetchUploads.mockResolvedValue([] as never)
|
|
|
|
const result = await importProviderDocuments({ supabase, companyId: COMPANY, userId: USER, consentId: 'c1' })
|
|
|
|
expect(result).toMatchObject({ scanned: 0, linked: 0, skipped: 0, unmatched: 0, failed: 0 })
|
|
expect(mockUpload).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('is best-effort: a failed receipt is counted, not thrown, and the sweep continues', async () => {
|
|
const upload2 = { id: 'up-2', description: 'Faktura', contentType: 'application/pdf', journalEntryId: 'bokio-je-2' }
|
|
const supabase = rangeMockSupabase({
|
|
fiscal_periods: PERIODS,
|
|
journal_entries: [
|
|
{ id: 'je-1', fiscal_period_id: 'fp-2021', source_voucher_series: 'V', source_voucher_number: 33 },
|
|
{ id: 'je-2', fiscal_period_id: 'fp-2021', source_voucher_series: 'V', source_voucher_number: 34 },
|
|
],
|
|
document_attachments: [],
|
|
})
|
|
mockFetchUploads.mockResolvedValue([UPLOAD, upload2] as never)
|
|
mockFetchVoucherIndex.mockResolvedValue(
|
|
new Map<string, BokioVoucherRef>([
|
|
['bokio-je-1', VOUCHER_REF],
|
|
['bokio-je-2', { series: 'V', number: 34, date: '2021-04-01' }],
|
|
]),
|
|
)
|
|
// First download fails, second succeeds.
|
|
mockDownload
|
|
.mockRejectedValueOnce(new Error('boom'))
|
|
.mockResolvedValueOnce({ bytes: bytesOf('OK'), contentType: 'application/octet-stream' })
|
|
|
|
const result = await importProviderDocuments({ supabase, companyId: COMPANY, userId: USER, consentId: 'c1' })
|
|
|
|
expect(result).toMatchObject({ scanned: 2, linked: 1, failed: 1 })
|
|
expect(mockUpload).toHaveBeenCalledTimes(1)
|
|
})
|
|
})
|