Feat/voucher docs (#664)
* feat: implement inbox document picker and linking functionality * feat: implement self-billing invoice functionality - Added support for registering self-billed invoices received from customers. - Updated the invoice schema to include fields for self-billing metadata such as `is_self_billed`, `external_invoice_number`, `self_billing_agreement_ref`, and `received_date`. - Created API route for handling self-billed invoice submissions, including validation and error handling. - Implemented database migrations to add necessary columns and constraints for self-billing invoices. - Developed tests to ensure correct behavior of self-billing invoice creation and validation rules. - Updated Swedish localization files to include new terms related to self-billing. * feat: enforce SIE import requirement for non-Fortnox providers in migration process * feat: streamline invoice processing and enhance error logging across APIs
This commit is contained in:
@@ -0,0 +1,102 @@
|
||||
import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest'
|
||||
import { createMockSupabase, createMockRequest, parseJsonResponse } from '@/tests/helpers'
|
||||
import type { ExtensionContext } from '@/lib/extensions/types'
|
||||
|
||||
/**
|
||||
* Guards the server-side "SIE import required first" rule on the entity-migration
|
||||
* route (POST /migrate).
|
||||
*
|
||||
* Provider API import only ever writes subledger entities (customers, suppliers,
|
||||
* invoices) — it never posts to the general ledger. The GL (kontoplan, ingående
|
||||
* balanser, verifikationer) arrives via SIE. Importing entities without the
|
||||
* SIE-derived ledger leaves an incomplete bokföring under BFL, so the route MUST
|
||||
* refuse to run for non-Fortnox providers until a completed SIE import exists.
|
||||
* Fortnox is exempt because it pulls SIE itself via API.
|
||||
*
|
||||
* Previously this was only an advisory banner + step-gating in the React wizard,
|
||||
* which a direct API call or a stale client could bypass. This test locks the
|
||||
* enforcement at the authoritative seam: the route handler.
|
||||
*/
|
||||
|
||||
vi.mock('../lib/migration-orchestrator', () => ({
|
||||
executeMigration: vi.fn().mockResolvedValue({ customers: { total: 0, imported: 0, skipped: 0 } }),
|
||||
}))
|
||||
|
||||
// index.ts imports many helpers from provider-client at module load; stub the
|
||||
// whole module and give getConsent/acceptConsent controllable behaviour.
|
||||
vi.mock('../lib/provider-client', () => ({
|
||||
createConsent: vi.fn(),
|
||||
getConsent: vi.fn(),
|
||||
listConsents: vi.fn(),
|
||||
generateOtc: vi.fn(),
|
||||
getAuthUrl: vi.fn(),
|
||||
exchangeAuthToken: vi.fn(),
|
||||
submitProviderToken: vi.fn(),
|
||||
acceptConsent: vi.fn().mockResolvedValue(undefined),
|
||||
deleteConsent: vi.fn(),
|
||||
resolveConsent: vi.fn(),
|
||||
fetchCompanyInfoDirect: vi.fn(),
|
||||
}))
|
||||
|
||||
import { arcimMigrationExtension } from '../index'
|
||||
import { executeMigration } from '../lib/migration-orchestrator'
|
||||
import { getConsent } from '../lib/provider-client'
|
||||
|
||||
const migrateRoute = (arcimMigrationExtension.apiRoutes ?? []).find(
|
||||
(r) => r.method === 'POST' && r.path === '/migrate',
|
||||
)!
|
||||
|
||||
type RouteHandler = (request: Request, ctx?: ExtensionContext) => Promise<Response>
|
||||
const handler = migrateRoute.handler as RouteHandler
|
||||
|
||||
function buildCtx(count: number | null): ExtensionContext {
|
||||
const { supabase, mockResult } = createMockSupabase()
|
||||
// The guard awaits `from('sie_imports').select(..,{count,head}).eq().eq()`.
|
||||
mockResult({ count })
|
||||
;(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 migrateRequest() {
|
||||
return createMockRequest('http://localhost/api/extensions/ext/arcim-migration/migrate', {
|
||||
method: 'POST',
|
||||
body: { consentId: 'consent-1' },
|
||||
})
|
||||
}
|
||||
|
||||
describe('POST /migrate — SIE-import-required guard', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('blocks a non-Fortnox provider when no completed SIE import exists', async () => {
|
||||
;(getConsent as Mock).mockResolvedValue({ id: 'consent-1', status: 1, provider: 'visma' })
|
||||
|
||||
const res = await handler(migrateRequest(), buildCtx(0))
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(res)
|
||||
|
||||
expect(status).toBe(409)
|
||||
expect(body.error.code).toBe('PROVIDER_SIE_IMPORT_REQUIRED')
|
||||
expect(executeMigration).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('allows a non-Fortnox provider once a completed SIE import exists', async () => {
|
||||
;(getConsent as Mock).mockResolvedValue({ id: 'consent-1', status: 1, provider: 'visma' })
|
||||
|
||||
const res = await handler(migrateRequest(), buildCtx(1))
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(executeMigration).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('exempts Fortnox — entity import runs even with no SIE import (SIE comes via API)', async () => {
|
||||
;(getConsent as Mock).mockResolvedValue({ id: 'consent-1', status: 1, provider: 'fortnox' })
|
||||
|
||||
const res = await handler(migrateRequest(), buildCtx(0))
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(executeMigration).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -1025,6 +1025,30 @@ export const arcimMigrationExtension: Extension = {
|
||||
})
|
||||
}
|
||||
|
||||
// ── Guard: a completed SIE import is required before entity import ──
|
||||
// Every provider except Fortnox exposes ONLY entity data (customers,
|
||||
// suppliers, invoices) via API — never the general ledger. Fortnox pulls
|
||||
// the GL itself via SIE-over-API. Importing entities without the
|
||||
// SIE-derived ledger (kontoplan, ingående balanser, verifikationer)
|
||||
// would leave an incomplete bokföring under BFL: a subledger with no
|
||||
// chart of accounts and no opening balances, so every subsequent posting
|
||||
// and balance is wrong. The wizard surfaces this as an advisory banner,
|
||||
// but it must be enforced here so the rule cannot be bypassed by a direct
|
||||
// API call, a skipped wizard step, or a stale client.
|
||||
if (consent.provider !== 'fortnox') {
|
||||
const { count: completedSieImports } = await supabase
|
||||
.from('sie_imports')
|
||||
.select('id', { count: 'exact', head: true })
|
||||
.eq('company_id', companyId)
|
||||
.eq('status', 'completed')
|
||||
|
||||
if (!completedSieImports || completedSieImports < 1) {
|
||||
return errorResponseFromCode('PROVIDER_SIE_IMPORT_REQUIRED', moduleLog, {
|
||||
details: { provider: consent.provider },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
log.info(`Starting migration for user ${user.id} from ${consent.provider}`)
|
||||
|
||||
const results = await executeMigration({
|
||||
|
||||
Reference in New Issue
Block a user