feat: arcim migration wizard UX, import fixes, Sentry setup (#22)
* feat: import system improvements, INK2 fix, and Swedish text corrections - SIE parser: Windows-1252 and CP437 encoding detection and decoding - Bank file parser: add Nordea Business (Företag) CSV format - Bank file parser: improve format detection for SEB, Länsförsäkringar, generic CSV - INK2 engine: calculate årets resultat (7222) from income statement for open fiscal years - Dashboard: parallel Supabase queries, simplified dashboard page - Fix Swedish characters (å, ä, ö) in BAS data descriptions, validation messages, AI consent disclosures - Import wizard UI improvements across all steps - Migration: add 'bas_range' match type to sie_account_mappings constraint - Extensive new tests for SIE parser encoding and bank file parser Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: arcim migration wizard UX fixes, Sentry setup, and extension scaffolding Arcim migration wizard improvements: - Progress bar now excludes non-interactive steps (migrating/result) - Fix OAuth text to match target="_blank" behavior (new tab, not redirect) - Display month names instead of "Månad X" in preview - Fix Swedish typo "förifylla" in no-company-info message - Replace native checkboxes with shadcn Switch in options step - Add ConfirmationDialog before starting migration - Show progress percentage during migration - Add "Nästa steg" guidance and navigation links in result step - Add "Försök igen" button in error state (returns to options) - Add Bokio company ID help text (GUID from URL) - Add Fortnox integration add-on hint on connection failure Also includes: SIE import system improvements, INK2 fixes, Swedish text corrections, Sentry error tracking setup, and arcim-migration extension scaffolding. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: address PR review feedback - Fix OAuth error recovery blank page (restore provider from URL params) - Pass real userId to MigrationWizard instead of empty string - Remove ~50 debug console.log statements from sie-import.ts - Fix comment referencing account 3740 → 3741 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
82ad630448
commit
2ad8731dc9
@@ -5,7 +5,7 @@ import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/com
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { ArrowLeftRight, FileText, ArrowLeft, Landmark, Loader2 } from 'lucide-react'
|
||||
import { ArrowLeftRight, ArrowRightLeft, FileText, ArrowLeft, Landmark, Loader2 } from 'lucide-react'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
import { BankSelector, type Bank } from '@/extensions/general/enable-banking/components/BankSelector'
|
||||
import { BankConnectionStatus } from '@/extensions/general/enable-banking/components/BankConnectionStatus'
|
||||
@@ -39,6 +39,12 @@ import type {
|
||||
} from '@/lib/import/types'
|
||||
import type { BASAccount } from '@/types'
|
||||
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
|
||||
import dynamic from 'next/dynamic'
|
||||
|
||||
const MigrationWizard = dynamic(
|
||||
() => import('@/components/extensions/general/ArcimMigrationWorkspace'),
|
||||
{ ssr: false, loading: () => <div className="flex items-center gap-3 text-muted-foreground p-6"><Loader2 className="h-5 w-5 animate-spin" />Laddar migreringsverktyg...</div> }
|
||||
)
|
||||
|
||||
// ============================================================
|
||||
// Bank File Import Wizard Steps
|
||||
@@ -730,16 +736,36 @@ function PSD2ConnectWizard() {
|
||||
// Import Page with Selection Cards
|
||||
// ============================================================
|
||||
|
||||
type ImportMode = null | 'psd2' | 'bank' | 'sie'
|
||||
type ImportMode = null | 'psd2' | 'bank' | 'sie' | 'migration'
|
||||
|
||||
export default function ImportPage() {
|
||||
const [mode, setMode] = useState<ImportMode>(null)
|
||||
const [userId, setUserId] = useState('')
|
||||
|
||||
// Fetch authenticated user ID for migration wizard
|
||||
useEffect(() => {
|
||||
const supabase = createClient()
|
||||
supabase.auth.getUser().then(({ data: { user } }) => {
|
||||
if (user) setUserId(user.id)
|
||||
})
|
||||
}, [])
|
||||
|
||||
// Auto-detect OAuth callback from migration extension
|
||||
useEffect(() => {
|
||||
if (new URLSearchParams(window.location.search).get('migration')) {
|
||||
setMode('migration')
|
||||
}
|
||||
}, [])
|
||||
// If extension isn't compiled in, we know synchronously it's unavailable
|
||||
const bankingCompiledIn = ENABLED_EXTENSION_IDS.has('enable-banking')
|
||||
const [hasBankingExtension, setHasBankingExtension] = useState<boolean | null>(
|
||||
bankingCompiledIn ? null : false
|
||||
)
|
||||
|
||||
// Migration extension: show card when compiled in (no DB toggle needed,
|
||||
// since the extensions marketplace is not exposed in the UI)
|
||||
const hasMigrationExtension = ENABLED_EXTENSION_IDS.has('arcim-migration')
|
||||
|
||||
useEffect(() => {
|
||||
if (!bankingCompiledIn) return
|
||||
fetch('/api/extensions/toggles/general/enable-banking')
|
||||
@@ -761,7 +787,7 @@ export default function ImportPage() {
|
||||
</div>
|
||||
|
||||
{mode === null && (
|
||||
<div className={`grid gap-4 ${hasBankingExtension !== false ? 'md:grid-cols-3' : 'md:grid-cols-2'}`}>
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{hasBankingExtension === null && (
|
||||
<Card className="animate-pulse">
|
||||
<CardContent className="pt-6 pb-6 flex flex-col items-center text-center space-y-3">
|
||||
@@ -844,6 +870,31 @@ export default function ImportPage() {
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{hasMigrationExtension === true && (
|
||||
<Card
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className="cursor-pointer hover:border-primary/50 active:scale-[0.98] transition-all"
|
||||
onClick={() => setMode('migration')}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setMode('migration') } }}
|
||||
>
|
||||
<CardContent className="pt-6 pb-6 flex flex-col items-center text-center space-y-3">
|
||||
<div className="h-12 w-12 rounded-full bg-muted flex items-center justify-center">
|
||||
<ArrowRightLeft className="h-6 w-6 text-muted-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">Migrera från annat system</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Flytta bokföring, kunder, leverantörer och fakturor från Fortnox, Visma, Bokio, Björn Lundén eller Briox.
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
SIE-data, kunder, leverantörer, fakturor
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -857,6 +908,7 @@ export default function ImportPage() {
|
||||
{mode === 'psd2' && <PSD2ConnectWizard />}
|
||||
{mode === 'bank' && <BankFileImportWizard />}
|
||||
{mode === 'sie' && <SIEImportWizard />}
|
||||
{mode === 'migration' && <MigrationWizard userId={userId} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server'
|
||||
import { redirect } from 'next/navigation'
|
||||
import DashboardNav from '@/components/dashboard/DashboardNav'
|
||||
import { RecaptIdentify } from '@/components/RecaptIdentify'
|
||||
import { SentryIdentify } from '@/components/SentryIdentify'
|
||||
import { SandboxBanner } from '@/components/dashboard/SandboxBanner'
|
||||
import type { EntityType } from '@/types'
|
||||
|
||||
@@ -60,6 +61,7 @@ export default async function DashboardLayout({
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
<SentryIdentify userId={user.id} email={user.email} />
|
||||
{!isSandbox && (
|
||||
<RecaptIdentify
|
||||
userId={user.id}
|
||||
|
||||
@@ -81,7 +81,6 @@ export default async function DashboardPage() {
|
||||
const onboardingProgress: OnboardingProgress = {
|
||||
hasCustomers: (customerCount || 0) > 0,
|
||||
hasInvoices: (invoiceCount || 0) > 0,
|
||||
hasReceipts: (receiptCount || 0) > 0,
|
||||
hasBankConnected: (transactionCount || 0) > 0,
|
||||
}
|
||||
|
||||
|
||||
@@ -40,7 +40,12 @@ export async function GET(
|
||||
|
||||
/**
|
||||
* DELETE /api/import/sie/[id]
|
||||
* Delete an import record (does not delete created journal entries)
|
||||
* Delete an import record.
|
||||
*
|
||||
* Only failed or pending imports can be deleted. Completed imports have created
|
||||
* journal entries that are part of räkenskapsinformation — deleting the metadata
|
||||
* without reversing entries would leave orphaned bookkeeping data, and deleting
|
||||
* both is prohibited under BFL 7 kap (7-year retention).
|
||||
*/
|
||||
export async function DELETE(
|
||||
request: Request,
|
||||
@@ -57,6 +62,24 @@ export async function DELETE(
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Check current status before deleting
|
||||
const { data: importRecord } = await supabase
|
||||
.from('sie_imports')
|
||||
.select('status')
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (!importRecord) {
|
||||
return NextResponse.json({ error: 'Import not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (importRecord.status === 'completed') {
|
||||
return NextResponse.json({
|
||||
error: 'Slutförd import kan inte raderas. Importerade verifikationer ingår i räkenskapsinformationen (BFL 7 kap).',
|
||||
}, { status: 403 })
|
||||
}
|
||||
|
||||
const { error } = await supabase
|
||||
.from('sie_imports')
|
||||
.delete()
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
decodeBuffer,
|
||||
calculateFileHash,
|
||||
} from '@/lib/import/sie-parser'
|
||||
import { suggestMappings, getMappingStats } from '@/lib/import/account-mapper'
|
||||
import { suggestMappings, getMappingStats, isSystemAccount } from '@/lib/import/account-mapper'
|
||||
import { generateImportPreview, checkDuplicateImport } from '@/lib/import/sie-import'
|
||||
import { BAS_REFERENCE } from '@/lib/bookkeeping/bas-data'
|
||||
import type { SIEAccountMappingRecord } from '@/lib/import/types'
|
||||
@@ -78,6 +78,15 @@ export async function POST(request: Request) {
|
||||
}, { status: 400 })
|
||||
}
|
||||
|
||||
// Separate source-system internal accounts (e.g. Fortnox 0099) from
|
||||
// real bookkeeping accounts. System accounts have no BAS equivalent and
|
||||
// should not appear in the mapping step.
|
||||
const excludedSystemAccounts = parsed.accounts
|
||||
.filter((a) => isSystemAccount(a.number))
|
||||
.map((a) => ({ number: a.number, name: a.name }))
|
||||
const bookkeepingAccounts = parsed.accounts
|
||||
.filter((a) => !isSystemAccount(a.number))
|
||||
|
||||
// Fetch stored mappings from database
|
||||
const { data: storedMappings } = await supabase
|
||||
.from('sie_account_mappings')
|
||||
@@ -88,13 +97,15 @@ export async function POST(request: Request) {
|
||||
// the user's active chart (~40 accounts). Accounts that match will be
|
||||
// auto-activated during the execute step.
|
||||
const mappings = suggestMappings(
|
||||
parsed.accounts,
|
||||
bookkeepingAccounts,
|
||||
BAS_REFERENCE,
|
||||
(storedMappings as SIEAccountMappingRecord[]) || undefined
|
||||
)
|
||||
|
||||
// Generate preview
|
||||
const preview = generateImportPreview(parsed, mappings)
|
||||
preview.excludedSystemAccounts = excludedSystemAccounts
|
||||
preview.accountCount = bookkeepingAccounts.length
|
||||
|
||||
// Calculate file hash for storage
|
||||
const fileHash = await calculateFileHash(content)
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
"use client";
|
||||
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import { useEffect } from "react";
|
||||
|
||||
export default function GlobalError({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
Sentry.captureException(error);
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<html lang="sv">
|
||||
<body>
|
||||
<div className="flex min-h-screen items-center justify-center p-8">
|
||||
<div className="text-center space-y-4">
|
||||
<h2 className="text-xl font-semibold">Något gick fel</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Ett oväntat fel inträffade. Försök igen.
|
||||
</p>
|
||||
<button
|
||||
onClick={reset}
|
||||
className="rounded-md bg-primary px-4 py-2 text-sm text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
Försök igen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
"use client";
|
||||
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
|
||||
export default function SentryExamplePage() {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center p-8">
|
||||
<div className="text-center space-y-4">
|
||||
<h1 className="text-xl font-semibold">Sentry Test</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Click the button to send a test error to Sentry.
|
||||
</p>
|
||||
<button
|
||||
className="rounded-md bg-primary px-4 py-2 text-sm text-primary-foreground hover:bg-primary/90"
|
||||
onClick={() => {
|
||||
Sentry.captureException(
|
||||
new Error("Sentry test error from gnubok")
|
||||
);
|
||||
alert("Test error sent to Sentry!");
|
||||
}}
|
||||
>
|
||||
Throw Test Error
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import { useEffect } from "react";
|
||||
|
||||
export function SentryIdentify({
|
||||
userId,
|
||||
email,
|
||||
}: {
|
||||
userId: string;
|
||||
email?: string;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
Sentry.setUser({ id: userId, email });
|
||||
return () => {
|
||||
Sentry.setUser(null);
|
||||
};
|
||||
}, [userId, email]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -252,7 +252,6 @@ export default function DashboardContent({ firstName, settings, summary, onboard
|
||||
hasCustomers={onboardingProgress.hasCustomers}
|
||||
hasInvoices={onboardingProgress.hasInvoices}
|
||||
hasBankConnected={onboardingProgress.hasBankConnected}
|
||||
hasReceipts={onboardingProgress.hasReceipts}
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -13,6 +13,7 @@ import {
|
||||
XCircle,
|
||||
ArrowRight,
|
||||
BarChart3,
|
||||
Info,
|
||||
} from 'lucide-react'
|
||||
import type { ImportPreview, ParseIssue } from '@/lib/import/types'
|
||||
|
||||
@@ -226,6 +227,16 @@ export default function SIEPreviewStep({
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Excluded system accounts info */}
|
||||
{preview.excludedSystemAccounts.length > 0 && (
|
||||
<div className="flex items-start gap-2 rounded-lg border bg-muted/50 px-4 py-3 text-sm text-muted-foreground">
|
||||
<Info className="h-4 w-4 mt-0.5 flex-shrink-0" />
|
||||
<span>
|
||||
{preview.excludedSystemAccounts.length} internt systemkonto från källsystemet exkluderades ({preview.excludedSystemAccounts.map((a) => a.number).join(', ')}) — inte bokföringskonton
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create missing accounts */}
|
||||
{missingAccounts.length > 0 && (
|
||||
<Card className="border-primary/50">
|
||||
|
||||
@@ -25,7 +25,6 @@ interface NewUserChecklistProps {
|
||||
hasCustomers: boolean
|
||||
hasInvoices: boolean
|
||||
hasBankConnected: boolean
|
||||
hasReceipts: boolean
|
||||
onDismiss?: () => void
|
||||
className?: string
|
||||
}
|
||||
@@ -36,7 +35,6 @@ export default function NewUserChecklist({
|
||||
hasCustomers,
|
||||
hasInvoices,
|
||||
hasBankConnected,
|
||||
hasReceipts,
|
||||
onDismiss,
|
||||
className,
|
||||
}: NewUserChecklistProps) {
|
||||
@@ -78,13 +76,6 @@ export default function NewUserChecklist({
|
||||
href: '/import',
|
||||
completed: hasBankConnected,
|
||||
},
|
||||
{
|
||||
id: 'receipt',
|
||||
label: 'Skanna ditt första kvitto',
|
||||
description: 'Fotografera för automatisk bokföring',
|
||||
href: '/receipts/scan',
|
||||
completed: hasReceipts,
|
||||
},
|
||||
]
|
||||
|
||||
const completedCount = items.filter((item) => item.completed).length
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"$schema":"./extensions.schema.json","extensions":["enable-banking","email"]}
|
||||
{"$schema":"./extensions.schema.json","extensions":["enable-banking","email","arcim-migration"]}
|
||||
|
||||
@@ -23,7 +23,8 @@
|
||||
"invoice-inbox",
|
||||
"calendar",
|
||||
"enable-banking",
|
||||
"email"
|
||||
"email",
|
||||
"arcim-migration"
|
||||
]
|
||||
},
|
||||
"description": "Extension IDs to enable. Each ID must match a manifest.json in the extensions/ directory."
|
||||
|
||||
@@ -0,0 +1,572 @@
|
||||
import type { Extension, ExtensionContext } from '@/lib/extensions/types'
|
||||
import { NextResponse } from 'next/server'
|
||||
import {
|
||||
createConsent,
|
||||
getConsent,
|
||||
generateOtc,
|
||||
getAuthUrl,
|
||||
exchangeAuthToken,
|
||||
submitProviderToken,
|
||||
deleteConsent,
|
||||
fetchCompanyInfo,
|
||||
fetchSIEExport,
|
||||
} from './lib/arcim-client'
|
||||
import { mapCompanyInfo } from './lib/entity-mapper'
|
||||
import { executeMigration } from './lib/migration-orchestrator'
|
||||
import type { ArcimProvider } from './types'
|
||||
import { ARCIM_PROVIDERS } from './types'
|
||||
import { parseSIEFile, validateSIEFile } from '@/lib/import/sie-parser'
|
||||
import { suggestMappings, getMappingStats, isSystemAccount } from '@/lib/import/account-mapper'
|
||||
import { loadMappings, generateImportPreview, executeSIEImport, saveMappings } from '@/lib/import/sie-import'
|
||||
import { BAS_REFERENCE } from '@/lib/bookkeeping/bas-reference'
|
||||
|
||||
/**
|
||||
* Arcim Migration extension
|
||||
*
|
||||
* Migrates bookkeeping data from external Swedish accounting systems
|
||||
* (Fortnox, Visma, Bokio, Björn Lundén, Briox) into gnubok via
|
||||
* the Arcim Sync unified API gateway.
|
||||
*
|
||||
* Bookkeeping data (accounts, balances, vouchers) is imported via SIE
|
||||
* files fetched from the gateway. Entity data (customers, suppliers,
|
||||
* invoices) is imported via the REST API.
|
||||
*
|
||||
* Required environment variables:
|
||||
* - ARCIM_SYNC_GATEWAY_URL
|
||||
* - ARCIM_SYNC_API_KEY
|
||||
*/
|
||||
export const arcimMigrationExtension: Extension = {
|
||||
id: 'arcim-migration',
|
||||
name: 'Systemmigration (Arcim Sync)',
|
||||
version: '1.0.0',
|
||||
|
||||
apiRoutes: [
|
||||
// ── List available providers ───────────────────────────────────
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/providers',
|
||||
handler: async () => {
|
||||
return NextResponse.json({ providers: ARCIM_PROVIDERS })
|
||||
},
|
||||
},
|
||||
|
||||
// ── Start consent flow (create consent + OTC) ─────────────────
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/connect',
|
||||
handler: async (request: Request, ctx?: ExtensionContext) => {
|
||||
const log = ctx?.log ?? console
|
||||
const supabase = ctx?.supabase ?? await (await import('@/lib/supabase/server')).createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { provider, companyName, orgNumber } = await request.json() as {
|
||||
provider: ArcimProvider
|
||||
companyName?: string
|
||||
orgNumber?: string
|
||||
}
|
||||
|
||||
if (!provider) {
|
||||
return NextResponse.json({ error: 'provider is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const providerInfo = ARCIM_PROVIDERS.find(p => p.id === provider)
|
||||
if (!providerInfo) {
|
||||
return NextResponse.json({ error: 'Invalid provider' }, { status: 400 })
|
||||
}
|
||||
|
||||
try {
|
||||
// Create consent in Arcim Sync
|
||||
const consent = await createConsent(
|
||||
provider,
|
||||
`gnubok-migration-${user.id}`,
|
||||
orgNumber,
|
||||
companyName
|
||||
)
|
||||
|
||||
// Store consent ID in extension settings for this user
|
||||
if (ctx?.settings) {
|
||||
await ctx.settings.set('consent_id', consent.id)
|
||||
await ctx.settings.set('provider', provider)
|
||||
}
|
||||
|
||||
if (providerInfo.authType === 'oauth') {
|
||||
// Generate OTC for OAuth flow
|
||||
const otc = await generateOtc(consent.id)
|
||||
|
||||
// Get OAuth URL from Arcim (redirect URI is configured server-side in the gateway)
|
||||
const { url } = await getAuthUrl(provider, otc.code)
|
||||
|
||||
return NextResponse.json({
|
||||
consentId: consent.id,
|
||||
authType: 'oauth',
|
||||
authUrl: url,
|
||||
otcCode: otc.code,
|
||||
})
|
||||
} else {
|
||||
// Token-based providers: consent is ready for direct use
|
||||
return NextResponse.json({
|
||||
consentId: consent.id,
|
||||
authType: 'token',
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('Failed to create consent:', error)
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Failed to connect' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
// ── Submit API token for token-based providers (Bokio, etc.) ──
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/submit-token',
|
||||
handler: async (request: Request, ctx?: ExtensionContext) => {
|
||||
const log = ctx?.log ?? console
|
||||
const supabase = ctx?.supabase ?? await (await import('@/lib/supabase/server')).createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { consentId, provider, apiToken, companyId } = await request.json() as {
|
||||
consentId: string
|
||||
provider: ArcimProvider
|
||||
apiToken: string
|
||||
companyId?: string
|
||||
}
|
||||
|
||||
if (!consentId || !provider) {
|
||||
return NextResponse.json(
|
||||
{ error: 'consentId and provider are required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// BL uses server-side client credentials — only needs companyId
|
||||
// Bokio and Briox need an API token
|
||||
if (provider !== 'bjornlunden' && !apiToken) {
|
||||
return NextResponse.json(
|
||||
{ error: 'apiToken is required for this provider' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Bokio and BL require companyId
|
||||
if ((provider === 'bokio' || provider === 'bjornlunden') && !companyId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'companyId is required for this provider' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
await submitProviderToken(consentId, provider, apiToken || 'client_credentials', companyId)
|
||||
return NextResponse.json({ success: true, consentId })
|
||||
} catch (error) {
|
||||
log.error('Submit token error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Failed to submit token' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
// ── OAuth callback ────────────────────────────────────────────
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/callback',
|
||||
handler: async (request: Request, ctx?: ExtensionContext) => {
|
||||
const log = ctx?.log ?? console
|
||||
const url = new URL(request.url)
|
||||
const code = url.searchParams.get('code')
|
||||
const state = url.searchParams.get('state') // OTC code
|
||||
|
||||
if (!code || !state) {
|
||||
return NextResponse.json({ error: 'Missing code or state' }, { status: 400 })
|
||||
}
|
||||
|
||||
try {
|
||||
// The state is the OTC code, and the code is the OAuth auth code
|
||||
// Exchange with the Arcim gateway
|
||||
const consentId = ctx?.settings
|
||||
? await ctx.settings.get<string>('consent_id')
|
||||
: null
|
||||
const provider = ctx?.settings
|
||||
? await ctx.settings.get<ArcimProvider>('provider')
|
||||
: null
|
||||
|
||||
if (!consentId || !provider) {
|
||||
return NextResponse.json({ error: 'No active migration session' }, { status: 400 })
|
||||
}
|
||||
|
||||
await exchangeAuthToken(consentId, provider, state, code)
|
||||
|
||||
// Redirect to import page with success
|
||||
const appUrl = process.env.NEXT_PUBLIC_APP_URL || ''
|
||||
return NextResponse.redirect(`${appUrl}/import?migration=connected&consentId=${consentId}`)
|
||||
} catch (error) {
|
||||
log.error('OAuth callback error:', error)
|
||||
const appUrl = process.env.NEXT_PUBLIC_APP_URL || ''
|
||||
return NextResponse.redirect(`${appUrl}/import?migration=error`)
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
// ── Preview: fetch company info + SIE stats before migration ──
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/preview',
|
||||
handler: async (request: Request, ctx?: ExtensionContext) => {
|
||||
const log = ctx?.log ?? console
|
||||
const supabase = ctx?.supabase ?? await (await import('@/lib/supabase/server')).createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const url = new URL(request.url)
|
||||
const consentId = url.searchParams.get('consentId')
|
||||
|
||||
if (!consentId) {
|
||||
return NextResponse.json({ error: 'consentId is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
try {
|
||||
// Verify consent is accepted
|
||||
const consent = await getConsent(consentId)
|
||||
if (consent.status !== 1) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Consent is not accepted. Complete OAuth first.' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Fetch company info for preview
|
||||
const companyInfo = await fetchCompanyInfo(consentId)
|
||||
const mapped = companyInfo ? mapCompanyInfo(companyInfo) : null
|
||||
|
||||
// Try to fetch SIE stats
|
||||
let sieAvailable = false
|
||||
let sieStats: { accountCount: number; transactionCount: number; fiscalYears: number[] } | null = null
|
||||
|
||||
try {
|
||||
log.info(`Fetching SIE export for consent ${consentId}...`)
|
||||
const sieResult = await fetchSIEExport(consentId, 4)
|
||||
log.info(`SIE export response: ${sieResult.files.length} files returned`)
|
||||
if (sieResult.files.length > 0) {
|
||||
sieAvailable = true
|
||||
const totalAccounts = Math.max(...sieResult.files.map(f => f.accountCount))
|
||||
const totalTransactions = sieResult.files.reduce((sum, f) => sum + f.transactionCount, 0)
|
||||
const fiscalYears = sieResult.files.map(f => f.fiscalYear).sort()
|
||||
sieStats = { accountCount: totalAccounts, transactionCount: totalTransactions, fiscalYears }
|
||||
log.info(`SIE stats: ${totalAccounts} accounts, ${totalTransactions} transactions, years: ${fiscalYears.join(', ')}`)
|
||||
} else {
|
||||
log.info('SIE export returned empty files array')
|
||||
}
|
||||
} catch (err) {
|
||||
log.info('SIE export failed:', err instanceof Error ? err.message : String(err))
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
consent: {
|
||||
id: consent.id,
|
||||
provider: consent.provider,
|
||||
status: consent.status,
|
||||
companyName: consent.companyName,
|
||||
},
|
||||
companyInfo: mapped,
|
||||
sieAvailable,
|
||||
sieStats,
|
||||
})
|
||||
} catch (error) {
|
||||
log.error('Preview error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Preview failed' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
// ── Fetch + parse SIE data for mapping step ───────────────────
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/sie-data',
|
||||
handler: async (request: Request, ctx?: ExtensionContext) => {
|
||||
const log = ctx?.log ?? console
|
||||
const supabase = ctx?.supabase ?? await (await import('@/lib/supabase/server')).createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const url = new URL(request.url)
|
||||
const consentId = url.searchParams.get('consentId')
|
||||
|
||||
if (!consentId) {
|
||||
return NextResponse.json({ error: 'consentId is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
try {
|
||||
// Fetch SIE from gateway
|
||||
const sieResult = await fetchSIEExport(consentId, 4)
|
||||
if (sieResult.files.length === 0) {
|
||||
return NextResponse.json({ error: 'No SIE data available' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Parse most recent file for preview/validation
|
||||
const sieFile = sieResult.files[sieResult.files.length - 1]
|
||||
const parsed = parseSIEFile(sieFile.rawContent)
|
||||
const validation = validateSIEFile(parsed)
|
||||
|
||||
// Collect ALL unique accounts across ALL fiscal year files
|
||||
// so mappings cover every account that will be imported
|
||||
const allAccountsMap = new Map<string, { number: string; name: string }>()
|
||||
for (const file of sieResult.files) {
|
||||
const fileParsed = parseSIEFile(file.rawContent)
|
||||
for (const acc of fileParsed.accounts) {
|
||||
if (!allAccountsMap.has(acc.number)) {
|
||||
allAccountsMap.set(acc.number, { number: acc.number, name: acc.name })
|
||||
}
|
||||
}
|
||||
}
|
||||
// Filter out source-system internal accounts (e.g. Fortnox 0099)
|
||||
// that have no BAS equivalent — same as core SIE import
|
||||
const allAccounts = [...allAccountsMap.values()]
|
||||
.filter(a => !isSystemAccount(a.number))
|
||||
.map(a => ({ number: a.number, name: a.name }))
|
||||
|
||||
// Load existing user mappings
|
||||
const existingMappings = await loadMappings(supabase, user.id)
|
||||
const existingRecords = [...existingMappings.values()].map(m => ({
|
||||
id: '',
|
||||
user_id: user.id,
|
||||
source_account: m.sourceAccount,
|
||||
source_name: m.sourceName,
|
||||
target_account: m.targetAccount,
|
||||
confidence: m.confidence,
|
||||
match_type: m.matchType,
|
||||
created_at: '',
|
||||
updated_at: '',
|
||||
}))
|
||||
|
||||
// Suggest mappings using accounts from ALL fiscal years
|
||||
const basAccounts = BAS_REFERENCE.map(b => ({
|
||||
account_number: b.account_number,
|
||||
account_name: b.account_name,
|
||||
}))
|
||||
const mappings = suggestMappings(allAccounts, basAccounts, existingRecords)
|
||||
const mappingStats = getMappingStats(mappings)
|
||||
|
||||
log.info(`Account mapping: ${allAccounts.length} unique accounts across ${sieResult.files.length} files, ${mappingStats.unmapped} unmapped`)
|
||||
|
||||
// Generate preview
|
||||
const preview = generateImportPreview(parsed, mappings)
|
||||
|
||||
// Collect all raw SIE content (all fiscal years)
|
||||
const allRawContent = sieResult.files.map(f => f.rawContent)
|
||||
|
||||
return NextResponse.json({
|
||||
parsed,
|
||||
mappings,
|
||||
mappingStats,
|
||||
preview,
|
||||
validation,
|
||||
rawContent: allRawContent,
|
||||
basAccounts: BAS_REFERENCE,
|
||||
})
|
||||
} catch (error) {
|
||||
log.error('SIE data fetch error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Failed to fetch SIE data' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
// ── Import SIE data (accounts, balances, vouchers) ────────────
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/import-sie',
|
||||
handler: async (request: Request, ctx?: ExtensionContext) => {
|
||||
const log = ctx?.log ?? console
|
||||
const supabase = ctx?.supabase ?? await (await import('@/lib/supabase/server')).createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { rawContent, mappings, options } = await request.json() as {
|
||||
rawContent: string
|
||||
mappings: import('@/lib/import/types').AccountMapping[]
|
||||
options: {
|
||||
createFiscalPeriod: boolean
|
||||
importOpeningBalances: boolean
|
||||
importTransactions: boolean
|
||||
voucherSeries?: string
|
||||
}
|
||||
}
|
||||
|
||||
if (!rawContent || !mappings) {
|
||||
return NextResponse.json({ error: 'rawContent and mappings are required' }, { status: 400 })
|
||||
}
|
||||
|
||||
try {
|
||||
// Parse the SIE content
|
||||
const parsed = parseSIEFile(rawContent)
|
||||
|
||||
// Save the user's mappings for future use
|
||||
await saveMappings(supabase, user.id, mappings)
|
||||
|
||||
// Execute the import via core engine
|
||||
const result = await executeSIEImport(supabase, user.id, parsed, mappings, {
|
||||
filename: `migration-sie-${Date.now()}.se`,
|
||||
fileContent: rawContent,
|
||||
createFiscalPeriod: options.createFiscalPeriod,
|
||||
importOpeningBalances: options.importOpeningBalances,
|
||||
importTransactions: options.importTransactions,
|
||||
voucherSeries: options.voucherSeries,
|
||||
})
|
||||
|
||||
log.info('SIE import completed:', {
|
||||
success: result.success,
|
||||
journalEntriesCreated: result.journalEntriesCreated,
|
||||
errors: result.errors.length,
|
||||
errorDetails: result.errors.slice(0, 10),
|
||||
})
|
||||
|
||||
return NextResponse.json(result)
|
||||
} catch (error) {
|
||||
log.error('SIE import failed:', error)
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'SIE import failed' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
// ── Execute entity migration (customers, suppliers, invoices) ──
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/migrate',
|
||||
handler: async (request: Request, ctx?: ExtensionContext) => {
|
||||
const log = ctx?.log ?? console
|
||||
const supabase = ctx?.supabase ?? await (await import('@/lib/supabase/server')).createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const {
|
||||
consentId,
|
||||
importCompanyInfo = true,
|
||||
importCustomers = true,
|
||||
importSuppliers = true,
|
||||
importSalesInvoices = true,
|
||||
importSupplierInvoices = true,
|
||||
} = await request.json() as {
|
||||
consentId: string
|
||||
importCompanyInfo?: boolean
|
||||
importCustomers?: boolean
|
||||
importSuppliers?: boolean
|
||||
importSalesInvoices?: boolean
|
||||
importSupplierInvoices?: boolean
|
||||
}
|
||||
|
||||
if (!consentId) {
|
||||
return NextResponse.json({ error: 'consentId is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
try {
|
||||
// Verify consent
|
||||
const consent = await getConsent(consentId)
|
||||
if (consent.status !== 1) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Consent is not accepted' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
log.info(`Starting migration for user ${user.id} from ${consent.provider}`)
|
||||
|
||||
const results = await executeMigration({
|
||||
consentId,
|
||||
userId: user.id,
|
||||
supabase,
|
||||
importCompanyInfo,
|
||||
importCustomers,
|
||||
importSuppliers,
|
||||
importSalesInvoices,
|
||||
importSupplierInvoices,
|
||||
})
|
||||
|
||||
log.info('Migration completed:', results)
|
||||
|
||||
return NextResponse.json({ success: true, results })
|
||||
} catch (error) {
|
||||
log.error('Migration failed:', error)
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Migration failed' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
// ── Disconnect / revoke consent ───────────────────────────────
|
||||
{
|
||||
method: 'DELETE',
|
||||
path: '/disconnect',
|
||||
handler: async (request: Request, ctx?: ExtensionContext) => {
|
||||
const log = ctx?.log ?? console
|
||||
const supabase = ctx?.supabase ?? await (await import('@/lib/supabase/server')).createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { consentId } = await request.json() as { consentId: string }
|
||||
|
||||
if (!consentId) {
|
||||
return NextResponse.json({ error: 'consentId is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
try {
|
||||
await deleteConsent(consentId)
|
||||
|
||||
// Clear stored consent from settings
|
||||
if (ctx?.settings) {
|
||||
await ctx.settings.set('consent_id', null)
|
||||
await ctx.settings.set('provider', null)
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
log.error('Disconnect error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Disconnect failed' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
eventHandlers: [],
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* HTTP client for the Arcim Sync gateway API.
|
||||
*
|
||||
* Targets the consent-based resource API (/api/v1/consents/...) which
|
||||
* provides typed, normalized access to any Swedish accounting provider.
|
||||
*/
|
||||
|
||||
import type {
|
||||
ArcimProvider,
|
||||
ConsentRecord,
|
||||
OtcResponse,
|
||||
PaginatedResponse,
|
||||
CompanyInformationDto,
|
||||
CustomerDto,
|
||||
SupplierDto,
|
||||
SalesInvoiceDto,
|
||||
SupplierInvoiceDto,
|
||||
} from '../types'
|
||||
|
||||
function getBaseUrl(): string {
|
||||
const url = process.env.ARCIM_SYNC_GATEWAY_URL
|
||||
if (!url) throw new Error('ARCIM_SYNC_GATEWAY_URL is not configured')
|
||||
return url.replace(/\/$/, '')
|
||||
}
|
||||
|
||||
function getApiKey(): string {
|
||||
const key = process.env.ARCIM_SYNC_API_KEY
|
||||
if (!key) throw new Error('ARCIM_SYNC_API_KEY is not configured')
|
||||
return key
|
||||
}
|
||||
|
||||
async function request<T>(
|
||||
path: string,
|
||||
options: RequestInit = {}
|
||||
): Promise<T> {
|
||||
const url = `${getBaseUrl()}${path}`
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
'Authorization': `Bearer ${getApiKey()}`,
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.text().catch(() => '')
|
||||
throw new Error(`Arcim API ${response.status}: ${body || response.statusText}`)
|
||||
}
|
||||
|
||||
return response.json()
|
||||
}
|
||||
|
||||
// ── Consent lifecycle ───────────────────────────────────────────────
|
||||
|
||||
export async function createConsent(
|
||||
provider: ArcimProvider,
|
||||
name: string,
|
||||
orgNumber?: string,
|
||||
companyName?: string
|
||||
): Promise<ConsentRecord> {
|
||||
return request<ConsentRecord>('/api/v1/consents', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name, provider, orgNumber, companyName }),
|
||||
})
|
||||
}
|
||||
|
||||
export async function getConsent(consentId: string): Promise<ConsentRecord> {
|
||||
return request<ConsentRecord>(`/api/v1/consents/${consentId}`)
|
||||
}
|
||||
|
||||
export async function generateOtc(
|
||||
consentId: string,
|
||||
expiresInMinutes: number = 60
|
||||
): Promise<OtcResponse> {
|
||||
return request<OtcResponse>(`/api/v1/consents/${consentId}/otc`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ expiresInMinutes }),
|
||||
})
|
||||
}
|
||||
|
||||
export async function deleteConsent(consentId: string): Promise<void> {
|
||||
await request(`/api/v1/consents/${consentId}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
// ── OAuth helpers ───────────────────────────────────────────────────
|
||||
|
||||
export async function getAuthUrl(
|
||||
provider: ArcimProvider,
|
||||
state?: string
|
||||
): Promise<{ url: string }> {
|
||||
const params = new URLSearchParams()
|
||||
if (state) params.set('state', state)
|
||||
const qs = params.toString()
|
||||
return request<{ url: string }>(`/api/v1/auth/${provider}/url${qs ? `?${qs}` : ''}`)
|
||||
}
|
||||
|
||||
export async function exchangeAuthToken(
|
||||
consentId: string,
|
||||
provider: ArcimProvider,
|
||||
otcCode: string,
|
||||
oauthCode: string
|
||||
): Promise<{ success: boolean; consentId: string }> {
|
||||
return request(`/api/v1/auth/${provider}/callback`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
code: oauthCode,
|
||||
consentId,
|
||||
otcCode,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
// ── Token-based auth (Bokio, Björn Lundén, Briox) ──────────────────
|
||||
|
||||
export async function submitProviderToken(
|
||||
consentId: string,
|
||||
provider: ArcimProvider,
|
||||
apiToken: string,
|
||||
companyId?: string
|
||||
): Promise<{ success: boolean; consentId: string }> {
|
||||
return request(`/api/v1/auth/${provider}/callback`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
code: apiToken,
|
||||
consentId,
|
||||
...(companyId ? { companyId } : {}),
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
// ── Resource fetching (paginated) ───────────────────────────────────
|
||||
|
||||
async function fetchAllPages<T>(
|
||||
consentId: string,
|
||||
resource: string,
|
||||
params?: Record<string, string>,
|
||||
pageSize: number = 100
|
||||
): Promise<T[]> {
|
||||
const all: T[] = []
|
||||
let page = 1
|
||||
|
||||
while (true) {
|
||||
const query = new URLSearchParams({
|
||||
page: String(page),
|
||||
pageSize: String(pageSize),
|
||||
...params,
|
||||
})
|
||||
const result = await request<PaginatedResponse<T>>(
|
||||
`/api/v1/consents/${consentId}/${resource}?${query}`
|
||||
)
|
||||
all.push(...result.data)
|
||||
|
||||
if (!result.hasMore || result.data.length === 0) break
|
||||
page++
|
||||
}
|
||||
|
||||
return all
|
||||
}
|
||||
|
||||
// ── Typed resource accessors ────────────────────────────────────────
|
||||
|
||||
export async function fetchCompanyInfo(
|
||||
consentId: string
|
||||
): Promise<CompanyInformationDto | null> {
|
||||
// CompanyInformation is a singleton resource — gateway returns { data: object }
|
||||
const result = await request<{ data: CompanyInformationDto }>(
|
||||
`/api/v1/consents/${consentId}/companyinformation`
|
||||
)
|
||||
return result.data ?? null
|
||||
}
|
||||
|
||||
export async function fetchCustomers(consentId: string): Promise<CustomerDto[]> {
|
||||
return fetchAllPages<CustomerDto>(consentId, 'customers')
|
||||
}
|
||||
|
||||
export async function fetchSuppliers(consentId: string): Promise<SupplierDto[]> {
|
||||
return fetchAllPages<SupplierDto>(consentId, 'suppliers')
|
||||
}
|
||||
|
||||
export async function fetchSalesInvoices(
|
||||
consentId: string,
|
||||
params?: Record<string, string>
|
||||
): Promise<SalesInvoiceDto[]> {
|
||||
return fetchAllPages<SalesInvoiceDto>(consentId, 'salesinvoices', params)
|
||||
}
|
||||
|
||||
export async function fetchSupplierInvoices(
|
||||
consentId: string,
|
||||
params?: Record<string, string>
|
||||
): Promise<SupplierInvoiceDto[]> {
|
||||
return fetchAllPages<SupplierInvoiceDto>(consentId, 'supplierinvoices', params)
|
||||
}
|
||||
|
||||
// ── SIE export ────────────────────────────────────────────────────
|
||||
|
||||
export interface SIEExportFile {
|
||||
fiscalYear: number
|
||||
sieType: number
|
||||
rawContent: string
|
||||
accountCount: number
|
||||
transactionCount: number
|
||||
}
|
||||
|
||||
export async function fetchSIEExport(
|
||||
consentId: string,
|
||||
sieType?: number
|
||||
): Promise<{ files: SIEExportFile[] }> {
|
||||
const params = new URLSearchParams()
|
||||
if (sieType) params.set('sieType', String(sieType))
|
||||
const qs = params.toString()
|
||||
return request<{ files: SIEExportFile[] }>(
|
||||
`/api/v1/consents/${consentId}/sie/export${qs ? `?${qs}` : ''}`
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
/**
|
||||
* Maps Arcim Sync canonical DTOs to gnubok internal types.
|
||||
*
|
||||
* These mappers transform the normalized data from any Swedish accounting
|
||||
* provider into the exact shapes gnubok expects for database insertion.
|
||||
*/
|
||||
|
||||
import type { CustomerType, SupplierType, VatTreatment } from '@/types'
|
||||
import type {
|
||||
CustomerDto,
|
||||
SupplierDto,
|
||||
SalesInvoiceDto,
|
||||
SalesInvoiceLineDto,
|
||||
SupplierInvoiceDto,
|
||||
SupplierInvoiceLineDto,
|
||||
CompanyInformationDto,
|
||||
PostalAddress,
|
||||
PartyDto,
|
||||
} from '../types'
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
function round2(n: number): number {
|
||||
return Math.round(n * 100) / 100
|
||||
}
|
||||
|
||||
function formatAddress(addr?: PostalAddress): {
|
||||
address_line1: string | null
|
||||
address_line2: string | null
|
||||
postal_code: string | null
|
||||
city: string | null
|
||||
country: string | null
|
||||
} {
|
||||
if (!addr) {
|
||||
return { address_line1: null, address_line2: null, postal_code: null, city: null, country: null }
|
||||
}
|
||||
const line1 = [addr.streetName, addr.buildingNumber].filter(Boolean).join(' ') || null
|
||||
return {
|
||||
address_line1: line1,
|
||||
address_line2: addr.additionalStreetName || null,
|
||||
postal_code: addr.postalZone || null,
|
||||
city: addr.cityName || null,
|
||||
country: addr.countryCode || null,
|
||||
}
|
||||
}
|
||||
|
||||
function getOrgNumber(party: PartyDto): string | null {
|
||||
// Look for SE:ORGNR scheme first, then companyId in legalEntity
|
||||
const seOrg = party.identifications?.find(i => i.schemeId === 'SE:ORGNR')
|
||||
if (seOrg) return seOrg.id
|
||||
return party.legalEntity?.companyId || null
|
||||
}
|
||||
|
||||
const EU_COUNTRIES = ['AT', 'BE', 'BG', 'CY', 'CZ', 'DE', 'DK', 'EE', 'EL', 'ES', 'FI', 'FR', 'HR', 'HU', 'IE', 'IT', 'LT', 'LU', 'LV', 'MT', 'NL', 'PL', 'PT', 'RO', 'SI', 'SK']
|
||||
|
||||
function inferTypeFromVatOrCountry(
|
||||
vatNumber: string | undefined,
|
||||
countryCode: string | undefined
|
||||
): 'swedish_business' | 'eu_business' | 'non_eu_business' {
|
||||
// 1. VAT number prefix is the strongest signal
|
||||
if (vatNumber) {
|
||||
const prefix = vatNumber.substring(0, 2).toUpperCase()
|
||||
if (prefix === 'SE') return 'swedish_business'
|
||||
if (EU_COUNTRIES.includes(prefix)) return 'eu_business'
|
||||
return 'non_eu_business'
|
||||
}
|
||||
|
||||
// 2. Fall back to address country
|
||||
const country = countryCode?.toUpperCase()
|
||||
if (!country || country === 'SE') return 'swedish_business'
|
||||
if (EU_COUNTRIES.includes(country)) return 'eu_business'
|
||||
return 'non_eu_business'
|
||||
}
|
||||
|
||||
function inferCustomerType(dto: CustomerDto): CustomerType {
|
||||
if (dto.type === 'private') return 'individual'
|
||||
return inferTypeFromVatOrCountry(dto.vatNumber, dto.party.postalAddress?.countryCode)
|
||||
}
|
||||
|
||||
function inferSupplierType(dto: SupplierDto): SupplierType {
|
||||
return inferTypeFromVatOrCountry(dto.vatNumber, dto.party.postalAddress?.countryCode)
|
||||
}
|
||||
|
||||
function inferVatTreatment(taxPercent?: number, currencyCode?: string): VatTreatment {
|
||||
if (taxPercent === 25) return 'standard_25'
|
||||
if (taxPercent === 12) return 'reduced_12'
|
||||
if (taxPercent === 6) return 'reduced_6'
|
||||
if (taxPercent === 0 && currencyCode && currencyCode !== 'SEK') return 'export'
|
||||
return 'standard_25'
|
||||
}
|
||||
|
||||
function inferVatRate(taxPercent?: number): number {
|
||||
if (taxPercent === 25 || taxPercent === 12 || taxPercent === 6) return taxPercent
|
||||
if (taxPercent === 0) return 0
|
||||
return 25 // Default to standard rate
|
||||
}
|
||||
|
||||
// ── Public mappers ──────────────────────────────────────────────────
|
||||
|
||||
export function mapCustomer(dto: CustomerDto, userId: string): Record<string, unknown> {
|
||||
const addr = formatAddress(dto.party.postalAddress)
|
||||
return {
|
||||
user_id: userId,
|
||||
name: dto.party.name,
|
||||
customer_type: inferCustomerType(dto),
|
||||
email: dto.party.contact?.email || null,
|
||||
phone: dto.party.contact?.telephone || null,
|
||||
...addr,
|
||||
org_number: getOrgNumber(dto.party),
|
||||
vat_number: dto.vatNumber || null,
|
||||
vat_number_validated: false,
|
||||
default_payment_terms: dto.defaultPaymentTermsDays || 30,
|
||||
notes: dto.note || null,
|
||||
}
|
||||
}
|
||||
|
||||
export function mapSupplier(dto: SupplierDto, userId: string): Record<string, unknown> {
|
||||
const addr = formatAddress(dto.party.postalAddress)
|
||||
return {
|
||||
user_id: userId,
|
||||
name: dto.party.name,
|
||||
supplier_type: inferSupplierType(dto),
|
||||
email: dto.party.contact?.email || null,
|
||||
phone: dto.party.contact?.telephone || null,
|
||||
...addr,
|
||||
org_number: getOrgNumber(dto.party),
|
||||
vat_number: dto.vatNumber || null,
|
||||
bankgiro: dto.bankGiro || null,
|
||||
plusgiro: dto.plusGiro || null,
|
||||
bank_account: dto.bankAccount || null,
|
||||
iban: null,
|
||||
bic: null,
|
||||
default_expense_account: null,
|
||||
default_payment_terms: dto.defaultPaymentTermsDays || 30,
|
||||
default_currency: 'SEK',
|
||||
notes: dto.note || null,
|
||||
}
|
||||
}
|
||||
|
||||
export function mapSalesInvoice(
|
||||
dto: SalesInvoiceDto,
|
||||
userId: string,
|
||||
customerId: string
|
||||
): { invoice: Record<string, unknown>; items: Record<string, unknown>[] } {
|
||||
const subtotal = round2(dto.legalMonetaryTotal.lineExtensionAmount.value)
|
||||
const total = round2(dto.legalMonetaryTotal.payableAmount.value)
|
||||
const vatAmount = round2(dto.taxTotal?.taxAmount.value ?? (total - subtotal))
|
||||
|
||||
// Determine primary VAT treatment from first line with tax
|
||||
const primaryTaxPercent = dto.lines.find(l => l.taxPercent != null)?.taxPercent
|
||||
const vatTreatment = inferVatTreatment(primaryTaxPercent, dto.currencyCode)
|
||||
|
||||
// Map Arcim status to gnubok status
|
||||
const statusMap: Record<string, string> = {
|
||||
draft: 'draft',
|
||||
sent: 'sent',
|
||||
booked: 'sent', // gnubok has no 'booked' status — treat as sent
|
||||
paid: 'paid',
|
||||
overdue: 'overdue',
|
||||
cancelled: 'cancelled',
|
||||
credited: 'credited',
|
||||
}
|
||||
|
||||
const isCreditNote = dto.invoiceTypeCode === '381'
|
||||
|
||||
const invoice: Record<string, unknown> = {
|
||||
user_id: userId,
|
||||
customer_id: customerId,
|
||||
invoice_number: dto.invoiceNumber,
|
||||
invoice_date: dto.issueDate,
|
||||
due_date: dto.dueDate || dto.issueDate,
|
||||
status: statusMap[dto.status] || 'sent',
|
||||
currency: dto.currencyCode || 'SEK',
|
||||
exchange_rate: dto.currencyCode === 'SEK' ? null : null,
|
||||
subtotal,
|
||||
subtotal_sek: dto.currencyCode === 'SEK' ? subtotal : null,
|
||||
vat_amount: vatAmount,
|
||||
vat_amount_sek: dto.currencyCode === 'SEK' ? vatAmount : null,
|
||||
total,
|
||||
total_sek: dto.currencyCode === 'SEK' ? total : null,
|
||||
vat_treatment: vatTreatment,
|
||||
vat_rate: inferVatRate(primaryTaxPercent),
|
||||
your_reference: null,
|
||||
our_reference: null,
|
||||
notes: dto.note || null,
|
||||
document_type: isCreditNote ? 'invoice' : 'invoice',
|
||||
paid_at: dto.paymentStatus.paid ? dto.paymentStatus.lastPaymentDate || dto.issueDate : null,
|
||||
paid_amount: dto.paymentStatus.paid ? total : round2(total - dto.paymentStatus.balance.value),
|
||||
}
|
||||
|
||||
const items = dto.lines.map((line, idx) => mapSalesInvoiceLine(line, idx))
|
||||
|
||||
return { invoice, items }
|
||||
}
|
||||
|
||||
function mapSalesInvoiceLine(line: SalesInvoiceLineDto, index: number): Record<string, unknown> {
|
||||
return {
|
||||
sort_order: index + 1,
|
||||
description: line.description || line.itemName || '',
|
||||
quantity: line.quantity || 1,
|
||||
unit: line.unitCode || 'st',
|
||||
unit_price: round2(line.unitPrice?.value ?? line.lineExtensionAmount.value),
|
||||
line_total: round2(line.lineExtensionAmount.value),
|
||||
vat_rate: inferVatRate(line.taxPercent),
|
||||
vat_amount: round2(line.taxAmount?.value ?? 0),
|
||||
}
|
||||
}
|
||||
|
||||
export function mapSupplierInvoice(
|
||||
dto: SupplierInvoiceDto,
|
||||
userId: string,
|
||||
supplierId: string
|
||||
): { invoice: Record<string, unknown>; items: Record<string, unknown>[] } {
|
||||
const subtotal = round2(dto.legalMonetaryTotal.lineExtensionAmount.value)
|
||||
const total = round2(dto.legalMonetaryTotal.payableAmount.value)
|
||||
const vatAmount = round2(dto.taxTotal?.taxAmount.value ?? (total - subtotal))
|
||||
|
||||
const primaryTaxPercent = dto.lines.find(l => l.taxPercent != null)?.taxPercent
|
||||
const vatTreatment = inferVatTreatment(primaryTaxPercent, dto.currencyCode)
|
||||
|
||||
const statusMap: Record<string, string> = {
|
||||
draft: 'registered',
|
||||
sent: 'registered',
|
||||
booked: 'registered',
|
||||
paid: 'paid',
|
||||
overdue: 'overdue',
|
||||
cancelled: 'credited',
|
||||
credited: 'credited',
|
||||
}
|
||||
|
||||
const isCreditNote = dto.invoiceTypeCode === '381'
|
||||
|
||||
const invoice: Record<string, unknown> = {
|
||||
user_id: userId,
|
||||
supplier_id: supplierId,
|
||||
supplier_invoice_number: dto.invoiceNumber,
|
||||
invoice_date: dto.issueDate,
|
||||
due_date: dto.dueDate || dto.issueDate,
|
||||
received_date: dto.issueDate,
|
||||
delivery_date: dto.deliveryDate || null,
|
||||
status: statusMap[dto.status] || 'registered',
|
||||
currency: dto.currencyCode || 'SEK',
|
||||
exchange_rate: dto.currencyCode === 'SEK' ? null : null,
|
||||
subtotal,
|
||||
subtotal_sek: dto.currencyCode === 'SEK' ? subtotal : null,
|
||||
vat_amount: vatAmount,
|
||||
vat_amount_sek: dto.currencyCode === 'SEK' ? vatAmount : null,
|
||||
total,
|
||||
total_sek: dto.currencyCode === 'SEK' ? total : null,
|
||||
vat_treatment: vatTreatment,
|
||||
reverse_charge: vatTreatment === 'reverse_charge',
|
||||
payment_reference: dto.ocrNumber || null,
|
||||
paid_at: dto.paymentStatus.paid ? dto.paymentStatus.lastPaymentDate || dto.issueDate : null,
|
||||
paid_amount: dto.paymentStatus.paid ? total : round2(total - dto.paymentStatus.balance.value),
|
||||
remaining_amount: round2(dto.paymentStatus.balance.value),
|
||||
is_credit_note: isCreditNote,
|
||||
notes: dto.note || null,
|
||||
}
|
||||
|
||||
const items = dto.lines.map((line, idx) => mapSupplierInvoiceLine(line, idx))
|
||||
|
||||
return { invoice, items }
|
||||
}
|
||||
|
||||
function mapSupplierInvoiceLine(line: SupplierInvoiceLineDto, index: number): Record<string, unknown> {
|
||||
return {
|
||||
sort_order: index + 1,
|
||||
description: line.description || line.itemName || '',
|
||||
quantity: line.quantity || 1,
|
||||
unit: line.unitCode || 'st',
|
||||
unit_price: round2(line.unitPrice?.value ?? line.lineExtensionAmount.value),
|
||||
line_total: round2(line.lineExtensionAmount.value),
|
||||
account_number: line.accountNumber || '4000', // Default to purchases
|
||||
vat_rate: inferVatRate(line.taxPercent),
|
||||
vat_amount: round2(line.taxAmount?.value ?? 0),
|
||||
}
|
||||
}
|
||||
|
||||
export function mapCompanyInfo(dto: CompanyInformationDto): {
|
||||
company_name: string | null
|
||||
org_number: string | null
|
||||
vat_number: string | null
|
||||
fiscal_year_start_month: number
|
||||
address_line1: string | null
|
||||
postal_code: string | null
|
||||
city: string | null
|
||||
phone: string | null
|
||||
email: string | null
|
||||
} {
|
||||
const addr = formatAddress(dto.address)
|
||||
// Parse fiscal year start month from "MM-DD" format
|
||||
let fiscalYearStartMonth = 1
|
||||
if (dto.fiscalYearStart) {
|
||||
const month = parseInt(dto.fiscalYearStart.split('-')[0], 10)
|
||||
if (month >= 1 && month <= 12) fiscalYearStartMonth = month
|
||||
}
|
||||
|
||||
return {
|
||||
company_name: dto.companyName || null,
|
||||
org_number: dto.organizationNumber || null,
|
||||
vat_number: dto.vatNumber || null,
|
||||
fiscal_year_start_month: fiscalYearStartMonth,
|
||||
address_line1: addr.address_line1,
|
||||
postal_code: addr.postal_code,
|
||||
city: addr.city,
|
||||
phone: dto.contact?.telephone || null,
|
||||
email: dto.contact?.email || null,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,449 @@
|
||||
/**
|
||||
* Migration orchestrator — coordinates the data migration from
|
||||
* an external accounting system via Arcim Sync into gnubok.
|
||||
*
|
||||
* Bookkeeping data (accounts, balances, vouchers) is now imported
|
||||
* via SIE files through the core SIE import engine. This orchestrator
|
||||
* handles only entity-level imports:
|
||||
* 1. Company info → pre-fill company_settings
|
||||
* 2. Customers → needed before sales invoices
|
||||
* 3. Suppliers → needed before supplier invoices
|
||||
* 4. Sales invoices (open only)
|
||||
* 5. Supplier invoices (open only)
|
||||
*/
|
||||
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import type { MigrationProgress, MigrationResults } from '../types'
|
||||
import {
|
||||
fetchCompanyInfo,
|
||||
fetchCustomers,
|
||||
fetchSuppliers,
|
||||
fetchSalesInvoices,
|
||||
fetchSupplierInvoices,
|
||||
} from './arcim-client'
|
||||
import {
|
||||
mapCustomer,
|
||||
mapSupplier,
|
||||
mapSalesInvoice,
|
||||
mapSupplierInvoice,
|
||||
mapCompanyInfo,
|
||||
} from './entity-mapper'
|
||||
|
||||
export interface MigrationOptions {
|
||||
consentId: string
|
||||
userId: string
|
||||
supabase: SupabaseClient
|
||||
importCompanyInfo?: boolean
|
||||
importCustomers?: boolean
|
||||
importSuppliers?: boolean
|
||||
importSalesInvoices?: boolean
|
||||
importSupplierInvoices?: boolean
|
||||
onProgress?: (progress: MigrationProgress) => void
|
||||
}
|
||||
|
||||
function emitProgress(options: MigrationOptions, progress: MigrationProgress) {
|
||||
options.onProgress?.(progress)
|
||||
}
|
||||
|
||||
// ── Main orchestrator ─────────────────────────────────────────────
|
||||
|
||||
export async function executeMigration(options: MigrationOptions): Promise<MigrationResults> {
|
||||
const { consentId, userId, supabase } = options
|
||||
const results: MigrationResults = {}
|
||||
|
||||
try {
|
||||
// ── Step 1: Company information ───────────────────────────────
|
||||
if (options.importCompanyInfo !== false) {
|
||||
emitProgress(options, { status: 'fetching', currentStep: 'Hämtar företagsinformation...', progress: 5 })
|
||||
try {
|
||||
const companyInfo = await fetchCompanyInfo(consentId)
|
||||
if (companyInfo) {
|
||||
const mapped = mapCompanyInfo(companyInfo)
|
||||
const { data: existing } = await supabase
|
||||
.from('company_settings')
|
||||
.select('company_name, org_number, vat_number')
|
||||
.eq('user_id', userId)
|
||||
.single()
|
||||
|
||||
const updates: Record<string, unknown> = {}
|
||||
if (!existing?.company_name && mapped.company_name) updates.company_name = mapped.company_name
|
||||
if (!existing?.org_number && mapped.org_number) updates.org_number = mapped.org_number
|
||||
if (!existing?.vat_number && mapped.vat_number) {
|
||||
updates.vat_number = mapped.vat_number
|
||||
updates.vat_registered = true
|
||||
}
|
||||
if (mapped.fiscal_year_start_month !== 1) {
|
||||
updates.fiscal_year_start_month = mapped.fiscal_year_start_month
|
||||
}
|
||||
if (mapped.address_line1) updates.address_line1 = mapped.address_line1
|
||||
if (mapped.postal_code) updates.postal_code = mapped.postal_code
|
||||
if (mapped.city) updates.city = mapped.city
|
||||
if (mapped.phone) updates.phone = mapped.phone
|
||||
if (mapped.email) updates.email = mapped.email
|
||||
|
||||
if (Object.keys(updates).length > 0) {
|
||||
await supabase.from('company_settings').update(updates).eq('user_id', userId)
|
||||
}
|
||||
results.companyInfo = { imported: true }
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to import company info:', err)
|
||||
results.companyInfo = { imported: false }
|
||||
}
|
||||
}
|
||||
|
||||
// ── Step 2: Customers ─────────────────────────────────────────
|
||||
const customerIdMap = new Map<string, string>()
|
||||
|
||||
if (options.importCustomers !== false) {
|
||||
emitProgress(options, { status: 'importing', currentStep: 'Importerar kunder...', progress: 20 })
|
||||
try {
|
||||
const customers = await fetchCustomers(consentId)
|
||||
let imported = 0
|
||||
let skipped = 0
|
||||
|
||||
for (const customer of customers) {
|
||||
if (!customer.active) {
|
||||
console.log(`[migration] Customer skipped (inactive): ${customer.party.name}`)
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
const orgNumber = customer.party.legalEntity?.companyId ||
|
||||
customer.party.identifications?.find(i => i.schemeId === 'SE:ORGNR')?.id
|
||||
if (orgNumber) {
|
||||
const { data: existing } = await supabase
|
||||
.from('customers')
|
||||
.select('id')
|
||||
.eq('user_id', userId)
|
||||
.eq('org_number', orgNumber)
|
||||
.limit(1)
|
||||
|
||||
if (existing && existing.length > 0) {
|
||||
console.log(`[migration] Customer skipped (duplicate org_number ${orgNumber}): ${customer.party.name}`)
|
||||
customerIdMap.set(customer.id, existing[0].id)
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
const mapped = mapCustomer(customer, userId)
|
||||
const { data: inserted, error } = await supabase
|
||||
.from('customers')
|
||||
.insert(mapped)
|
||||
.select('id')
|
||||
.single()
|
||||
|
||||
if (error || !inserted) {
|
||||
console.error(`[migration] Customer insert failed: ${customer.party.name}`, error?.message)
|
||||
skipped++
|
||||
} else {
|
||||
customerIdMap.set(customer.id, inserted.id)
|
||||
imported++
|
||||
}
|
||||
}
|
||||
|
||||
results.customers = { total: customers.length, imported, skipped }
|
||||
} catch (err) {
|
||||
console.error('Failed to import customers:', err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Step 3: Suppliers ─────────────────────────────────────────
|
||||
const supplierIdMap = new Map<string, string>()
|
||||
|
||||
if (options.importSuppliers !== false) {
|
||||
emitProgress(options, { status: 'importing', currentStep: 'Importerar leverantörer...', progress: 40 })
|
||||
try {
|
||||
const suppliers = await fetchSuppliers(consentId)
|
||||
let imported = 0
|
||||
let skipped = 0
|
||||
|
||||
for (const supplier of suppliers) {
|
||||
if (!supplier.active) {
|
||||
console.log(`[migration] Supplier skipped (inactive): ${supplier.party.name}`)
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
const orgNumber = supplier.party.legalEntity?.companyId ||
|
||||
supplier.party.identifications?.find(i => i.schemeId === 'SE:ORGNR')?.id
|
||||
if (orgNumber) {
|
||||
const { data: existing } = await supabase
|
||||
.from('suppliers')
|
||||
.select('id')
|
||||
.eq('user_id', userId)
|
||||
.eq('org_number', orgNumber)
|
||||
.limit(1)
|
||||
|
||||
if (existing && existing.length > 0) {
|
||||
console.log(`[migration] Supplier skipped (duplicate org_number ${orgNumber}): ${supplier.party.name}`)
|
||||
supplierIdMap.set(supplier.id, existing[0].id)
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
const mapped = mapSupplier(supplier, userId)
|
||||
const { data: inserted, error } = await supabase
|
||||
.from('suppliers')
|
||||
.insert(mapped)
|
||||
.select('id')
|
||||
.single()
|
||||
|
||||
if (error || !inserted) {
|
||||
console.error(`[migration] Supplier insert failed: ${supplier.party.name}`, error?.message)
|
||||
skipped++
|
||||
} else {
|
||||
supplierIdMap.set(supplier.id, inserted.id)
|
||||
imported++
|
||||
}
|
||||
}
|
||||
|
||||
results.suppliers = { total: suppliers.length, imported, skipped }
|
||||
} catch (err) {
|
||||
console.error('Failed to import suppliers:', err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Step 4: Sales invoices (open/unpaid only) ─────────────────
|
||||
if (options.importSalesInvoices !== false) {
|
||||
emitProgress(options, { status: 'importing', currentStep: 'Importerar kundfakturor...', progress: 60 })
|
||||
try {
|
||||
const invoices = await fetchSalesInvoices(consentId)
|
||||
const openInvoices = invoices.filter(i =>
|
||||
i.status === 'sent' || i.status === 'overdue' || i.status === 'booked'
|
||||
)
|
||||
console.log(`[migration] Sales invoices: ${invoices.length} total, ${openInvoices.length} open (filtered by status: sent/overdue/booked)`)
|
||||
|
||||
let imported = 0
|
||||
let skipped = 0
|
||||
|
||||
for (const inv of openInvoices) {
|
||||
const customerOrgNumber = inv.customer.legalEntity?.companyId ||
|
||||
inv.customer.identifications?.find(i => i.schemeId === 'SE:ORGNR')?.id
|
||||
|
||||
let customerId: string | null = null
|
||||
|
||||
if (customerOrgNumber) {
|
||||
const { data: match } = await supabase
|
||||
.from('customers')
|
||||
.select('id')
|
||||
.eq('user_id', userId)
|
||||
.eq('org_number', customerOrgNumber)
|
||||
.limit(1)
|
||||
if (match?.[0]) customerId = match[0].id
|
||||
}
|
||||
|
||||
if (!customerId) {
|
||||
const { data: match } = await supabase
|
||||
.from('customers')
|
||||
.select('id')
|
||||
.eq('user_id', userId)
|
||||
.eq('name', inv.customer.name)
|
||||
.limit(1)
|
||||
if (match?.[0]) customerId = match[0].id
|
||||
}
|
||||
|
||||
if (!customerId) {
|
||||
const minimalCustomer = {
|
||||
user_id: userId,
|
||||
name: inv.customer.name,
|
||||
customer_type: 'swedish_business',
|
||||
default_payment_terms: 30,
|
||||
country: 'SE',
|
||||
vat_number_validated: false,
|
||||
}
|
||||
const { data: created, error: custErr } = await supabase
|
||||
.from('customers')
|
||||
.insert(minimalCustomer)
|
||||
.select('id')
|
||||
.single()
|
||||
if (created) {
|
||||
customerId = created.id
|
||||
} else {
|
||||
console.error(`[migration] Sales invoice ${inv.invoiceNumber} skipped — could not create customer "${inv.customer.name}":`, custErr?.message)
|
||||
}
|
||||
}
|
||||
|
||||
if (!customerId) {
|
||||
console.log(`[migration] Sales invoice ${inv.invoiceNumber} skipped — no customer match for "${inv.customer.name}" (org: ${customerOrgNumber || 'n/a'})`)
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
const { data: existingInv } = await supabase
|
||||
.from('invoices')
|
||||
.select('id')
|
||||
.eq('user_id', userId)
|
||||
.eq('invoice_number', inv.invoiceNumber)
|
||||
.limit(1)
|
||||
|
||||
if (existingInv && existingInv.length > 0) {
|
||||
console.log(`[migration] Sales invoice ${inv.invoiceNumber} skipped — already exists`)
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
const { invoice: mappedInvoice, items: mappedItems } = mapSalesInvoice(inv, userId, customerId)
|
||||
|
||||
const { data: insertedInv, error: invError } = await supabase
|
||||
.from('invoices')
|
||||
.insert(mappedInvoice)
|
||||
.select('id')
|
||||
.single()
|
||||
|
||||
if (invError || !insertedInv) {
|
||||
console.error(`[migration] Sales invoice ${inv.invoiceNumber} insert failed:`, invError?.message)
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
if (mappedItems.length > 0) {
|
||||
const itemsWithInvoiceId = mappedItems.map(item => ({
|
||||
...item,
|
||||
invoice_id: insertedInv.id,
|
||||
}))
|
||||
await supabase.from('invoice_items').insert(itemsWithInvoiceId)
|
||||
}
|
||||
|
||||
imported++
|
||||
}
|
||||
|
||||
results.salesInvoices = { total: openInvoices.length, imported, skipped }
|
||||
} catch (err) {
|
||||
console.error('Failed to import sales invoices:', err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Step 5: Supplier invoices (open/unpaid only) ──────────────
|
||||
if (options.importSupplierInvoices !== false) {
|
||||
emitProgress(options, { status: 'importing', currentStep: 'Importerar leverantörsfakturor...', progress: 80 })
|
||||
try {
|
||||
const invoices = await fetchSupplierInvoices(consentId)
|
||||
const openInvoices = invoices.filter(i =>
|
||||
i.status === 'sent' || i.status === 'overdue' || i.status === 'booked' || i.status === 'draft'
|
||||
)
|
||||
console.log(`[migration] Supplier invoices: ${invoices.length} total, ${openInvoices.length} open (filtered by status: sent/overdue/booked/draft)`)
|
||||
|
||||
let imported = 0
|
||||
let skipped = 0
|
||||
|
||||
for (const inv of openInvoices) {
|
||||
const supplierOrgNumber = inv.supplier.legalEntity?.companyId ||
|
||||
inv.supplier.identifications?.find(i => i.schemeId === 'SE:ORGNR')?.id
|
||||
|
||||
let supplierId: string | null = null
|
||||
|
||||
if (supplierOrgNumber) {
|
||||
const { data: match } = await supabase
|
||||
.from('suppliers')
|
||||
.select('id')
|
||||
.eq('user_id', userId)
|
||||
.eq('org_number', supplierOrgNumber)
|
||||
.limit(1)
|
||||
if (match?.[0]) supplierId = match[0].id
|
||||
}
|
||||
|
||||
if (!supplierId) {
|
||||
const { data: match } = await supabase
|
||||
.from('suppliers')
|
||||
.select('id')
|
||||
.eq('user_id', userId)
|
||||
.eq('name', inv.supplier.name)
|
||||
.limit(1)
|
||||
if (match?.[0]) supplierId = match[0].id
|
||||
}
|
||||
|
||||
if (!supplierId) {
|
||||
const minimalSupplier = {
|
||||
user_id: userId,
|
||||
name: inv.supplier.name,
|
||||
supplier_type: 'swedish_business',
|
||||
default_payment_terms: 30,
|
||||
default_currency: 'SEK',
|
||||
country: 'SE',
|
||||
}
|
||||
const { data: created, error: supErr } = await supabase
|
||||
.from('suppliers')
|
||||
.insert(minimalSupplier)
|
||||
.select('id')
|
||||
.single()
|
||||
if (created) {
|
||||
supplierId = created.id
|
||||
} else {
|
||||
console.error(`[migration] Supplier invoice ${inv.invoiceNumber} skipped — could not create supplier "${inv.supplier.name}":`, supErr?.message)
|
||||
}
|
||||
}
|
||||
|
||||
if (!supplierId) {
|
||||
console.log(`[migration] Supplier invoice ${inv.invoiceNumber} skipped — no supplier match for "${inv.supplier.name}" (org: ${supplierOrgNumber || 'n/a'})`)
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
const { data: existingInv } = await supabase
|
||||
.from('supplier_invoices')
|
||||
.select('id')
|
||||
.eq('user_id', userId)
|
||||
.eq('supplier_invoice_number', inv.invoiceNumber)
|
||||
.eq('supplier_id', supplierId)
|
||||
.limit(1)
|
||||
|
||||
if (existingInv && existingInv.length > 0) {
|
||||
console.log(`[migration] Supplier invoice ${inv.invoiceNumber} skipped — already exists for supplier "${inv.supplier.name}"`)
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
const { invoice: mappedInvoice, items: mappedItems } = mapSupplierInvoice(inv, userId, supplierId)
|
||||
|
||||
// Get next arrival number (ankomstnummer) — required NOT NULL column
|
||||
const { data: arrivalNum, error: arrivalError } = await supabase
|
||||
.rpc('get_next_arrival_number', { p_user_id: userId })
|
||||
|
||||
if (arrivalError || arrivalNum == null) {
|
||||
console.error(`[migration] Supplier invoice ${inv.invoiceNumber} skipped — could not get arrival number:`, arrivalError?.message)
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
mappedInvoice.arrival_number = arrivalNum
|
||||
|
||||
const { data: insertedInv, error: invError } = await supabase
|
||||
.from('supplier_invoices')
|
||||
.insert(mappedInvoice)
|
||||
.select('id')
|
||||
.single()
|
||||
|
||||
if (invError || !insertedInv) {
|
||||
console.error(`[migration] Supplier invoice ${inv.invoiceNumber} insert failed for "${inv.supplier.name}":`, invError?.message, JSON.stringify(mappedInvoice, null, 2))
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
if (mappedItems.length > 0) {
|
||||
const itemsWithInvoiceId = mappedItems.map(item => ({
|
||||
...item,
|
||||
supplier_invoice_id: insertedInv.id,
|
||||
}))
|
||||
await supabase.from('supplier_invoice_items').insert(itemsWithInvoiceId)
|
||||
}
|
||||
|
||||
imported++
|
||||
}
|
||||
|
||||
results.supplierInvoices = { total: openInvoices.length, imported, skipped }
|
||||
} catch (err) {
|
||||
console.error('Failed to import supplier invoices:', err)
|
||||
}
|
||||
}
|
||||
|
||||
emitProgress(options, { status: 'completed', progress: 100, results })
|
||||
return results
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Migration failed'
|
||||
emitProgress(options, { status: 'failed', progress: 0, error: message })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"id": "arcim-migration",
|
||||
"sector": "general",
|
||||
"exportName": "arcimMigrationExtension",
|
||||
"entryPoint": "@/extensions/general/arcim-migration",
|
||||
"workspace": "@/components/extensions/general/ArcimMigrationWorkspace",
|
||||
"requiredEnvVars": ["ARCIM_SYNC_GATEWAY_URL", "ARCIM_SYNC_API_KEY"],
|
||||
"optionalEnvVars": [],
|
||||
"npmDependencies": [],
|
||||
"definition": {
|
||||
"name": "Systemmigration (Arcim Sync)",
|
||||
"category": "import",
|
||||
"icon": "ArrowRightLeft",
|
||||
"dataPattern": "manual",
|
||||
"hasOwnData": false,
|
||||
"description": "Migrera bokföring från Fortnox, Visma, Bokio, Björn Lundén eller Briox",
|
||||
"longDescription": "Flytta all bokföringsdata från ditt gamla system till gnubok. Importerar kontoplan, verifikationer, kunder, leverantörer och öppna fakturor automatiskt via säker API-integration."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
/**
|
||||
* Types for the Arcim Sync migration extension.
|
||||
*
|
||||
* These mirror the canonical DTOs from the Arcim Sync gateway
|
||||
* (packages/core/src/types/dto/) so we don't take a runtime dependency.
|
||||
*/
|
||||
|
||||
// ── Arcim Sync canonical DTOs (subset we consume) ──────────────────
|
||||
|
||||
export interface AmountType {
|
||||
value: number
|
||||
currencyCode: string
|
||||
}
|
||||
|
||||
export interface PostalAddress {
|
||||
streetName?: string
|
||||
additionalStreetName?: string
|
||||
buildingNumber?: string
|
||||
cityName?: string
|
||||
postalZone?: string
|
||||
countrySubentity?: string
|
||||
countryCode?: string
|
||||
}
|
||||
|
||||
export interface Contact {
|
||||
name?: string
|
||||
telephone?: string
|
||||
email?: string
|
||||
website?: string
|
||||
}
|
||||
|
||||
export interface PartyIdentification {
|
||||
id: string
|
||||
schemeId?: string
|
||||
}
|
||||
|
||||
export interface PartyLegalEntity {
|
||||
registrationName: string
|
||||
companyId?: string
|
||||
companyIdSchemeId?: string
|
||||
}
|
||||
|
||||
export interface PartyDto {
|
||||
name: string
|
||||
identifications: PartyIdentification[]
|
||||
postalAddress?: PostalAddress
|
||||
legalEntity?: PartyLegalEntity
|
||||
contact?: Contact
|
||||
}
|
||||
|
||||
export interface PaginatedResponse<T> {
|
||||
data: T[]
|
||||
page: number
|
||||
pageSize: number
|
||||
totalCount: number
|
||||
hasMore: boolean
|
||||
}
|
||||
|
||||
export interface TaxSubtotalDto {
|
||||
taxableAmount: AmountType
|
||||
taxAmount: AmountType
|
||||
taxCategory?: string
|
||||
percent?: number
|
||||
}
|
||||
|
||||
export interface TaxTotalDto {
|
||||
taxAmount: AmountType
|
||||
taxSubtotals?: TaxSubtotalDto[]
|
||||
}
|
||||
|
||||
export interface LegalMonetaryTotalDto {
|
||||
lineExtensionAmount: AmountType
|
||||
taxExclusiveAmount?: AmountType
|
||||
taxInclusiveAmount?: AmountType
|
||||
payableAmount: AmountType
|
||||
}
|
||||
|
||||
export interface PaymentStatusDto {
|
||||
paid: boolean
|
||||
balance: AmountType
|
||||
lastPaymentDate?: string
|
||||
}
|
||||
|
||||
// ── Company Information ─────────────────────────────────────────────
|
||||
|
||||
export interface CompanyInformationDto {
|
||||
companyName: string
|
||||
organizationNumber?: string
|
||||
legalEntity?: PartyLegalEntity
|
||||
address?: PostalAddress
|
||||
contact?: Contact
|
||||
vatNumber?: string
|
||||
fiscalYearStart?: string // MM-DD
|
||||
baseCurrency?: string
|
||||
}
|
||||
|
||||
// ── Customer ────────────────────────────────────────────────────────
|
||||
|
||||
export type ArcimCustomerType = 'company' | 'private'
|
||||
|
||||
export interface CustomerDto {
|
||||
id: string
|
||||
customerNumber: string
|
||||
type?: ArcimCustomerType
|
||||
party: PartyDto
|
||||
active: boolean
|
||||
vatNumber?: string
|
||||
defaultPaymentTermsDays?: number
|
||||
note?: string
|
||||
}
|
||||
|
||||
// ── Supplier ────────────────────────────────────────────────────────
|
||||
|
||||
export interface SupplierDto {
|
||||
id: string
|
||||
supplierNumber: string
|
||||
party: PartyDto
|
||||
active: boolean
|
||||
vatNumber?: string
|
||||
bankAccount?: string
|
||||
bankGiro?: string
|
||||
plusGiro?: string
|
||||
defaultPaymentTermsDays?: number
|
||||
note?: string
|
||||
}
|
||||
|
||||
// ── Sales Invoice ───────────────────────────────────────────────────
|
||||
|
||||
export type InvoiceStatusCode = 'draft' | 'sent' | 'booked' | 'paid' | 'overdue' | 'cancelled' | 'credited'
|
||||
|
||||
export interface SalesInvoiceLineDto {
|
||||
id: string
|
||||
description?: string
|
||||
quantity?: number
|
||||
unitCode?: string
|
||||
unitPrice?: AmountType
|
||||
lineExtensionAmount: AmountType
|
||||
taxPercent?: number
|
||||
taxAmount?: AmountType
|
||||
accountNumber?: string
|
||||
itemName?: string
|
||||
}
|
||||
|
||||
export interface SalesInvoiceDto {
|
||||
id: string
|
||||
invoiceNumber: string
|
||||
issueDate: string
|
||||
dueDate?: string
|
||||
deliveryDate?: string
|
||||
invoiceTypeCode?: string
|
||||
currencyCode: string
|
||||
status: InvoiceStatusCode
|
||||
supplier: PartyDto
|
||||
customer: PartyDto
|
||||
lines: SalesInvoiceLineDto[]
|
||||
taxTotal?: TaxTotalDto
|
||||
legalMonetaryTotal: LegalMonetaryTotalDto
|
||||
paymentStatus: PaymentStatusDto
|
||||
paymentTerms?: string
|
||||
note?: string
|
||||
}
|
||||
|
||||
// ── Supplier Invoice ────────────────────────────────────────────────
|
||||
|
||||
export interface SupplierInvoiceLineDto {
|
||||
id: string
|
||||
description?: string
|
||||
quantity?: number
|
||||
unitCode?: string
|
||||
unitPrice?: AmountType
|
||||
lineExtensionAmount: AmountType
|
||||
taxPercent?: number
|
||||
taxAmount?: AmountType
|
||||
accountNumber?: string
|
||||
itemName?: string
|
||||
}
|
||||
|
||||
export interface SupplierInvoiceDto {
|
||||
id: string
|
||||
invoiceNumber: string
|
||||
issueDate: string
|
||||
dueDate?: string
|
||||
deliveryDate?: string
|
||||
invoiceTypeCode?: string
|
||||
currencyCode: string
|
||||
status: InvoiceStatusCode
|
||||
supplier: PartyDto
|
||||
buyer: PartyDto
|
||||
lines: SupplierInvoiceLineDto[]
|
||||
taxTotal?: TaxTotalDto
|
||||
legalMonetaryTotal: LegalMonetaryTotalDto
|
||||
paymentStatus: PaymentStatusDto
|
||||
ocrNumber?: string
|
||||
note?: string
|
||||
}
|
||||
|
||||
// ── Supported providers ─────────────────────────────────────────────
|
||||
|
||||
export type ArcimProvider = 'fortnox' | 'visma' | 'briox' | 'bokio' | 'bjornlunden'
|
||||
|
||||
export const ARCIM_PROVIDERS: { id: ArcimProvider; name: string; authType: 'oauth' | 'token' }[] = [
|
||||
{ id: 'fortnox', name: 'Fortnox', authType: 'oauth' },
|
||||
{ id: 'visma', name: 'Visma eEkonomi', authType: 'oauth' },
|
||||
{ id: 'bokio', name: 'Bokio', authType: 'token' },
|
||||
{ id: 'bjornlunden', name: 'Björn Lundén', authType: 'token' },
|
||||
{ id: 'briox', name: 'Briox', authType: 'token' },
|
||||
]
|
||||
|
||||
// ── Migration state ─────────────────────────────────────────────────
|
||||
|
||||
export interface MigrationProgress {
|
||||
status: 'idle' | 'connecting' | 'fetching' | 'importing' | 'completed' | 'failed'
|
||||
currentStep?: string
|
||||
progress: number // 0-100
|
||||
results?: MigrationResults
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface MigrationResults {
|
||||
companyInfo?: { imported: boolean }
|
||||
customers?: { total: number; imported: number; skipped: number }
|
||||
suppliers?: { total: number; imported: number; skipped: number }
|
||||
salesInvoices?: { total: number; imported: number; skipped: number }
|
||||
supplierInvoices?: { total: number; imported: number; skipped: number }
|
||||
}
|
||||
|
||||
// ── Consent flow ────────────────────────────────────────────────────
|
||||
|
||||
export interface ConsentRecord {
|
||||
id: string
|
||||
name: string
|
||||
provider: ArcimProvider
|
||||
status: 0 | 1 | 2 | 3 // Created | Accepted | Revoked | Inactive
|
||||
orgNumber?: string
|
||||
companyName?: string
|
||||
etag?: string
|
||||
createdAt?: string
|
||||
updatedAt?: string
|
||||
}
|
||||
|
||||
export interface OtcResponse {
|
||||
code: string
|
||||
consentId: string
|
||||
expiresAt: string
|
||||
}
|
||||
@@ -49,7 +49,7 @@ describe('sectors registry', () => {
|
||||
})
|
||||
|
||||
it('should have 8 total extensions', () => {
|
||||
expect(getAllExtensions().length).toBe(8)
|
||||
expect(getAllExtensions().length).toBe(9)
|
||||
})
|
||||
|
||||
it('should have unique slugs within each sector', () => {
|
||||
@@ -94,7 +94,7 @@ describe('sectors registry', () => {
|
||||
|
||||
it('getExtensionsBySector returns extensions for a sector', () => {
|
||||
const extensions = getExtensionsBySector('general')
|
||||
expect(extensions.length).toBe(8)
|
||||
expect(extensions.length).toBe(9)
|
||||
})
|
||||
|
||||
it('all extensions have required fields', () => {
|
||||
|
||||
@@ -3,4 +3,5 @@
|
||||
export const ENABLED_EXTENSION_IDS: ReadonlySet<string> = new Set([
|
||||
'enable-banking',
|
||||
'email',
|
||||
'arcim-migration',
|
||||
])
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
import type { Extension } from '../types'
|
||||
import { enableBankingExtension } from '@/extensions/general/enable-banking'
|
||||
import { emailExtension } from '@/extensions/general/email'
|
||||
import { arcimMigrationExtension } from '@/extensions/general/arcim-migration'
|
||||
|
||||
export const FIRST_PARTY_EXTENSIONS: Extension[] = [
|
||||
enableBankingExtension,
|
||||
emailExtension,
|
||||
arcimMigrationExtension,
|
||||
]
|
||||
|
||||
@@ -30,5 +30,15 @@ export const EXTENSION_DEFINITIONS: Record<string, ExtensionDefinition[]> = {
|
||||
"company_settings"
|
||||
]
|
||||
},
|
||||
{
|
||||
"slug": "arcim-migration",
|
||||
"name": "Systemmigration (Arcim Sync)",
|
||||
"sector": "general",
|
||||
"category": "import",
|
||||
"icon": "ArrowRightLeft",
|
||||
"dataPattern": "manual",
|
||||
"description": "Migrera bokföring från Fortnox, Visma, Bokio, Björn Lundén eller Briox",
|
||||
"longDescription": "Flytta all bokföringsdata från ditt gamla system till gnubok. Importerar kontoplan, verifikationer, kunder, leverantörer och öppna fakturor automatiskt via säker API-integration."
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
@@ -5,4 +5,5 @@ import type { WorkspaceComponentProps } from '../workspace-registry'
|
||||
|
||||
export const WORKSPACES: Record<string, ComponentType<WorkspaceComponentProps>> = {
|
||||
'general/enable-banking': dynamic(() => import('@/components/extensions/general/EnableBankingWorkspace')),
|
||||
'general/arcim-migration': dynamic(() => import('@/components/extensions/general/ArcimMigrationWorkspace')),
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ const LEGACY_GENERAL_EXTENSIONS = [
|
||||
'ai-categorization',
|
||||
'ai-chat',
|
||||
'enable-banking',
|
||||
'arcim-migration',
|
||||
]
|
||||
|
||||
export async function isExtensionEnabled(
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
getMappingStats,
|
||||
applyMappingOverride,
|
||||
mappingsToMap,
|
||||
isSystemAccount,
|
||||
} from '../account-mapper'
|
||||
|
||||
// --- Helpers ---
|
||||
@@ -54,6 +55,8 @@ const basAccounts: BASAccount[] = [
|
||||
makeBASAccount('1510', 'Kundfordringar'),
|
||||
makeBASAccount('1930', 'Företagskonto'),
|
||||
makeBASAccount('2440', 'Leverantörsskulder'),
|
||||
makeBASAccount('2640', 'Ingående moms'),
|
||||
makeBASAccount('2641', 'Debiterad ingående moms'),
|
||||
makeBASAccount('3001', 'Försäljning varor 25%'),
|
||||
makeBASAccount('3002', 'Försäljning varor 12%'),
|
||||
makeBASAccount('5010', 'Lokalhyra'),
|
||||
@@ -94,7 +97,7 @@ describe('suggestMappings', () => {
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].targetAccount).toBe('3400')
|
||||
expect(result[0].targetName).toBe('Försäljning tjänster')
|
||||
expect(result[0].confidence).toBe(0.9)
|
||||
expect(result[0].confidence).toBe(0.7)
|
||||
expect(result[0].matchType).toBe('bas_range')
|
||||
})
|
||||
|
||||
@@ -105,7 +108,7 @@ describe('suggestMappings', () => {
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].targetAccount).toBe('1241')
|
||||
expect(result[0].targetName).toBe('Personbilar')
|
||||
expect(result[0].confidence).toBe(0.9)
|
||||
expect(result[0].confidence).toBe(0.7)
|
||||
expect(result[0].matchType).toBe('bas_range')
|
||||
})
|
||||
|
||||
@@ -164,9 +167,9 @@ describe('suggestMappings', () => {
|
||||
// Unmapped (confidence 0) should come first
|
||||
expect(result[0].sourceAccount).toBe('9999')
|
||||
expect(result[0].confidence).toBe(0)
|
||||
// bas_range (confidence 0.9) next
|
||||
// bas_range (confidence 0.7) next
|
||||
expect(result[1].sourceAccount).toBe('3400')
|
||||
expect(result[1].confidence).toBe(0.9)
|
||||
expect(result[1].confidence).toBe(0.7)
|
||||
// Exact matches (confidence 1.0) come last
|
||||
expect(result[2].confidence).toBe(1.0)
|
||||
expect(result[3].confidence).toBe(1.0)
|
||||
@@ -193,13 +196,33 @@ describe('suggestMappings', () => {
|
||||
expect(result).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('redirects group header account 2640 to posting account 2641', () => {
|
||||
const source = [makeSIEAccount('2640', 'Ingående moms')]
|
||||
const result = suggestMappings(source, basAccounts)
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].sourceAccount).toBe('2640')
|
||||
expect(result[0].targetAccount).toBe('2641')
|
||||
expect(result[0].targetName).toBe('Debiterad ingående moms')
|
||||
expect(result[0].confidence).toBe(1.0)
|
||||
expect(result[0].matchType).toBe('exact')
|
||||
})
|
||||
|
||||
it('does not redirect 2641 (it is the posting account, not a group header)', () => {
|
||||
const source = [makeSIEAccount('2641', 'Debiterad ingående moms')]
|
||||
const result = suggestMappings(source, basAccounts)
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].targetAccount).toBe('2641')
|
||||
})
|
||||
|
||||
it('handles empty BAS accounts — bas_range fallback for valid accounts', () => {
|
||||
const source = [makeSIEAccount('1510', 'Kundfordringar')]
|
||||
const result = suggestMappings(source, [])
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].targetAccount).toBe('1510')
|
||||
expect(result[0].confidence).toBe(0.9)
|
||||
expect(result[0].confidence).toBe(0.7)
|
||||
expect(result[0].matchType).toBe('bas_range')
|
||||
})
|
||||
|
||||
@@ -360,8 +383,8 @@ describe('getMappingStats', () => {
|
||||
)
|
||||
const stats = getMappingStats(mappings)
|
||||
|
||||
// Average of (1.0 + 0.9) / 2 = 0.95
|
||||
expect(stats.averageConfidence).toBe(0.95)
|
||||
// Average of (1.0 + 0.7) / 2 = 0.85
|
||||
expect(stats.averageConfidence).toBe(0.85)
|
||||
})
|
||||
|
||||
it('returns 0 average confidence when nothing is mapped', () => {
|
||||
@@ -446,3 +469,53 @@ describe('mappingsToMap', () => {
|
||||
expect(map.size).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isSystemAccount', () => {
|
||||
it('returns true for Fortnox system account 0099', () => {
|
||||
expect(isSystemAccount('0099')).toBe(true)
|
||||
})
|
||||
|
||||
it('returns true for other 0xxx accounts', () => {
|
||||
expect(isSystemAccount('0001')).toBe(true)
|
||||
expect(isSystemAccount('0500')).toBe(true)
|
||||
expect(isSystemAccount('0999')).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false for valid BAS accounts (1000-8999)', () => {
|
||||
expect(isSystemAccount('1000')).toBe(false)
|
||||
expect(isSystemAccount('1510')).toBe(false)
|
||||
expect(isSystemAccount('3001')).toBe(false)
|
||||
expect(isSystemAccount('8999')).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for 9000+ accounts (handled separately as out-of-range)', () => {
|
||||
expect(isSystemAccount('9000')).toBe(false)
|
||||
expect(isSystemAccount('9999')).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for non-4-digit numbers', () => {
|
||||
expect(isSystemAccount('099')).toBe(false)
|
||||
expect(isSystemAccount('00099')).toBe(false)
|
||||
expect(isSystemAccount('abc')).toBe(false)
|
||||
expect(isSystemAccount('')).toBe(false)
|
||||
})
|
||||
|
||||
it('allows pre-filtering system accounts before suggestMappings', () => {
|
||||
const allAccounts = [
|
||||
makeSIEAccount('0099', 'Systemkonto'),
|
||||
makeSIEAccount('1510', 'Kundfordringar'),
|
||||
makeSIEAccount('1930', 'Företagskonto'),
|
||||
]
|
||||
|
||||
const bookkeepingAccounts = allAccounts.filter((a) => !isSystemAccount(a.number))
|
||||
const excluded = allAccounts.filter((a) => isSystemAccount(a.number))
|
||||
|
||||
expect(bookkeepingAccounts).toHaveLength(2)
|
||||
expect(excluded).toHaveLength(1)
|
||||
expect(excluded[0].number).toBe('0099')
|
||||
|
||||
const mappings = suggestMappings(bookkeepingAccounts, basAccounts)
|
||||
expect(mappings).toHaveLength(2)
|
||||
expect(mappings.every((m) => m.targetAccount)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { generateImportPreview } from '../sie-import'
|
||||
import { generateImportPreview, validateIBBalance, isBalanceSheetAccount } from '../sie-import'
|
||||
import type { ParsedSIEFile, AccountMapping } from '../types'
|
||||
|
||||
// --- Helpers ---
|
||||
@@ -221,3 +221,116 @@ describe('generateImportPreview', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateIBBalance', () => {
|
||||
it('returns 0 roundingAdjustment when IB is balanced', () => {
|
||||
const parsed = makeParsedFile({
|
||||
openingBalances: [
|
||||
{ yearIndex: 0, account: '1510', amount: 50000 },
|
||||
{ yearIndex: 0, account: '2440', amount: -50000 },
|
||||
],
|
||||
})
|
||||
const accountMap = new Map([['1510', '1510'], ['2440', '2440']])
|
||||
const result = validateIBBalance(parsed, accountMap)
|
||||
|
||||
expect(result.roundingAdjustment).toBe(0)
|
||||
expect(result.fileImbalance).toBe(0)
|
||||
expect(result.excludedAccountsTotal).toBe(0)
|
||||
expect(result.lines).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('returns rounding adjustment for imbalance <= 1 SEK', () => {
|
||||
const parsed = makeParsedFile({
|
||||
openingBalances: [
|
||||
{ yearIndex: 0, account: '1510', amount: 50000.50 },
|
||||
{ yearIndex: 0, account: '2440', amount: -50000 },
|
||||
],
|
||||
})
|
||||
const accountMap = new Map([['1510', '1510'], ['2440', '2440']])
|
||||
const result = validateIBBalance(parsed, accountMap)
|
||||
|
||||
expect(result.roundingAdjustment).toBe(0.5)
|
||||
expect(result.fileImbalance).toBe(0.5)
|
||||
})
|
||||
|
||||
it('returns large adjustment for file-level imbalance (unallocated årets resultat)', () => {
|
||||
// Simulates a Fortnox export where previous year result hasn't been allocated
|
||||
// to equity — BS accounts don't balance because årets resultat is implicit
|
||||
const parsed = makeParsedFile({
|
||||
openingBalances: [
|
||||
{ yearIndex: 0, account: '1510', amount: 50100 },
|
||||
{ yearIndex: 0, account: '2440', amount: -50000 },
|
||||
],
|
||||
})
|
||||
const accountMap = new Map([['1510', '1510'], ['2440', '2440']])
|
||||
const result = validateIBBalance(parsed, accountMap)
|
||||
|
||||
// The adjustment is 100 SEK — caller should book to 2099, never reject
|
||||
expect(result.roundingAdjustment).toBe(100)
|
||||
expect(result.fileImbalance).toBe(100)
|
||||
expect(result.excludedAccountsTotal).toBe(0)
|
||||
})
|
||||
|
||||
it('tracks excluded accounts separately from file imbalance (Fortnox system accounts)', () => {
|
||||
// Simulates Fortnox 0099 carrying IB balance — file is balanced,
|
||||
// but mapped accounts are not because 0099 is excluded from mapping
|
||||
const parsed = makeParsedFile({
|
||||
openingBalances: [
|
||||
{ yearIndex: 0, account: '1510', amount: 50000 },
|
||||
{ yearIndex: 0, account: '2440', amount: -150000 },
|
||||
{ yearIndex: 0, account: '0099', amount: 100000 }, // System account, not mapped
|
||||
],
|
||||
})
|
||||
const accountMap = new Map([['1510', '1510'], ['2440', '2440']])
|
||||
const result = validateIBBalance(parsed, accountMap)
|
||||
|
||||
// File-level: 50000 + (-150000) + 100000 = 0, balanced
|
||||
expect(result.fileImbalance).toBe(0)
|
||||
// Mapped-level: 50000 debit, 150000 credit = -100000 diff
|
||||
expect(result.roundingAdjustment).toBe(-100000)
|
||||
// The excluded 0099 accounts for the entire difference
|
||||
expect(result.excludedAccountsTotal).toBe(100000)
|
||||
// Only 2 lines (0099 excluded)
|
||||
expect(result.lines).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('ignores non-current-year balances', () => {
|
||||
const parsed = makeParsedFile({
|
||||
openingBalances: [
|
||||
{ yearIndex: 0, account: '1510', amount: 50000 },
|
||||
{ yearIndex: 0, account: '2440', amount: -50000 },
|
||||
{ yearIndex: -1, account: '1510', amount: 99999 }, // Previous year — ignored
|
||||
],
|
||||
})
|
||||
const accountMap = new Map([['1510', '1510'], ['2440', '2440']])
|
||||
const result = validateIBBalance(parsed, accountMap)
|
||||
|
||||
expect(result.roundingAdjustment).toBe(0)
|
||||
expect(result.lines).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isBalanceSheetAccount', () => {
|
||||
it('returns true for class 1 (assets)', () => {
|
||||
expect(isBalanceSheetAccount('1510')).toBe(true)
|
||||
expect(isBalanceSheetAccount('1930')).toBe(true)
|
||||
})
|
||||
|
||||
it('returns true for class 2 (liabilities/equity)', () => {
|
||||
expect(isBalanceSheetAccount('2099')).toBe(true)
|
||||
expect(isBalanceSheetAccount('2440')).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false for class 3 (revenue)', () => {
|
||||
expect(isBalanceSheetAccount('3001')).toBe(false)
|
||||
expect(isBalanceSheetAccount('3740')).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for class 4-8 (expenses)', () => {
|
||||
expect(isBalanceSheetAccount('4010')).toBe(false)
|
||||
expect(isBalanceSheetAccount('5010')).toBe(false)
|
||||
expect(isBalanceSheetAccount('6211')).toBe(false)
|
||||
expect(isBalanceSheetAccount('7210')).toBe(false)
|
||||
expect(isBalanceSheetAccount('8999')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -383,7 +383,7 @@ describe('validateSIEFile', () => {
|
||||
expect(validation.errors.some((e) => e.includes('not balanced'))).toBe(true)
|
||||
})
|
||||
|
||||
it('adds warning for undefined account references', () => {
|
||||
it('no longer warns about accounts referenced in #IB since parser auto-adds them', () => {
|
||||
const content = [
|
||||
'#FLAGGA 0',
|
||||
'#SIETYP 4',
|
||||
@@ -394,9 +394,12 @@ describe('validateSIEFile', () => {
|
||||
].join('\n')
|
||||
|
||||
const parsed = parseSIEFile(content)
|
||||
const validation = validateSIEFile(parsed)
|
||||
// Parser now auto-adds 9999 to accounts list from #IB data
|
||||
expect(parsed.accounts.map((a) => a.number)).toContain('9999')
|
||||
|
||||
expect(validation.warnings.some((w) => w.includes('9999') && w.includes('not defined'))).toBe(true)
|
||||
const validation = validateSIEFile(parsed)
|
||||
// No warning since account was auto-added by the parser
|
||||
expect(validation.warnings.some((w) => w.includes('9999') && w.includes('not defined'))).toBe(false)
|
||||
})
|
||||
|
||||
it('adds error for missing #RAR', () => {
|
||||
@@ -436,6 +439,60 @@ describe('validateSIEFile', () => {
|
||||
|
||||
// --- Fix 2: Windows-1252 encoding detection and decoding ---
|
||||
|
||||
describe('detectEncoding — #FORMAT PC8 detection', () => {
|
||||
it('returns cp437 when #FORMAT PC8 is present in the first 500 bytes', () => {
|
||||
const text = '#FLAGGA 0\n#FORMAT PC8\n#SIETYP 4\n'
|
||||
const encoder = new TextEncoder()
|
||||
const buf = encoder.encode(text)
|
||||
const encoding = detectEncoding(buf.buffer)
|
||||
expect(encoding).toBe('cp437')
|
||||
})
|
||||
|
||||
it('returns cp437 even when Win-1252 bytes follow #FORMAT PC8', () => {
|
||||
// #FORMAT PC8 header should take priority over any byte analysis
|
||||
const prefix = new TextEncoder().encode('#FORMAT PC8\n#FNAMN F')
|
||||
const buf = new Uint8Array(prefix.length + 3)
|
||||
buf.set(prefix)
|
||||
buf[prefix.length] = 0xf6 // ö in Win-1252
|
||||
buf[prefix.length + 1] = 0xe4 // ä in Win-1252
|
||||
buf[prefix.length + 2] = 0xe5 // å in Win-1252
|
||||
const encoding = detectEncoding(buf.buffer)
|
||||
expect(encoding).toBe('cp437')
|
||||
})
|
||||
})
|
||||
|
||||
describe('detectEncoding — range-based discrimination', () => {
|
||||
it('detects CP437 when bytes are in 0x80-0x9F range only', () => {
|
||||
// 0x84=ä, 0x86=å, 0x94=ö in CP437 — all in 0x80-0x9F
|
||||
const buf = new Uint8Array([0x23, 0x84, 0x86, 0x94, 0x84, 0x86])
|
||||
const encoding = detectEncoding(buf.buffer)
|
||||
expect(encoding).toBe('cp437')
|
||||
})
|
||||
|
||||
it('detects Win-1252 when bytes are in 0xC0-0xFF range only', () => {
|
||||
// 0xE4=ä, 0xE5=å, 0xF6=ö in Win-1252 — all in 0xC0-0xFF
|
||||
const buf = new Uint8Array([0x23, 0xe4, 0xe5, 0xf6, 0xe4, 0xe5])
|
||||
const encoding = detectEncoding(buf.buffer)
|
||||
expect(encoding).toBe('windows1252')
|
||||
})
|
||||
|
||||
it('does not double-count UTF-8 continuation bytes as CP437', () => {
|
||||
// UTF-8: ä = C3 A4, å = C3 A5, ö = C3 B6
|
||||
// Without skipping, 0xA4/0xA5/0xB6 are NOT in CP437 map so no false count,
|
||||
// but 0x84/0x85 ARE in CP437 map — test that C3 84 (Ä in UTF-8) is not
|
||||
// counted as CP437 0x84 (ä)
|
||||
const buf = new Uint8Array([
|
||||
0x23, // #
|
||||
0xc3, 0x84, // Ä in UTF-8
|
||||
0xc3, 0x85, // Å in UTF-8
|
||||
0xc3, 0x96, // Ö in UTF-8
|
||||
0xc3, 0xa4, // ä in UTF-8
|
||||
0xc3, 0xa5, // å in UTF-8
|
||||
])
|
||||
const encoding = detectEncoding(buf.buffer)
|
||||
expect(encoding).toBe('utf8')
|
||||
})
|
||||
})
|
||||
describe('detectEncoding — Windows-1252', () => {
|
||||
it('detects Windows-1252 when Swedish chars use Win-1252 byte values', () => {
|
||||
// Build a buffer with Windows-1252 encoded Swedish text: "#FNAMN Företag"
|
||||
@@ -662,3 +719,82 @@ describe('parseSIEFile — missing amount handling', () => {
|
||||
expect(result.openingBalances[0].amount).toBe(100000)
|
||||
})
|
||||
})
|
||||
|
||||
// --- Fix B4: Account collection from transaction data ---
|
||||
|
||||
describe('parseSIEFile — account collection from transaction data', () => {
|
||||
it('adds accounts from #TRANS that are missing from #KONTO', () => {
|
||||
const content = [
|
||||
'#FLAGGA 0',
|
||||
'#SIETYP 4',
|
||||
'#FNAMN "Test"',
|
||||
'#RAR 0 20240101 20241231',
|
||||
'#KONTO 1510 "Kundfordringar"',
|
||||
// 3001 is NOT defined in #KONTO but used in #TRANS
|
||||
'#VER A 1 20240115 "Test"',
|
||||
'{',
|
||||
'#TRANS 1510 {} 10000.00',
|
||||
'#TRANS 3001 {} -10000.00',
|
||||
'}',
|
||||
].join('\n')
|
||||
|
||||
const result = parseSIEFile(content)
|
||||
// Should have both 1510 (from #KONTO) and 3001 (from #TRANS)
|
||||
expect(result.accounts.map((a) => a.number)).toContain('1510')
|
||||
expect(result.accounts.map((a) => a.number)).toContain('3001')
|
||||
// The auto-added account should have empty name
|
||||
const added = result.accounts.find((a) => a.number === '3001')
|
||||
expect(added?.name).toBe('')
|
||||
})
|
||||
|
||||
it('adds accounts from #IB that are missing from #KONTO', () => {
|
||||
const content = [
|
||||
'#FLAGGA 0',
|
||||
'#SIETYP 4',
|
||||
'#FNAMN "Test"',
|
||||
'#RAR 0 20240101 20241231',
|
||||
'#KONTO 1510 "Kundfordringar"',
|
||||
'#IB 0 1510 50000.00',
|
||||
'#IB 0 2440 -50000.00', // 2440 not in #KONTO
|
||||
].join('\n')
|
||||
|
||||
const result = parseSIEFile(content)
|
||||
expect(result.accounts.map((a) => a.number)).toContain('2440')
|
||||
})
|
||||
|
||||
it('does not duplicate accounts already in #KONTO', () => {
|
||||
const content = [
|
||||
'#FLAGGA 0',
|
||||
'#SIETYP 4',
|
||||
'#FNAMN "Test"',
|
||||
'#RAR 0 20240101 20241231',
|
||||
'#KONTO 1510 "Kundfordringar"',
|
||||
'#KONTO 3001 "Försäljning"',
|
||||
'#VER A 1 20240115 "Test"',
|
||||
'{',
|
||||
'#TRANS 1510 {} 10000.00',
|
||||
'#TRANS 3001 {} -10000.00',
|
||||
'}',
|
||||
].join('\n')
|
||||
|
||||
const result = parseSIEFile(content)
|
||||
const count1510 = result.accounts.filter((a) => a.number === '1510').length
|
||||
expect(count1510).toBe(1)
|
||||
})
|
||||
|
||||
it('adds accounts from #UB and #RES that are missing from #KONTO', () => {
|
||||
const content = [
|
||||
'#FLAGGA 0',
|
||||
'#SIETYP 4',
|
||||
'#FNAMN "Test"',
|
||||
'#RAR 0 20240101 20241231',
|
||||
'#KONTO 1510 "Kundfordringar"',
|
||||
'#UB 0 1930 100000.00', // 1930 not in #KONTO
|
||||
'#RES 0 3001 -50000.00', // 3001 not in #KONTO
|
||||
].join('\n')
|
||||
|
||||
const result = parseSIEFile(content)
|
||||
expect(result.accounts.map((a) => a.number)).toContain('1930')
|
||||
expect(result.accounts.map((a) => a.number)).toContain('3001')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -25,6 +25,25 @@ export type MappableAccount = {
|
||||
account_name: string
|
||||
}
|
||||
|
||||
// Group header accounts that should redirect to their posting sub-account.
|
||||
// These are BAS group headers not meant for direct posting.
|
||||
const GROUP_HEADER_REDIRECTS: Record<string, string> = {
|
||||
'2640': '2641', // Ingående moms → Debiterad ingående moms
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an account is a source-system internal account that should be
|
||||
* excluded from import. BAS accounts use classes 1-8 (1000-8999). Account
|
||||
* numbers starting with 0 (e.g. Fortnox 0099) are internal system accounts
|
||||
* with no BAS equivalent — they should be silently filtered out rather than
|
||||
* forcing the user to map them.
|
||||
*/
|
||||
export function isSystemAccount(accountNumber: string): boolean {
|
||||
if (!/^\d{4}$/.test(accountNumber)) return false
|
||||
const num = parseInt(accountNumber, 10)
|
||||
return num < 1000
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an account number is in the valid BAS range (1000-8999).
|
||||
* Standard Swedish BAS accounts are 4-digit numbers in classes 1-8.
|
||||
@@ -60,6 +79,23 @@ function findBestMatch(
|
||||
)
|
||||
|
||||
if (exactMatch) {
|
||||
// Redirect group header accounts to their posting sub-account
|
||||
const redirect = GROUP_HEADER_REDIRECTS[exactMatch.account_number]
|
||||
if (redirect) {
|
||||
const redirectTarget = basAccounts.find((a) => a.account_number === redirect)
|
||||
if (redirectTarget) {
|
||||
return {
|
||||
sourceAccount: source.number,
|
||||
sourceName: source.name,
|
||||
targetAccount: redirectTarget.account_number,
|
||||
targetName: redirectTarget.account_name,
|
||||
confidence: 1.0,
|
||||
matchType: 'exact',
|
||||
isOverride: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
sourceAccount: source.number,
|
||||
sourceName: source.name,
|
||||
@@ -80,7 +116,7 @@ function findBestMatch(
|
||||
sourceName: source.name,
|
||||
targetAccount: source.number,
|
||||
targetName: source.name,
|
||||
confidence: 0.9,
|
||||
confidence: 0.7,
|
||||
matchType: 'bas_range',
|
||||
isOverride: false,
|
||||
}
|
||||
|
||||
+715
-51
@@ -14,16 +14,22 @@ import type {
|
||||
ImportResult,
|
||||
ImportPreview,
|
||||
SIEImport,
|
||||
MigrationDocumentation,
|
||||
} from './types'
|
||||
import type { CreateJournalEntryLineInput } from '@/types'
|
||||
import { mappingsToMap, getMappingStats } from './account-mapper'
|
||||
import { calculateFileHash } from './sie-parser'
|
||||
import { getBASReference } from '@/lib/bookkeeping/bas-reference'
|
||||
import { computeSRUCode } from '@/lib/bookkeeping/bas-data/sru-mapping'
|
||||
|
||||
/**
|
||||
* Format a date to ISO date string (YYYY-MM-DD)
|
||||
*/
|
||||
function formatDate(date: Date): string {
|
||||
return date.toISOString().split('T')[0]
|
||||
const year = date.getFullYear()
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
return `${year}-${month}-${day}`
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -68,6 +74,7 @@ export function generateImportPreview(
|
||||
unmapped: mappingStats.unmapped,
|
||||
lowConfidence: mappingStats.lowConfidence,
|
||||
},
|
||||
excludedSystemAccounts: [],
|
||||
issues: parsed.issues,
|
||||
}
|
||||
}
|
||||
@@ -157,14 +164,82 @@ async function ensureFiscalPeriod(
|
||||
}
|
||||
|
||||
/**
|
||||
* Create opening balance journal entry from IB amounts
|
||||
* Compute IB imbalance and validate it before creating the opening balance entry.
|
||||
*
|
||||
* Distinguishes between:
|
||||
* - File-level imbalance: the raw SIE #IB data doesn't balance (source file error)
|
||||
* - Mapping-level imbalance: caused by excluded accounts (system accounts like Fortnox 0099)
|
||||
* that carry IB balances but are correctly filtered from mapping. This is expected and
|
||||
* should be booked to 2099 with clear documentation.
|
||||
*/
|
||||
export function validateIBBalance(
|
||||
parsed: ParsedSIEFile,
|
||||
accountMap: Map<string, string>
|
||||
): {
|
||||
lines: CreateJournalEntryLineInput[]
|
||||
roundingAdjustment: number
|
||||
fileImbalance: number
|
||||
excludedAccountsTotal: number
|
||||
} {
|
||||
const currentYearBalances = parsed.openingBalances.filter((b) => b.yearIndex === 0)
|
||||
|
||||
// First: check the raw file-level IB balance (all accounts, before mapping)
|
||||
const rawTotal = currentYearBalances.reduce((sum, b) => sum + b.amount, 0)
|
||||
const fileImbalance = Math.round(Math.abs(rawTotal) * 100) / 100
|
||||
|
||||
// Build mapped lines and track excluded account totals
|
||||
const lines: CreateJournalEntryLineInput[] = []
|
||||
let excludedTotal = 0
|
||||
|
||||
for (const balance of currentYearBalances) {
|
||||
const targetAccount = accountMap.get(balance.account)
|
||||
if (!targetAccount) {
|
||||
// Account not in mapping (system account or unmapped) — track its IB contribution
|
||||
excludedTotal += balance.amount
|
||||
continue
|
||||
}
|
||||
|
||||
if (balance.amount > 0) {
|
||||
lines.push({
|
||||
account_number: targetAccount,
|
||||
debit_amount: balance.amount,
|
||||
credit_amount: 0,
|
||||
line_description: `IB ${balance.account}`,
|
||||
})
|
||||
} else if (balance.amount < 0) {
|
||||
lines.push({
|
||||
account_number: targetAccount,
|
||||
debit_amount: 0,
|
||||
credit_amount: Math.abs(balance.amount),
|
||||
line_description: `IB ${balance.account}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const totalDebit = lines.reduce((sum, l) => sum + l.debit_amount, 0)
|
||||
const totalCredit = lines.reduce((sum, l) => sum + l.credit_amount, 0)
|
||||
const mappedDiff = Math.round((totalDebit - totalCredit) * 100) / 100
|
||||
|
||||
return {
|
||||
lines,
|
||||
roundingAdjustment: Math.abs(mappedDiff) > 0.01 ? mappedDiff : 0,
|
||||
fileImbalance,
|
||||
excludedAccountsTotal: Math.round(excludedTotal * 100) / 100,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create opening balance journal entry from IB amounts.
|
||||
* The caller must validate the IB balance first via validateIBBalance().
|
||||
* If roundingAdjustment is non-zero, it is booked explicitly to 2099 with clear text.
|
||||
*/
|
||||
async function createOpeningBalanceEntry(
|
||||
supabase: SupabaseClient,
|
||||
userId: string,
|
||||
fiscalPeriodId: string,
|
||||
parsed: ParsedSIEFile,
|
||||
accountMap: Map<string, string>
|
||||
accountMap: Map<string, string>,
|
||||
roundingAdjustment: number
|
||||
): Promise<string | null> {
|
||||
const currentYearBalances = parsed.openingBalances.filter((b) => b.yearIndex === 0)
|
||||
|
||||
@@ -177,11 +252,8 @@ async function createOpeningBalanceEntry(
|
||||
|
||||
for (const balance of currentYearBalances) {
|
||||
const targetAccount = accountMap.get(balance.account)
|
||||
if (!targetAccount) {
|
||||
continue // Skip unmapped accounts
|
||||
}
|
||||
if (!targetAccount) continue
|
||||
|
||||
// Opening balances: positive = debit, negative = credit
|
||||
if (balance.amount > 0) {
|
||||
lines.push({
|
||||
account_number: targetAccount,
|
||||
@@ -203,27 +275,21 @@ async function createOpeningBalanceEntry(
|
||||
return null
|
||||
}
|
||||
|
||||
// Check if balanced
|
||||
const totalDebit = lines.reduce((sum, l) => sum + l.debit_amount, 0)
|
||||
const totalCredit = lines.reduce((sum, l) => sum + l.credit_amount, 0)
|
||||
const diff = Math.abs(totalDebit - totalCredit)
|
||||
|
||||
// If not balanced, add an adjustment line to equity
|
||||
if (diff > 0.01) {
|
||||
const adjustment = totalDebit - totalCredit
|
||||
if (adjustment > 0) {
|
||||
// Add explicit rounding adjustment if needed (pre-validated by caller, <= 1 SEK)
|
||||
if (Math.abs(roundingAdjustment) > 0.01) {
|
||||
if (roundingAdjustment > 0) {
|
||||
lines.push({
|
||||
account_number: '2099', // Årets resultat (or similar equity account)
|
||||
account_number: '2099',
|
||||
debit_amount: 0,
|
||||
credit_amount: adjustment,
|
||||
line_description: 'Balanseringsdifferens',
|
||||
credit_amount: roundingAdjustment,
|
||||
line_description: `Avrundningsdifferens vid SIE-import, ${roundingAdjustment} SEK`,
|
||||
})
|
||||
} else {
|
||||
lines.push({
|
||||
account_number: '2099',
|
||||
debit_amount: Math.abs(adjustment),
|
||||
debit_amount: Math.abs(roundingAdjustment),
|
||||
credit_amount: 0,
|
||||
line_description: 'Balanseringsdifferens',
|
||||
line_description: `Avrundningsdifferens vid SIE-import, ${roundingAdjustment} SEK`,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -253,15 +319,58 @@ async function importVouchers(
|
||||
parsed: ParsedSIEFile,
|
||||
accountMap: Map<string, string>,
|
||||
voucherSeries: string
|
||||
): Promise<{ created: number; ids: string[]; errors: string[] }> {
|
||||
): Promise<{
|
||||
created: number
|
||||
ids: string[]
|
||||
errors: string[]
|
||||
skippedEmpty: number
|
||||
skippedSingleLine: number
|
||||
skippedUnbalanced: number
|
||||
skippedUnmapped: number
|
||||
movementsByAccount: Map<string, number>
|
||||
skippedDetails: {
|
||||
voucherId: string
|
||||
date: string
|
||||
description: string
|
||||
reason: 'unmapped' | 'empty' | 'unbalanced' | 'zero_lines' | 'single_line'
|
||||
unmappedAccounts?: string[]
|
||||
balanceDiff?: number
|
||||
totalDebit?: number
|
||||
totalCredit?: number
|
||||
sourceLines?: { account: string; amount: number }[]
|
||||
mappedLineCount?: number
|
||||
originalLineCount?: number
|
||||
}[]
|
||||
voucherNumberMapping: Array<{ sourceId: string; targetNumber: number }>
|
||||
}> {
|
||||
const results = {
|
||||
created: 0,
|
||||
ids: [] as string[],
|
||||
errors: [] as string[],
|
||||
skippedEmpty: 0,
|
||||
skippedSingleLine: 0,
|
||||
skippedUnbalanced: 0,
|
||||
skippedUnmapped: 0,
|
||||
movementsByAccount: new Map<string, number>(),
|
||||
skippedDetails: [] as {
|
||||
voucherId: string
|
||||
date: string
|
||||
description: string
|
||||
reason: 'unmapped' | 'empty' | 'unbalanced' | 'zero_lines' | 'single_line'
|
||||
unmappedAccounts?: string[]
|
||||
balanceDiff?: number
|
||||
totalDebit?: number
|
||||
totalCredit?: number
|
||||
sourceLines?: { account: string; amount: number }[]
|
||||
mappedLineCount?: number
|
||||
originalLineCount?: number
|
||||
}[],
|
||||
voucherNumberMapping: [] as Array<{ sourceId: string; targetNumber: number }>,
|
||||
}
|
||||
|
||||
// Pre-filter and prepare all valid vouchers
|
||||
interface PreparedVoucher {
|
||||
sourceId: string
|
||||
date: string
|
||||
description: string
|
||||
lines: { account_number: string; debit_amount: number; credit_amount: number; line_description: string | null }[]
|
||||
@@ -272,15 +381,14 @@ async function importVouchers(
|
||||
for (const voucher of parsed.vouchers) {
|
||||
const lines: PreparedVoucher['lines'] = []
|
||||
let hasUnmappedAccount = false
|
||||
const unmappedAccountSet = new Set<string>()
|
||||
|
||||
for (const line of voucher.lines) {
|
||||
const targetAccount = accountMap.get(line.account)
|
||||
|
||||
if (!targetAccount) {
|
||||
hasUnmappedAccount = true
|
||||
results.errors.push(
|
||||
`Voucher ${voucher.series}${voucher.number}: Unmapped account ${line.account}`
|
||||
)
|
||||
unmappedAccountSet.add(line.account)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -300,30 +408,117 @@ async function importVouchers(
|
||||
line_description: line.description || null,
|
||||
})
|
||||
}
|
||||
// Note: lines with amount === 0 are silently dropped
|
||||
}
|
||||
|
||||
// Skip vouchers with unmapped accounts or too few lines
|
||||
if (hasUnmappedAccount || lines.length < 2) {
|
||||
const voucherId = `${voucher.series}${voucher.number}`
|
||||
const voucherDate = formatDate(voucher.date)
|
||||
|
||||
// Skip vouchers with unmapped accounts
|
||||
if (hasUnmappedAccount) {
|
||||
results.skippedDetails.push({
|
||||
voucherId,
|
||||
date: voucherDate,
|
||||
description: voucher.description,
|
||||
reason: 'unmapped',
|
||||
unmappedAccounts: [...unmappedAccountSet],
|
||||
mappedLineCount: lines.length,
|
||||
originalLineCount: voucher.lines.length,
|
||||
sourceLines: voucher.lines.map(l => ({ account: l.account, amount: l.amount })),
|
||||
})
|
||||
results.skippedUnmapped++
|
||||
continue
|
||||
}
|
||||
|
||||
// Validate balance
|
||||
// Fix 3: Separate empty (0 lines) from single-line vouchers
|
||||
if (lines.length === 0) {
|
||||
results.skippedDetails.push({
|
||||
voucherId,
|
||||
date: voucherDate,
|
||||
description: voucher.description,
|
||||
reason: 'zero_lines',
|
||||
mappedLineCount: 0,
|
||||
originalLineCount: voucher.lines.length,
|
||||
sourceLines: voucher.lines.map(l => ({ account: l.account, amount: l.amount })),
|
||||
})
|
||||
results.skippedEmpty++
|
||||
continue
|
||||
}
|
||||
|
||||
if (lines.length === 1) {
|
||||
results.skippedDetails.push({
|
||||
voucherId,
|
||||
date: voucherDate,
|
||||
description: voucher.description,
|
||||
reason: 'single_line',
|
||||
mappedLineCount: 1,
|
||||
originalLineCount: voucher.lines.length,
|
||||
sourceLines: voucher.lines.map(l => ({ account: l.account, amount: l.amount })),
|
||||
})
|
||||
results.skippedSingleLine++
|
||||
continue
|
||||
}
|
||||
|
||||
// Validate balance — Fix 2: Tiered rounding with öresutjämning (3741)
|
||||
const totalDebit = lines.reduce((sum, l) => sum + l.debit_amount, 0)
|
||||
const totalCredit = lines.reduce((sum, l) => sum + l.credit_amount, 0)
|
||||
if (Math.abs(totalDebit - totalCredit) > 0.01) {
|
||||
results.errors.push(
|
||||
`Voucher ${voucher.series}${voucher.number}: Not balanced (debit: ${totalDebit}, credit: ${totalCredit})`
|
||||
)
|
||||
const balanceDiff = Math.round(Math.abs(totalDebit - totalCredit) * 100) / 100
|
||||
if (balanceDiff > 1.00) {
|
||||
// More than 1 SEK off — incomplete voucher in source system, skip
|
||||
results.skippedDetails.push({
|
||||
voucherId,
|
||||
date: voucherDate,
|
||||
description: voucher.description,
|
||||
reason: 'unbalanced',
|
||||
balanceDiff,
|
||||
totalDebit: Math.round(totalDebit * 100) / 100,
|
||||
totalCredit: Math.round(totalCredit * 100) / 100,
|
||||
mappedLineCount: lines.length,
|
||||
originalLineCount: voucher.lines.length,
|
||||
sourceLines: voucher.lines.map(l => ({ account: l.account, amount: l.amount })),
|
||||
})
|
||||
results.skippedUnbalanced++
|
||||
continue
|
||||
} else if (balanceDiff > 0.005) {
|
||||
// Rounding difference <= 1 SEK — add explicit öresutjämning line (never modify existing lines)
|
||||
const roundedDiff = Math.round((totalDebit - totalCredit) * 100) / 100
|
||||
if (roundedDiff > 0) {
|
||||
lines.push({
|
||||
account_number: '3741',
|
||||
debit_amount: 0,
|
||||
credit_amount: Math.abs(roundedDiff),
|
||||
line_description: 'Öresutjämning',
|
||||
})
|
||||
} else {
|
||||
lines.push({
|
||||
account_number: '3741',
|
||||
debit_amount: Math.abs(roundedDiff),
|
||||
credit_amount: 0,
|
||||
line_description: 'Öresutjämning',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
preparedVouchers.push({
|
||||
sourceId: voucherId,
|
||||
date: formatDate(voucher.date),
|
||||
description: voucher.description || `Import: ${voucher.series}${voucher.number}`,
|
||||
lines,
|
||||
})
|
||||
}
|
||||
|
||||
// Compute per-account net movements from vouchers that will be imported.
|
||||
// Used for UB/RES reconciliation to generate migration adjustment entries.
|
||||
for (const v of preparedVouchers) {
|
||||
for (const l of v.lines) {
|
||||
const net = l.debit_amount - l.credit_amount
|
||||
results.movementsByAccount.set(
|
||||
l.account_number,
|
||||
(results.movementsByAccount.get(l.account_number) || 0) + net
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (preparedVouchers.length === 0) {
|
||||
return results
|
||||
}
|
||||
@@ -404,6 +599,7 @@ async function importVouchers(
|
||||
if (!entryId) continue
|
||||
|
||||
const voucher = batch[i]
|
||||
const assignedNumber = currentVoucherNumber + batchStart + i
|
||||
voucher.lines.forEach((line, lineIndex) => {
|
||||
allLines.push({
|
||||
journal_entry_id: entryId,
|
||||
@@ -417,6 +613,12 @@ async function importVouchers(
|
||||
})
|
||||
})
|
||||
|
||||
// Fix 7: Capture voucher number mapping (source → target)
|
||||
results.voucherNumberMapping.push({
|
||||
sourceId: voucher.sourceId,
|
||||
targetNumber: assignedNumber,
|
||||
})
|
||||
|
||||
results.ids.push(entryId)
|
||||
results.created++
|
||||
}
|
||||
@@ -433,11 +635,254 @@ async function importVouchers(
|
||||
}
|
||||
}
|
||||
|
||||
// Update voucher sequence to reflect all assigned numbers.
|
||||
// next_voucher_number() was called once but we assigned N numbers manually,
|
||||
// so the sequence only got incremented by 1. Fix with GREATEST to avoid races.
|
||||
if (results.created > 0) {
|
||||
const highestUsed = currentVoucherNumber + preparedVouchers.length - 1
|
||||
await supabase.rpc('reserve_voucher_range', {
|
||||
p_user_id: userId,
|
||||
p_fiscal_period_id: fiscalPeriodId,
|
||||
p_series: voucherSeries,
|
||||
p_highest_used: highestUsed,
|
||||
})
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
/**
|
||||
* Record the import in the database
|
||||
* Determine if an account is balance sheet (class 1-2) or P&L (class 3-8)
|
||||
*/
|
||||
export function isBalanceSheetAccount(accountNumber: string): boolean {
|
||||
const firstDigit = parseInt(accountNumber.charAt(0), 10)
|
||||
return firstDigit >= 1 && firstDigit <= 2
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a migration adjustment entry (omföringsverifikation) to reconcile
|
||||
* imported voucher movements against the SIE file's closing balances.
|
||||
*
|
||||
* When unbalanced vouchers are skipped during import, the sum of imported
|
||||
* movements will differ from the true account balances computed by the source
|
||||
* system. This function:
|
||||
* 1. Computes expected net movements from #UB (balance sheet) and #RES (result),
|
||||
* separated by account class per Fix 8
|
||||
* 2. Compares against actual imported movements
|
||||
* 3. Books the per-account delta as a proper omföringsverifikation
|
||||
*
|
||||
* Per BFL 1999:1078 and BFNAR 2013:2, corrections must be documented through
|
||||
* verifikationer with clear descriptions. This satisfies that requirement.
|
||||
*/
|
||||
async function createMigrationAdjustmentEntry(
|
||||
supabase: SupabaseClient,
|
||||
userId: string,
|
||||
fiscalPeriodId: string,
|
||||
parsed: ParsedSIEFile,
|
||||
accountMap: Map<string, string>,
|
||||
importedMovements: Map<string, number>,
|
||||
skippedDetails: {
|
||||
voucherId: string
|
||||
date: string
|
||||
reason: string
|
||||
}[]
|
||||
): Promise<{ entryId: string | null; deltaAccounts: number; warnings: string[] }> {
|
||||
const warnings: string[] = []
|
||||
const hasUB = parsed.closingBalances.some((b) => b.yearIndex === 0)
|
||||
const hasRES = parsed.resultBalances.some((b) => b.yearIndex === 0)
|
||||
|
||||
if (!hasUB && !hasRES) {
|
||||
return { entryId: null, deltaAccounts: 0, warnings }
|
||||
}
|
||||
|
||||
// Fix 8: Separate BS/P&L reconciliation
|
||||
// For BS accounts (class 1-2): expectedMovement = UB - IB (ignore RES)
|
||||
// For P&L accounts (class 3-8): expectedMovement = RES (ignore IB/UB)
|
||||
const expectedMovements = new Map<string, number>()
|
||||
|
||||
// Process IB — only for balance sheet accounts
|
||||
for (const ib of parsed.openingBalances.filter((b) => b.yearIndex === 0)) {
|
||||
const target = accountMap.get(ib.account)
|
||||
if (!target) continue
|
||||
if (!isBalanceSheetAccount(target)) {
|
||||
// P&L account appearing in IB — likely malformed SIE
|
||||
warnings.push(`P&L-konto ${ib.account} (→${target}) förekommer i #IB — ignoreras för resultaträkning`)
|
||||
continue
|
||||
}
|
||||
expectedMovements.set(target, (expectedMovements.get(target) || 0) - ib.amount)
|
||||
}
|
||||
|
||||
// Process UB — only for balance sheet accounts
|
||||
for (const ub of parsed.closingBalances.filter((b) => b.yearIndex === 0)) {
|
||||
const target = accountMap.get(ub.account)
|
||||
if (!target) continue
|
||||
if (!isBalanceSheetAccount(target)) {
|
||||
warnings.push(`P&L-konto ${ub.account} (→${target}) förekommer i #UB — ignoreras för resultaträkning`)
|
||||
continue
|
||||
}
|
||||
expectedMovements.set(target, (expectedMovements.get(target) || 0) + ub.amount)
|
||||
}
|
||||
|
||||
// Process RES — only for P&L accounts
|
||||
for (const res of parsed.resultBalances.filter((b) => b.yearIndex === 0)) {
|
||||
const target = accountMap.get(res.account)
|
||||
if (!target) continue
|
||||
if (isBalanceSheetAccount(target)) {
|
||||
warnings.push(`Balanskonto ${res.account} (→${target}) förekommer i #RES — ignoreras för balansräkning`)
|
||||
continue
|
||||
}
|
||||
expectedMovements.set(target, (expectedMovements.get(target) || 0) + res.amount)
|
||||
}
|
||||
|
||||
// Compute per-account delta: expected - imported
|
||||
const lines: CreateJournalEntryLineInput[] = []
|
||||
const allAccounts = new Set([...expectedMovements.keys(), ...importedMovements.keys()])
|
||||
let deltaAccountCount = 0
|
||||
|
||||
for (const account of allAccounts) {
|
||||
const expected = expectedMovements.get(account) || 0
|
||||
const imported = importedMovements.get(account) || 0
|
||||
const delta = Math.round((expected - imported) * 100) / 100
|
||||
|
||||
if (Math.abs(delta) < 0.01) continue
|
||||
deltaAccountCount++
|
||||
|
||||
// Fix 4: Per-line text referencing what the adjustment concerns
|
||||
const lineDesc = `Justering konto ${account}: delta ${delta} SEK från ${skippedDetails.length} exkl. verifikationer`
|
||||
|
||||
if (delta > 0) {
|
||||
lines.push({
|
||||
account_number: account,
|
||||
debit_amount: delta,
|
||||
credit_amount: 0,
|
||||
line_description: lineDesc,
|
||||
})
|
||||
} else {
|
||||
lines.push({
|
||||
account_number: account,
|
||||
debit_amount: 0,
|
||||
credit_amount: Math.abs(delta),
|
||||
line_description: lineDesc,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (lines.length === 0) {
|
||||
return { entryId: null, deltaAccounts: 0, warnings }
|
||||
}
|
||||
|
||||
// The entry must balance. It should by construction, but verify and handle rounding.
|
||||
const totalDebit = lines.reduce((sum, l) => sum + l.debit_amount, 0)
|
||||
const totalCredit = lines.reduce((sum, l) => sum + l.credit_amount, 0)
|
||||
const balanceDiff = Math.round(Math.abs(totalDebit - totalCredit) * 100) / 100
|
||||
|
||||
if (balanceDiff > 0.005) {
|
||||
const roundedDiff = Math.round((totalDebit - totalCredit) * 100) / 100
|
||||
if (roundedDiff > 0) {
|
||||
lines.push({
|
||||
account_number: '3741',
|
||||
debit_amount: 0,
|
||||
credit_amount: Math.abs(roundedDiff),
|
||||
line_description: 'Öresutjämning omföringsverifikation',
|
||||
})
|
||||
} else {
|
||||
lines.push({
|
||||
account_number: '3741',
|
||||
debit_amount: Math.abs(roundedDiff),
|
||||
credit_amount: 0,
|
||||
line_description: 'Öresutjämning omföringsverifikation',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Date the adjustment at fiscal year end
|
||||
const fiscalYearEnd = parsed.stats.fiscalYearEnd
|
||||
const entryDate = fiscalYearEnd ? formatDate(fiscalYearEnd) : formatDate(new Date())
|
||||
|
||||
// Fix 4: Build structured description with skipped voucher details
|
||||
const skippedIds = skippedDetails.map(d => d.voucherId)
|
||||
const skippedDates = skippedDetails.map(d => d.date).sort()
|
||||
const firstId = skippedIds[0] || '?'
|
||||
const lastId = skippedIds[skippedIds.length - 1] || '?'
|
||||
const firstDate = skippedDates[0] || '?'
|
||||
const lastDate = skippedDates[skippedDates.length - 1] || '?'
|
||||
|
||||
const entry = await createJournalEntry(supabase, userId, {
|
||||
fiscal_period_id: fiscalPeriodId,
|
||||
entry_date: entryDate,
|
||||
description: `Omföringsverifikation: justering för ${skippedDetails.length} exkluderade verifikationer (${firstId}–${lastId}, ${firstDate}–${lastDate}) vid SIE-import`,
|
||||
source_type: 'import',
|
||||
voucher_series: 'M',
|
||||
lines,
|
||||
})
|
||||
|
||||
return { entryId: entry.id, deltaAccounts: deltaAccountCount, warnings }
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure a specific account exists in the user's chart of accounts.
|
||||
* Uses BAS reference for metadata when available, falls back to derivation.
|
||||
*/
|
||||
async function ensureAccountExists(
|
||||
supabase: SupabaseClient,
|
||||
userId: string,
|
||||
accountNumber: string,
|
||||
accountName: string
|
||||
): Promise<void> {
|
||||
const { data } = await supabase
|
||||
.from('chart_of_accounts')
|
||||
.select('id')
|
||||
.eq('user_id', userId)
|
||||
.eq('account_number', accountNumber)
|
||||
.single()
|
||||
|
||||
if (data) return // Already exists
|
||||
|
||||
const basRef = getBASReference(accountNumber)
|
||||
|
||||
if (basRef) {
|
||||
await supabase.from('chart_of_accounts').insert({
|
||||
user_id: userId,
|
||||
account_number: accountNumber,
|
||||
account_name: basRef.account_name,
|
||||
account_class: basRef.account_class,
|
||||
account_group: basRef.account_group,
|
||||
account_type: basRef.account_type,
|
||||
normal_balance: basRef.normal_balance,
|
||||
sru_code: basRef.sru_code ?? computeSRUCode(accountNumber),
|
||||
k2_excluded: basRef.k2_excluded,
|
||||
plan_type: 'full_bas',
|
||||
is_active: true,
|
||||
is_system_account: false,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Fallback: derive metadata from account number
|
||||
const classNum = parseInt(accountNumber.charAt(0), 10)
|
||||
const group = accountNumber.substring(0, 2)
|
||||
const accountType = classNum === 1 ? 'asset'
|
||||
: classNum === 2 ? (group === '21' ? 'untaxed_reserves' : (group === '20' ? 'equity' : 'liability'))
|
||||
: classNum === 3 ? 'revenue'
|
||||
: 'expense'
|
||||
|
||||
await supabase.from('chart_of_accounts').insert({
|
||||
user_id: userId,
|
||||
account_number: accountNumber,
|
||||
account_name: accountName,
|
||||
account_class: classNum,
|
||||
account_group: group,
|
||||
account_type: accountType,
|
||||
normal_balance: classNum <= 1 || classNum >= 4 ? 'debit' : 'credit',
|
||||
sru_code: computeSRUCode(accountNumber),
|
||||
plan_type: 'full_bas',
|
||||
is_active: true,
|
||||
is_system_account: false,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Record the import in the database and archive the SIE file to Supabase Storage.
|
||||
*/
|
||||
async function recordImport(
|
||||
supabase: SupabaseClient,
|
||||
@@ -445,7 +890,8 @@ async function recordImport(
|
||||
parsed: ParsedSIEFile,
|
||||
fileContent: string,
|
||||
filename: string,
|
||||
result: ImportResult
|
||||
result: ImportResult,
|
||||
documentation?: MigrationDocumentation
|
||||
): Promise<string> {
|
||||
const fileHash = await calculateFileHash(fileContent)
|
||||
|
||||
@@ -471,6 +917,7 @@ async function recordImport(
|
||||
fiscal_period_id: result.fiscalPeriodId,
|
||||
opening_balance_entry_id: result.openingBalanceEntryId,
|
||||
imported_at: new Date().toISOString(),
|
||||
migration_documentation: documentation ?? null,
|
||||
})
|
||||
.select('id')
|
||||
.single()
|
||||
@@ -479,6 +926,22 @@ async function recordImport(
|
||||
throw new Error(`Failed to record import: ${error?.message}`)
|
||||
}
|
||||
|
||||
// Archive the SIE file to Supabase Storage (BFL 7 kap 1-2§ retention)
|
||||
const storagePath = `${userId}/${data.id}.se`
|
||||
const fileBlob = new Blob([fileContent], { type: 'text/plain; charset=cp437' })
|
||||
const { error: uploadError } = await supabase.storage
|
||||
.from('sie-files')
|
||||
.upload(storagePath, fileBlob, { upsert: false })
|
||||
|
||||
if (uploadError) {
|
||||
console.error(`[sie-import] Failed to archive SIE file: ${uploadError.message}`)
|
||||
} else {
|
||||
await supabase
|
||||
.from('sie_imports')
|
||||
.update({ file_storage_path: storagePath })
|
||||
.eq('id', data.id)
|
||||
}
|
||||
|
||||
return data.id
|
||||
}
|
||||
|
||||
@@ -592,6 +1055,23 @@ export async function executeSIEImport(
|
||||
// Build account mapping lookup
|
||||
const accountMap = mappingsToMap(mappings)
|
||||
|
||||
// Ensure all mapped target accounts exist in chart_of_accounts.
|
||||
// The mapping contains every account referenced in the SIE file; accounts
|
||||
// that were not seeded during onboarding need to be created here so that
|
||||
// journal entry lines can link to them via account_id.
|
||||
const seenTargets = new Set<string>()
|
||||
for (const mapping of mappings) {
|
||||
if (mapping.targetAccount && !seenTargets.has(mapping.targetAccount)) {
|
||||
seenTargets.add(mapping.targetAccount)
|
||||
await ensureAccountExists(
|
||||
supabase,
|
||||
userId,
|
||||
mapping.targetAccount,
|
||||
mapping.targetName
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Create or find fiscal period
|
||||
const fiscalYearStart = parsed.stats.fiscalYearStart
|
||||
const fiscalYearEnd = parsed.stats.fiscalYearEnd
|
||||
@@ -626,53 +1106,237 @@ export async function executeSIEImport(
|
||||
result.fiscalPeriodId = existing.id
|
||||
}
|
||||
|
||||
// Import opening balances
|
||||
if (options.importOpeningBalances && parsed.openingBalances.length > 0 && result.fiscalPeriodId) {
|
||||
result.openingBalanceEntryId = await createOpeningBalanceEntry(
|
||||
supabase,
|
||||
userId,
|
||||
result.fiscalPeriodId,
|
||||
parsed,
|
||||
accountMap
|
||||
)
|
||||
// Track documentation data across import phases
|
||||
let ibRoundingAdjustment = 0
|
||||
let migrationAdjustmentInfo = { created: false, deltaAccounts: 0, entryId: null as string | null }
|
||||
let voucherNumberMapping: Array<{ sourceId: string; targetNumber: number }> = []
|
||||
let voucherStats = {
|
||||
total: parsed.vouchers.length,
|
||||
imported: 0,
|
||||
skippedUnbalanced: 0,
|
||||
skippedUnmapped: 0,
|
||||
skippedSingleLine: 0,
|
||||
skippedEmpty: 0,
|
||||
}
|
||||
const voucherSeries = options.voucherSeries || 'B'
|
||||
|
||||
if (result.openingBalanceEntryId) {
|
||||
result.journalEntriesCreated++
|
||||
result.journalEntryIds.push(result.openingBalanceEntryId)
|
||||
// Validate and import opening balances.
|
||||
//
|
||||
// IB imbalance is NORMAL in Swedish SIE files for two common reasons:
|
||||
// 1. Excluded system accounts (Fortnox 0099 etc.) carry IB balances
|
||||
// 2. Previous year's result (årets resultat) hasn't been allocated to equity
|
||||
// yet — the profit/loss is implicit, not an explicit IB on 2099
|
||||
//
|
||||
// In both cases, the correct treatment is to book the diff to 2099 with
|
||||
// explicit documentation. We never reject based on IB imbalance — the
|
||||
// original goal was to stop SILENT equity alteration, not prevent it.
|
||||
if (options.importOpeningBalances && parsed.openingBalances.length > 0 && result.fiscalPeriodId) {
|
||||
const ibValidation = validateIBBalance(parsed, accountMap)
|
||||
|
||||
if (ibValidation.lines.length > 0) {
|
||||
const absAdj = Math.abs(ibValidation.roundingAdjustment)
|
||||
|
||||
if (absAdj > 0.01) {
|
||||
ibRoundingAdjustment = ibValidation.roundingAdjustment
|
||||
|
||||
// Produce a descriptive warning explaining the source of the imbalance
|
||||
if (Math.abs(ibValidation.excludedAccountsTotal) > 0.01 && ibValidation.fileImbalance <= 1.00) {
|
||||
// File-level IB is balanced — imbalance is entirely from excluded system accounts
|
||||
result.warnings.push(
|
||||
`Exkluderade systemkonton har IB-saldon på totalt ${ibValidation.excludedAccountsTotal} SEK. ` +
|
||||
`Differensen (${ibValidation.roundingAdjustment} SEK) bokförs på konto 2099.`
|
||||
)
|
||||
} else if (ibValidation.fileImbalance > 1.00) {
|
||||
// File-level IB doesn't balance — likely unallocated årets resultat from previous year
|
||||
result.warnings.push(
|
||||
`Ingående balanser obalanserade med ${ibValidation.roundingAdjustment} SEK ` +
|
||||
`(troligen ej allokerat årets resultat från föregående räkenskapsår). ` +
|
||||
`Differensen bokförs på konto 2099 (Årets resultat).`
|
||||
)
|
||||
} else {
|
||||
// Small rounding
|
||||
result.warnings.push(
|
||||
`Avrundningsdifferens vid SIE-import: ${ibValidation.roundingAdjustment} SEK bokförd på konto 2099`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
result.openingBalanceEntryId = await createOpeningBalanceEntry(
|
||||
supabase,
|
||||
userId,
|
||||
result.fiscalPeriodId,
|
||||
parsed,
|
||||
accountMap,
|
||||
ibRoundingAdjustment
|
||||
)
|
||||
|
||||
if (result.openingBalanceEntryId) {
|
||||
result.journalEntriesCreated++
|
||||
result.journalEntryIds.push(result.openingBalanceEntryId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Import transactions (SIE4 only)
|
||||
if (options.importTransactions && parsed.vouchers.length > 0 && result.fiscalPeriodId) {
|
||||
// Detect partial-year export: if voucher dates don't span the full fiscal year,
|
||||
// the migration adjustment will produce incorrect large deltas for the missing period.
|
||||
if (parsed.vouchers.length > 0 && fiscalYearStart && fiscalYearEnd) {
|
||||
const voucherDates = parsed.vouchers.map(v => v.date.getTime())
|
||||
const earliestVoucher = new Date(Math.min(...voucherDates))
|
||||
const latestVoucher = new Date(Math.max(...voucherDates))
|
||||
|
||||
// Allow 30 days margin from fiscal year start/end for partial detection
|
||||
const msPerDay = 86400000
|
||||
const startGap = earliestVoucher.getTime() - fiscalYearStart.getTime()
|
||||
const endGap = fiscalYearEnd.getTime() - latestVoucher.getTime()
|
||||
|
||||
if (startGap > 60 * msPerDay || endGap > 60 * msPerDay) {
|
||||
result.warnings.push(
|
||||
`SIE-filen verkar innehålla ett ofullständigt räkenskapsår: verifikationer ${formatDate(earliestVoucher)}–${formatDate(latestVoucher)}, ` +
|
||||
`räkenskapsår ${formatDate(fiscalYearStart)}–${formatDate(fiscalYearEnd)}. ` +
|
||||
`Omföringsverifikationen kan bli felaktig om #UB/#RES avser hela året men verifikationerna bara täcker en del.`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure öresutjämning account 3741 exists in the user's chart
|
||||
await ensureAccountExists(supabase, userId, '3741', 'Öresutjämning vid import')
|
||||
|
||||
const voucherResults = await importVouchers(
|
||||
supabase,
|
||||
userId,
|
||||
result.fiscalPeriodId,
|
||||
parsed,
|
||||
accountMap,
|
||||
options.voucherSeries || 'B'
|
||||
voucherSeries
|
||||
)
|
||||
|
||||
result.journalEntriesCreated += voucherResults.created
|
||||
result.journalEntryIds.push(...voucherResults.ids)
|
||||
result.errors.push(...voucherResults.errors)
|
||||
voucherNumberMapping = voucherResults.voucherNumberMapping
|
||||
|
||||
// Update stats for documentation
|
||||
voucherStats = {
|
||||
total: parsed.vouchers.length,
|
||||
imported: voucherResults.created,
|
||||
skippedUnbalanced: voucherResults.skippedUnbalanced,
|
||||
skippedUnmapped: voucherResults.skippedUnmapped,
|
||||
skippedSingleLine: voucherResults.skippedSingleLine,
|
||||
skippedEmpty: voucherResults.skippedEmpty,
|
||||
}
|
||||
|
||||
// Report skipped vouchers as warnings
|
||||
const totalSkipped = voucherResults.skippedEmpty + voucherResults.skippedSingleLine + voucherResults.skippedUnbalanced + voucherResults.skippedUnmapped
|
||||
if (totalSkipped > 0) {
|
||||
const parts: string[] = []
|
||||
if (voucherResults.skippedEmpty > 0) parts.push(`${voucherResults.skippedEmpty} tomma`)
|
||||
if (voucherResults.skippedUnbalanced > 0) parts.push(`${voucherResults.skippedUnbalanced} obalanserade`)
|
||||
if (voucherResults.skippedUnmapped > 0) parts.push(`${voucherResults.skippedUnmapped} med ej mappade konton`)
|
||||
result.warnings.push(
|
||||
`${totalSkipped} verifikationer hoppades över (ofullständiga i källsystemet): ${parts.join(', ')}`
|
||||
)
|
||||
}
|
||||
|
||||
// Fix 3: Specific warning for single-line vouchers
|
||||
if (voucherResults.skippedSingleLine > 0) {
|
||||
const singleLineDetails = voucherResults.skippedDetails
|
||||
.filter(d => d.reason === 'single_line')
|
||||
.slice(0, 10)
|
||||
.map(d => d.voucherId)
|
||||
result.warnings.push(
|
||||
`${voucherResults.skippedSingleLine} enradsverifikationer hoppades över (kan vara periodiseringar/manuella justeringar): ${singleLineDetails.join(', ')}${voucherResults.skippedSingleLine > 10 ? '...' : ''}`
|
||||
)
|
||||
}
|
||||
|
||||
// Create migration adjustment entry to reconcile against UB/RES
|
||||
const totalSkippedForAdjustment = voucherResults.skippedUnbalanced + voucherResults.skippedUnmapped + voucherResults.skippedSingleLine
|
||||
if (totalSkippedForAdjustment > 0 && result.fiscalPeriodId) {
|
||||
try {
|
||||
const adjustment = await createMigrationAdjustmentEntry(
|
||||
supabase,
|
||||
userId,
|
||||
result.fiscalPeriodId,
|
||||
parsed,
|
||||
accountMap,
|
||||
voucherResults.movementsByAccount,
|
||||
voucherResults.skippedDetails
|
||||
)
|
||||
|
||||
result.warnings.push(...adjustment.warnings)
|
||||
|
||||
if (adjustment.entryId) {
|
||||
result.journalEntriesCreated++
|
||||
result.journalEntryIds.push(adjustment.entryId)
|
||||
result.warnings.push(
|
||||
`Migreringsjustering skapad: ${adjustment.deltaAccounts} konton justerade för att matcha UB/RES från källsystemet`
|
||||
)
|
||||
migrationAdjustmentInfo = {
|
||||
created: true,
|
||||
deltaAccounts: adjustment.deltaAccounts,
|
||||
entryId: adjustment.entryId,
|
||||
}
|
||||
}
|
||||
} catch (adjustmentError) {
|
||||
console.error('[sie-import] Failed to create migration adjustment entry:', adjustmentError)
|
||||
result.warnings.push(
|
||||
'Kunde inte skapa migreringsjustering — kontrollera saldon manuellt mot källsystemet'
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Save account mappings for future use
|
||||
await saveMappings(supabase, userId, mappings)
|
||||
|
||||
// Record the import
|
||||
// Fix 6: Generate systemdokumentation (MigrationDocumentation)
|
||||
const mappingStats = getMappingStats(mappings)
|
||||
const documentation: MigrationDocumentation = {
|
||||
sourceSystem: parsed.header.program,
|
||||
sourceVersion: parsed.header.programVersion,
|
||||
sieType: parsed.header.sieType,
|
||||
generatedDate: parsed.header.generatedDate ? formatDate(parsed.header.generatedDate) : null,
|
||||
fiscalYear: {
|
||||
start: formatDate(fiscalYearStart),
|
||||
end: formatDate(fiscalYearEnd),
|
||||
},
|
||||
importedAt: new Date().toISOString(),
|
||||
importedBy: userId,
|
||||
accountMappings: {
|
||||
total: mappingStats.total,
|
||||
exact: mappingStats.exact,
|
||||
basRange: mappingStats.basRange,
|
||||
manual: mappingStats.manual,
|
||||
unmapped: mappingStats.unmapped,
|
||||
},
|
||||
vouchers: voucherStats,
|
||||
openingBalanceRounding: ibRoundingAdjustment !== 0 ? ibRoundingAdjustment : null,
|
||||
migrationAdjustment: migrationAdjustmentInfo,
|
||||
voucherSeriesUsed: voucherSeries,
|
||||
voucherNumberRange: voucherNumberMapping.length > 0
|
||||
? {
|
||||
from: voucherNumberMapping[0].targetNumber,
|
||||
to: voucherNumberMapping[voucherNumberMapping.length - 1].targetNumber,
|
||||
}
|
||||
: null,
|
||||
voucherNumberMapping,
|
||||
}
|
||||
|
||||
// Set success before recording so recordImport() sees correct status
|
||||
result.success = result.errors.length === 0
|
||||
|
||||
// Record the import with documentation
|
||||
result.importId = await recordImport(
|
||||
supabase,
|
||||
userId,
|
||||
parsed,
|
||||
options.fileContent,
|
||||
options.filename,
|
||||
result
|
||||
result,
|
||||
documentation
|
||||
)
|
||||
|
||||
result.success = result.errors.length === 0
|
||||
|
||||
// Add warnings for any issues
|
||||
for (const issue of parsed.issues) {
|
||||
if (issue.severity === 'warning') {
|
||||
|
||||
+124
-29
@@ -24,21 +24,43 @@ import type {
|
||||
ValidationResult,
|
||||
} from './types'
|
||||
|
||||
// CP437 to UTF-8 mapping for Swedish characters
|
||||
// CP437 was the standard encoding for DOS/early Windows
|
||||
// CP437 to UTF-8 mapping — full 0x80-0x9F range
|
||||
// CP437 was the standard encoding for DOS/early Windows (used by SIE #FORMAT PC8)
|
||||
const CP437_MAP: Record<number, string> = {
|
||||
// 0x80-0x8F
|
||||
0x80: 'Ç', // Ç
|
||||
0x81: 'ü', // ü
|
||||
0x82: 'é', // é
|
||||
0x83: 'â', // â
|
||||
0x84: 'ä', // ä
|
||||
0x85: 'à', // à
|
||||
0x86: 'å', // å
|
||||
0x87: 'ç', // ç
|
||||
0x88: 'ê', // ê
|
||||
0x89: 'ë', // ë
|
||||
0x8a: 'è', // è
|
||||
0x8b: 'ï', // ï
|
||||
0x8c: 'î', // î
|
||||
0x8d: 'ì', // ì
|
||||
0x8e: 'Ä', // Ä
|
||||
0x8f: 'Å', // Å
|
||||
0x99: 'Ö', // Ö
|
||||
0x84: 'ä', // ä
|
||||
0x86: 'å', // å
|
||||
0x94: 'ö', // ö
|
||||
0x81: 'ü', // ü
|
||||
0x9a: 'Ü', // Ü
|
||||
0x92: 'Æ', // Æ (not common but in CP437)
|
||||
// 0x90-0x9F
|
||||
0x90: 'É', // É
|
||||
0x91: 'æ', // æ
|
||||
0x92: 'Æ', // Æ
|
||||
0x93: 'ô', // ô
|
||||
0x94: 'ö', // ö
|
||||
0x95: 'ò', // ò
|
||||
0x96: 'û', // û
|
||||
0x97: 'ù', // ù
|
||||
0x98: 'ÿ', // ÿ
|
||||
0x99: 'Ö', // Ö
|
||||
0x9a: 'Ü', // Ü
|
||||
0x9b: 'ø', // ø (Norwegian)
|
||||
0x9c: '£', // £
|
||||
0x9d: 'Ø', // Ø (Norwegian)
|
||||
0x9b: 'ø', // ø
|
||||
0x9e: '×', // ×
|
||||
0x9f: 'ƒ', // ƒ
|
||||
}
|
||||
|
||||
// Windows-1252 bytes for Swedish characters (superset of ISO-8859-1)
|
||||
@@ -53,7 +75,16 @@ const WIN1252_SWEDISH_BYTES = new Set([
|
||||
])
|
||||
|
||||
/**
|
||||
* Detect the encoding of a SIE file by looking for Swedish characters
|
||||
* Detect the encoding of a SIE file by looking for Swedish characters.
|
||||
*
|
||||
* Strategy:
|
||||
* 1. UTF-8 BOM → utf8
|
||||
* 2. `#FORMAT PC8` in raw bytes → cp437 (SIE standard header for CP437)
|
||||
* 3. Range-based discrimination: CP437 Swedish chars live in 0x80-0x9F,
|
||||
* Windows-1252 Swedish chars live in 0xC0-0xFF. These ranges don't overlap,
|
||||
* so presence in one range rules out the other.
|
||||
* 4. UTF-8 multi-byte sequences (0xC3 + continuation) are detected with proper
|
||||
* skipping of continuation bytes to avoid false CP437 counts.
|
||||
*/
|
||||
export function detectEncoding(buffer: ArrayBuffer): SIEEncoding {
|
||||
const bytes = new Uint8Array(buffer)
|
||||
@@ -63,11 +94,27 @@ export function detectEncoding(buffer: ArrayBuffer): SIEEncoding {
|
||||
return 'utf8'
|
||||
}
|
||||
|
||||
// Look for encoding-specific Swedish characters in first 1000 bytes
|
||||
const sampleSize = Math.min(bytes.length, 1000)
|
||||
let cp437Count = 0
|
||||
let utf8Count = 0
|
||||
let win1252Count = 0
|
||||
// Check for #FORMAT PC8 in the first 500 bytes (ASCII-safe, works regardless of encoding)
|
||||
const headerSize = Math.min(bytes.length, 500)
|
||||
const FORMAT_PC8 = [0x23, 0x46, 0x4f, 0x52, 0x4d, 0x41, 0x54, 0x20, 0x50, 0x43, 0x38]
|
||||
for (let i = 0; i <= headerSize - FORMAT_PC8.length; i++) {
|
||||
let match = true
|
||||
for (let j = 0; j < FORMAT_PC8.length; j++) {
|
||||
if (bytes[i + j] !== FORMAT_PC8[j]) {
|
||||
match = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if (match) {
|
||||
return 'cp437'
|
||||
}
|
||||
}
|
||||
|
||||
// Scan sample for encoding-specific byte ranges
|
||||
const sampleSize = Math.min(bytes.length, 2000)
|
||||
let cp437Count = 0 // Swedish chars in 0x80-0x9F (CP437 range)
|
||||
let utf8Count = 0 // Valid UTF-8 multi-byte Swedish sequences
|
||||
let win1252Count = 0 // Swedish chars in 0xC0-0xFF (Win-1252 range)
|
||||
|
||||
for (let i = 0; i < sampleSize; i++) {
|
||||
const byte = bytes[i]
|
||||
@@ -88,8 +135,20 @@ export function detectEncoding(buffer: ArrayBuffer): SIEEncoding {
|
||||
const nextByte = bytes[i + 1]
|
||||
if ([0x84, 0x85, 0x96, 0xa4, 0xa5, 0xb6].includes(nextByte)) {
|
||||
utf8Count++
|
||||
i++ // Skip continuation byte to avoid false CP437 count (e.g. 0x84 = ä in CP437)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// CP437 Swedish chars live in 0x80-0x9F
|
||||
if (byte >= 0x80 && byte <= 0x9f && CP437_MAP[byte]) {
|
||||
cp437Count++
|
||||
}
|
||||
|
||||
// Windows-1252 Swedish chars live in 0xC0-0xFF
|
||||
if (WIN1252_SWEDISH_BYTES.has(byte)) {
|
||||
win1252Count++
|
||||
}
|
||||
}
|
||||
|
||||
if (utf8Count > cp437Count && utf8Count > win1252Count) return 'utf8'
|
||||
@@ -277,6 +336,7 @@ export function parseSIEFile(content: string): ParsedSIEFile {
|
||||
address: null,
|
||||
fiscalYears: [],
|
||||
currency: 'SEK',
|
||||
kontoPlanType: null,
|
||||
}
|
||||
|
||||
const accounts: SIEAccount[] = []
|
||||
@@ -377,6 +437,10 @@ export function parseSIEFile(content: string): ParsedSIEFile {
|
||||
header.currency = parseStringField(fields[1]) || 'SEK'
|
||||
break
|
||||
|
||||
case 'KPTYP':
|
||||
header.kontoPlanType = parseStringField(fields[1])
|
||||
break
|
||||
|
||||
case 'RAR': {
|
||||
// #RAR yearIndex start end
|
||||
const yearIndex = parseInt(fields[1], 10)
|
||||
@@ -516,10 +580,16 @@ export function parseSIEFile(content: string): ParsedSIEFile {
|
||||
break
|
||||
}
|
||||
|
||||
case 'TRANS': {
|
||||
// #TRANS accountNumber {objectList} amount [date] [description] [quantity] [signature]
|
||||
case 'TRANS':
|
||||
case 'RTRANS':
|
||||
case 'BTRANS': {
|
||||
// #TRANS/#RTRANS/#BTRANS accountNumber {objectList} amount [date] [description] [quantity] [signature]
|
||||
// BTRANS = Added/corrected transaction lines (part of the voucher)
|
||||
// RTRANS = Removed/reversed transaction lines (amounts already have correct sign)
|
||||
// All three must be included for vouchers to balance correctly.
|
||||
// Fortnox/Bokio/Visma only emit #TRANS — this is a no-op for those providers.
|
||||
if (!currentVoucher) {
|
||||
addIssue(issues, 'error', lineNum, 'TRANS outside of VER block', tag)
|
||||
addIssue(issues, 'error', lineNum, `${tag} outside of VER block`, tag)
|
||||
break
|
||||
}
|
||||
|
||||
@@ -534,7 +604,7 @@ export function parseSIEFile(content: string): ParsedSIEFile {
|
||||
|
||||
const transAmountStr = fields[fieldIndex]
|
||||
if (!transAmountStr || transAmountStr.trim() === '') {
|
||||
addIssue(issues, 'warning', lineNum, 'Missing amount in #TRANS, skipping line', tag)
|
||||
addIssue(issues, 'warning', lineNum, `Missing amount in #${tag}, skipping line`, tag)
|
||||
break
|
||||
}
|
||||
|
||||
@@ -563,17 +633,9 @@ export function parseSIEFile(content: string): ParsedSIEFile {
|
||||
break
|
||||
}
|
||||
|
||||
case 'RTRANS':
|
||||
case 'BTRANS':
|
||||
// BTRANS = Balance transactions (preliminary/carried-forward balances)
|
||||
// RTRANS = Reversed/corrected transactions
|
||||
// These are supplementary lines and should NOT be included in balance validation
|
||||
// or imported as regular transaction lines. Skip them.
|
||||
break
|
||||
|
||||
default:
|
||||
// Unknown tag - add info issue for notable ones
|
||||
if (!['KSUMMA', 'BKOD', 'TAXAR', 'OMFATTN', 'KPTYP', 'DIM', 'OBJEKT', 'OIB', 'OUB', 'PBUDGET', 'PSALDO'].includes(tag)) {
|
||||
if (!['KSUMMA', 'BKOD', 'TAXAR', 'OMFATTN', 'DIM', 'OBJEKT', 'OIB', 'OUB', 'PBUDGET', 'PSALDO'].includes(tag)) {
|
||||
addIssue(issues, 'info', lineNum, `Unknown tag: #${tag}`, tag)
|
||||
}
|
||||
}
|
||||
@@ -588,6 +650,28 @@ export function parseSIEFile(content: string): ParsedSIEFile {
|
||||
}
|
||||
}
|
||||
|
||||
// Collect accounts referenced in balances and vouchers but missing from #KONTO
|
||||
const definedAccountNumbers = new Set(accounts.map((a) => a.number))
|
||||
const referencedAccounts = new Set<string>()
|
||||
|
||||
for (const balance of [...openingBalances, ...closingBalances, ...resultBalances]) {
|
||||
if (balance.account && !definedAccountNumbers.has(balance.account)) {
|
||||
referencedAccounts.add(balance.account)
|
||||
}
|
||||
}
|
||||
for (const voucher of vouchers) {
|
||||
for (const line of voucher.lines) {
|
||||
if (line.account && !definedAccountNumbers.has(line.account)) {
|
||||
referencedAccounts.add(line.account)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const accountNumber of referencedAccounts) {
|
||||
accounts.push({ number: accountNumber, name: '' })
|
||||
addIssue(issues, 'info', 0, `Account ${accountNumber} added from transaction data (not in #KONTO)`)
|
||||
}
|
||||
|
||||
// Calculate statistics
|
||||
const currentFiscalYear = header.fiscalYears.find((fy) => fy.yearIndex === 0)
|
||||
const totalTransactionLines = vouchers.reduce((sum, v) => sum + v.lines.length, 0)
|
||||
@@ -637,6 +721,17 @@ export function validateSIEFile(parsed: ParsedSIEFile): ValidationResult {
|
||||
warnings.push('No accounts found (#KONTO)')
|
||||
}
|
||||
|
||||
// Warn if non-BAS kontoplan declared — mapping logic assumes BAS number ranges
|
||||
if (parsed.header.kontoPlanType) {
|
||||
const planType = parsed.header.kontoPlanType.toUpperCase()
|
||||
const isBAS = planType.startsWith('BAS') || planType === 'EUBAS' || planType === 'EU-BAS'
|
||||
if (!isBAS) {
|
||||
warnings.push(
|
||||
`Kontoplanstyp "${parsed.header.kontoPlanType}" är inte BAS-baserad. Alla kontomappningar bör granskas manuellt.`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Check for unbalanced vouchers
|
||||
for (const voucher of parsed.vouchers) {
|
||||
const total = voucher.lines.reduce((sum, l) => sum + l.amount, 0)
|
||||
|
||||
@@ -39,6 +39,7 @@ export interface SIEHeader {
|
||||
// Fiscal year info
|
||||
fiscalYears: FiscalYearInfo[] // #RAR
|
||||
currency: string // #VALUTA (default SEK)
|
||||
kontoPlanType: string | null // #KPTYP (e.g. 'BAS95', 'BAS96', 'EUBAS')
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -189,6 +190,7 @@ export interface SIEImport {
|
||||
fiscal_period_id: string | null
|
||||
opening_balance_entry_id: string | null
|
||||
imported_at: string | null
|
||||
migration_documentation: MigrationDocumentation | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
@@ -284,10 +286,65 @@ export interface ImportPreview {
|
||||
lowConfidence: number
|
||||
}
|
||||
|
||||
// Source-system accounts excluded from import (e.g. Fortnox 0099)
|
||||
excludedSystemAccounts: { number: string; name: string }[]
|
||||
|
||||
// Issues to review
|
||||
issues: ParseIssue[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Structured systemdokumentation per BFNAR 2013:2 Chapter 9.
|
||||
* Generated at the end of a SIE import and stored in sie_imports.migration_documentation.
|
||||
*/
|
||||
export interface MigrationDocumentation {
|
||||
// Source system info
|
||||
sourceSystem: string | null // from #PROGRAM
|
||||
sourceVersion: string | null
|
||||
sieType: number
|
||||
generatedDate: string | null // from #GEN
|
||||
|
||||
// Import scope
|
||||
fiscalYear: { start: string; end: string }
|
||||
importedAt: string
|
||||
importedBy: string // user_id
|
||||
|
||||
// Account mapping
|
||||
accountMappings: {
|
||||
total: number
|
||||
exact: number
|
||||
basRange: number
|
||||
manual: number
|
||||
unmapped: number
|
||||
}
|
||||
|
||||
// Voucher statistics
|
||||
vouchers: {
|
||||
total: number
|
||||
imported: number
|
||||
skippedUnbalanced: number
|
||||
skippedUnmapped: number
|
||||
skippedSingleLine: number
|
||||
skippedEmpty: number
|
||||
}
|
||||
|
||||
// Adjustments
|
||||
openingBalanceRounding: number | null // SEK amount if any
|
||||
migrationAdjustment: {
|
||||
created: boolean
|
||||
deltaAccounts: number
|
||||
entryId: string | null
|
||||
}
|
||||
|
||||
// Voucher number mapping
|
||||
voucherSeriesUsed: string
|
||||
voucherNumberRange: { from: number; to: number } | null
|
||||
voucherNumberMapping: Array<{
|
||||
sourceId: string // e.g. "A1"
|
||||
targetNumber: number
|
||||
}>
|
||||
}
|
||||
|
||||
/**
|
||||
* Wizard step state
|
||||
*/
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 45 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.9 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.6 KiB |
@@ -0,0 +1,9 @@
|
||||
<svg version="1.2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1550 1550" width="1550" height="1550">
|
||||
<title>fortnox-brand-symbol-svg</title>
|
||||
<path fill="#003824" d="m774.4 302.8h243.3c17.6 0 27.1 9.5 27.1 27.1v129.8c0 17.5-9.4 26.9-27.1 26.9h-243.3c-56.7 0-94.5 37.9-94.5 94.6v177h337.8c17.6 0 27.1 9.5 27.1 27v129.8c0 17.6-9.4 27.1-27.1 27.1h-337.8v252.6c0 17.6-9.5 27.1-27.1 27.1h-135.1c-17.6 0-27.1-9.4-27.1-27.1v-608.1c0-168.9 114.9-283.9 283.8-283.9"/>
|
||||
<g>
|
||||
<path fill="#007533" d="m774.8 1549.9c-148.3 0-294.9-42.5-421.6-124.7-157-101.7-271.7-256.7-323.1-436.3-51-177.8-36.9-367.9 39.6-535.2 12.3-26.8 44-38.6 70.8-26.3 26.7 12.2 38.6 43.9 26.2 70.7-65.9 144.2-78 308.2-34.1 461.5 44.3 154.8 143.3 288.5 278.6 376.2 160.5 104.1 358.5 134.3 543.2 82.8 28.4-7.8 57.8 8.8 65.7 37.1 7.8 28.4-8.8 57.8-37.1 65.6-68.5 19.2-138.5 28.5-208.3 28.5z"/>
|
||||
<path fill="#ffc200" d="m1162.9 1436.8c-17.6 0-34.9-8.8-45-24.8-15.8-24.8-8.5-57.8 16.3-73.6 81.1-51.7 148.9-118.8 201.3-199.7 200.5-309.2 112.1-723.8-197-924.3-10.1-6.6-21.2-13.2-34.7-20.9-25.6-14.5-34.7-47-20.1-72.7 14.5-25.6 47-34.7 72.7-20.1 15.5 8.7 28.3 16.4 40.2 24.1 358.5 232.5 460.9 713.4 228.4 1071.9-60.8 93.8-139.4 171.7-233.5 231.6-8.9 5.7-18.8 8.4-28.6 8.4z"/>
|
||||
<path fill="#00db33" d="m234 350.8c-12.5 0-25.2-4.3-35.2-13.2-22.2-19.5-24.3-53.2-4.8-75.3 186.3-211.8 473.6-305.2 749.4-243.7 28.8 6.4 46.9 34.9 40.5 63.7-6.4 28.7-34.9 46.8-63.7 40.4-237.9-53-485.5 27.6-646.3 210-10.5 11.9-25.2 18.1-39.9 18.1z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.3 KiB |
@@ -0,0 +1,284 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
/**
|
||||
* Backfill missing accounts into chart_of_accounts for ALL tenants.
|
||||
*
|
||||
* Finds accounts referenced by journal_entry_lines but not in chart_of_accounts,
|
||||
* resolves metadata from BAS reference (primary) or SIE account mappings (fallback),
|
||||
* and inserts them.
|
||||
*
|
||||
* Usage: npx tsx scripts/backfill-import-accounts.ts [--dry-run]
|
||||
*/
|
||||
|
||||
import { config } from 'dotenv'
|
||||
config({ path: '.env.local' })
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
import { getBASReference } from '../lib/bookkeeping/bas-reference'
|
||||
import { computeSRUCode } from '../lib/bookkeeping/bas-data/sru-mapping'
|
||||
|
||||
const DRY_RUN = process.argv.includes('--dry-run')
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL
|
||||
const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY
|
||||
|
||||
if (!supabaseUrl || !serviceRoleKey) {
|
||||
console.error('Missing NEXT_PUBLIC_SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY in .env')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const supabase = createClient(supabaseUrl, serviceRoleKey)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Non-BAS account overrides (company-specific sub-accounts not in BAS 2026)
|
||||
// Only used when BAS reference and SIE source_name both miss.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface AccountOverride {
|
||||
account_name: string
|
||||
account_type?: 'asset' | 'liability' | 'equity' | 'revenue' | 'expense' | 'untaxed_reserves'
|
||||
normal_balance?: 'debit' | 'credit'
|
||||
}
|
||||
|
||||
const NON_BAS_OVERRIDES: Record<string, AccountOverride> = {
|
||||
'1402': { account_name: 'Förråd av varor' },
|
||||
'1799': { account_name: 'Observationskonto' },
|
||||
'2662': {
|
||||
account_name: 'Kortfristig skuld Le comptoir',
|
||||
account_type: 'liability',
|
||||
normal_balance: 'credit',
|
||||
},
|
||||
'3041': { account_name: 'Försäljning tjänster 25% Sverige' },
|
||||
'3051': { account_name: 'Försäljning varor 25% Sverige' },
|
||||
'3052': { account_name: 'Försäljning varor 12% Sverige' },
|
||||
'4020': { account_name: 'Alkoholskatt' },
|
||||
'4056': { account_name: 'Inköp varor 25% EU' },
|
||||
'4057': { account_name: 'Inköp varor 12% EU' },
|
||||
'4071': { account_name: 'Lagerkostnader' },
|
||||
'4072': { account_name: 'Inköp frakt 25% EU' },
|
||||
'4990': { account_name: 'Lagerförändring' },
|
||||
'4992': { account_name: 'Varor på väg' },
|
||||
'6561': { account_name: 'GS1' },
|
||||
'8300': { account_name: 'Ränteintäkter (gruppkonto)' },
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function deriveAccountType(accountNumber: string): 'asset' | 'liability' | 'equity' | 'revenue' | 'expense' | 'untaxed_reserves' {
|
||||
const classNum = parseInt(accountNumber.charAt(0), 10)
|
||||
const group = accountNumber.substring(0, 2)
|
||||
|
||||
if (classNum === 1) return 'asset'
|
||||
if (classNum === 2) {
|
||||
if (group === '20') return 'equity'
|
||||
if (group === '21') return 'untaxed_reserves'
|
||||
return 'liability'
|
||||
}
|
||||
if (classNum === 3) return 'revenue'
|
||||
return 'expense'
|
||||
}
|
||||
|
||||
function deriveNormalBalance(accountNumber: string): 'debit' | 'credit' {
|
||||
const classNum = parseInt(accountNumber.charAt(0), 10)
|
||||
return classNum <= 1 || classNum >= 4 ? 'debit' : 'credit'
|
||||
}
|
||||
|
||||
async function getUsedAccountNumbers(userId: string): Promise<Set<string>> {
|
||||
const usedSet = new Set<string>()
|
||||
const PAGE_SIZE = 1000
|
||||
let offset = 0
|
||||
let hasMore = true
|
||||
|
||||
while (hasMore) {
|
||||
const { data: batch, error } = await supabase
|
||||
.from('journal_entry_lines')
|
||||
.select('account_number, journal_entries!inner(user_id)')
|
||||
.eq('journal_entries.user_id', userId)
|
||||
.range(offset, offset + PAGE_SIZE - 1)
|
||||
|
||||
if (error) throw new Error(`Failed to fetch lines for ${userId}: ${error.message}`)
|
||||
|
||||
for (const row of batch ?? []) {
|
||||
usedSet.add(row.account_number)
|
||||
}
|
||||
|
||||
hasMore = (batch?.length ?? 0) === PAGE_SIZE
|
||||
offset += PAGE_SIZE
|
||||
}
|
||||
|
||||
return usedSet
|
||||
}
|
||||
|
||||
async function getSIESourceNames(userId: string): Promise<Map<string, string>> {
|
||||
const { data, error } = await supabase
|
||||
.from('sie_account_mappings')
|
||||
.select('source_account, source_name')
|
||||
.eq('user_id', userId)
|
||||
|
||||
if (error) throw new Error(`Failed to fetch SIE mappings for ${userId}: ${error.message}`)
|
||||
|
||||
const map = new Map<string, string>()
|
||||
for (const row of data ?? []) {
|
||||
map.set(row.source_account, row.source_name)
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Per-tenant backfill
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function backfillForUser(userId: string): Promise<number> {
|
||||
console.log(`\n--- User ${userId} ---`)
|
||||
|
||||
// Get existing accounts
|
||||
const { data: existingAccounts, error: existingError } = await supabase
|
||||
.from('chart_of_accounts')
|
||||
.select('account_number')
|
||||
.eq('user_id', userId)
|
||||
|
||||
if (existingError) throw new Error(`Failed to fetch accounts: ${existingError.message}`)
|
||||
const existingSet = new Set(existingAccounts?.map(a => a.account_number) ?? [])
|
||||
|
||||
// Get used account numbers from journal entries
|
||||
const usedSet = await getUsedAccountNumbers(userId)
|
||||
const missingAccounts = [...usedSet].filter(num => !existingSet.has(num)).sort()
|
||||
|
||||
if (missingAccounts.length === 0) {
|
||||
console.log(' No missing accounts.')
|
||||
return 0
|
||||
}
|
||||
|
||||
console.log(` Found ${missingAccounts.length} missing accounts`)
|
||||
|
||||
// Get SIE source names as fallback for account naming
|
||||
const sieNames = await getSIESourceNames(userId)
|
||||
|
||||
// Build insert rows
|
||||
const rows = missingAccounts.map(accountNumber => {
|
||||
const basRef = getBASReference(accountNumber)
|
||||
|
||||
if (basRef) {
|
||||
return {
|
||||
user_id: userId,
|
||||
account_number: accountNumber,
|
||||
account_name: basRef.account_name,
|
||||
account_class: basRef.account_class,
|
||||
account_group: basRef.account_group,
|
||||
account_type: basRef.account_type,
|
||||
normal_balance: basRef.normal_balance,
|
||||
sru_code: basRef.sru_code ?? computeSRUCode(accountNumber),
|
||||
k2_excluded: basRef.k2_excluded,
|
||||
plan_type: 'full_bas' as const,
|
||||
is_active: true,
|
||||
is_system_account: false,
|
||||
}
|
||||
}
|
||||
|
||||
// Check hardcoded overrides (for company-specific accounts with known metadata)
|
||||
const override = NON_BAS_OVERRIDES[accountNumber]
|
||||
if (override) {
|
||||
const accountType = override.account_type ?? deriveAccountType(accountNumber)
|
||||
const normalBalance = override.normal_balance ?? deriveNormalBalance(accountNumber)
|
||||
const classNum = parseInt(accountNumber.charAt(0), 10)
|
||||
return {
|
||||
user_id: userId,
|
||||
account_number: accountNumber,
|
||||
account_name: override.account_name,
|
||||
account_class: classNum,
|
||||
account_group: accountNumber.substring(0, 2),
|
||||
account_type: accountType,
|
||||
normal_balance: normalBalance,
|
||||
sru_code: computeSRUCode(accountNumber),
|
||||
k2_excluded: false,
|
||||
plan_type: 'full_bas' as const,
|
||||
is_active: true,
|
||||
is_system_account: false,
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: use SIE source_name if available, otherwise derive
|
||||
const sieName = sieNames.get(accountNumber)
|
||||
const classNum = parseInt(accountNumber.charAt(0), 10)
|
||||
if (sieName) {
|
||||
console.warn(` INFO: Account ${accountNumber} not in BAS — using SIE name: "${sieName}"`)
|
||||
} else {
|
||||
console.warn(` WARNING: Account ${accountNumber} not in BAS or SIE — deriving all metadata`)
|
||||
}
|
||||
|
||||
return {
|
||||
user_id: userId,
|
||||
account_number: accountNumber,
|
||||
account_name: sieName ?? `Konto ${accountNumber}`,
|
||||
account_class: classNum,
|
||||
account_group: accountNumber.substring(0, 2),
|
||||
account_type: deriveAccountType(accountNumber),
|
||||
normal_balance: deriveNormalBalance(accountNumber),
|
||||
sru_code: computeSRUCode(accountNumber),
|
||||
k2_excluded: false,
|
||||
plan_type: 'full_bas' as const,
|
||||
is_active: true,
|
||||
is_system_account: false,
|
||||
}
|
||||
})
|
||||
|
||||
// Log summary
|
||||
const fromBAS = rows.filter(r => getBASReference(r.account_number)).length
|
||||
const fromOverride = rows.filter(r => !getBASReference(r.account_number) && NON_BAS_OVERRIDES[r.account_number]).length
|
||||
const fromFallback = rows.length - fromBAS - fromOverride
|
||||
console.log(` ${fromBAS} from BAS, ${fromOverride} from overrides, ${fromFallback} from SIE/derived`)
|
||||
|
||||
for (const row of rows) {
|
||||
console.log(` ${row.account_number} — ${row.account_name} (${row.account_type}, SRU: ${row.sru_code ?? 'none'})`)
|
||||
}
|
||||
|
||||
if (DRY_RUN) {
|
||||
console.log(` [DRY RUN] Would insert ${rows.length} accounts`)
|
||||
return rows.length
|
||||
}
|
||||
|
||||
const { error: insertError } = await supabase
|
||||
.from('chart_of_accounts')
|
||||
.insert(rows)
|
||||
|
||||
if (insertError) {
|
||||
console.error(` Insert failed: ${insertError.message}`)
|
||||
return 0
|
||||
}
|
||||
|
||||
console.log(` Inserted ${rows.length} accounts`)
|
||||
return rows.length
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function main() {
|
||||
if (DRY_RUN) console.log('=== DRY RUN MODE ===\n')
|
||||
|
||||
// Find all users with missing accounts
|
||||
const { data: allImportUsers, error } = await supabase
|
||||
.from('sie_imports')
|
||||
.select('user_id')
|
||||
|
||||
if (error) {
|
||||
console.error('Failed to fetch SIE import users:', error.message)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const userIds = [...new Set(allImportUsers?.map(r => r.user_id) ?? [])]
|
||||
console.log(`Found ${userIds.length} users with SIE imports`)
|
||||
|
||||
let totalInserted = 0
|
||||
for (const userId of userIds) {
|
||||
totalInserted += await backfillForUser(userId)
|
||||
}
|
||||
|
||||
console.log(`\n=== Done: ${totalInserted} accounts ${DRY_RUN ? 'would be' : ''} inserted across ${userIds.length} users ===`)
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error(err)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -0,0 +1,130 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
/**
|
||||
* Backfill SIE file archival for existing imports.
|
||||
*
|
||||
* Accepts a directory of SIE files, computes SHA-256 hashes, matches against
|
||||
* sie_imports.file_hash, uploads to Supabase Storage, and populates
|
||||
* file_storage_path.
|
||||
*
|
||||
* Usage: npx tsx scripts/backfill-sie-files.ts <directory-of-sie-files>
|
||||
*/
|
||||
|
||||
import { config } from 'dotenv'
|
||||
config({ path: '.env.local' })
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
import { readdir, readFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { createHash } from 'node:crypto'
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL
|
||||
const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY
|
||||
|
||||
if (!supabaseUrl || !serviceRoleKey) {
|
||||
console.error('Missing NEXT_PUBLIC_SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY in .env')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const supabase = createClient(supabaseUrl, serviceRoleKey)
|
||||
|
||||
async function calculateFileHash(content: string): Promise<string> {
|
||||
const hash = createHash('sha256')
|
||||
hash.update(content)
|
||||
return hash.digest('hex')
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const dir = process.argv[2]
|
||||
if (!dir) {
|
||||
console.error('Usage: npx tsx scripts/backfill-sie-files.ts <directory-of-sie-files>')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// 1. Read all SIE files from directory
|
||||
const files = await readdir(dir)
|
||||
const sieFiles = files.filter(f => f.toLowerCase().endsWith('.se') || f.toLowerCase().endsWith('.si'))
|
||||
|
||||
if (sieFiles.length === 0) {
|
||||
console.error(`No .se/.si files found in ${dir}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log(`Found ${sieFiles.length} SIE files in ${dir}`)
|
||||
|
||||
// 2. Get all existing imports without file_storage_path
|
||||
const { data: imports, error: importError } = await supabase
|
||||
.from('sie_imports')
|
||||
.select('id, user_id, file_hash, filename, file_storage_path')
|
||||
.is('file_storage_path', null)
|
||||
|
||||
if (importError) {
|
||||
console.error('Failed to fetch imports:', importError.message)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
if (!imports || imports.length === 0) {
|
||||
console.log('No imports need backfilling.')
|
||||
return
|
||||
}
|
||||
|
||||
console.log(`Found ${imports.length} imports without archived files`)
|
||||
|
||||
// Build hash→import mapping
|
||||
const hashToImport = new Map<string, typeof imports[number]>()
|
||||
for (const imp of imports) {
|
||||
if (imp.file_hash) {
|
||||
hashToImport.set(imp.file_hash, imp)
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Match files by hash and upload
|
||||
let matched = 0
|
||||
let uploaded = 0
|
||||
|
||||
for (const filename of sieFiles) {
|
||||
const filePath = join(dir, filename)
|
||||
const content = await readFile(filePath, 'utf-8')
|
||||
const hash = await calculateFileHash(content)
|
||||
|
||||
const imp = hashToImport.get(hash)
|
||||
if (!imp) {
|
||||
console.log(` ${filename} — no matching import (hash: ${hash.substring(0, 12)}...)`)
|
||||
continue
|
||||
}
|
||||
|
||||
matched++
|
||||
console.log(` ${filename} → import ${imp.id} (${imp.filename})`)
|
||||
|
||||
// Upload to storage
|
||||
const storagePath = `${imp.user_id}/${imp.id}.se`
|
||||
const fileBlob = new Blob([content], { type: 'text/plain; charset=cp437' })
|
||||
const { error: uploadError } = await supabase.storage
|
||||
.from('sie-files')
|
||||
.upload(storagePath, fileBlob, { upsert: false })
|
||||
|
||||
if (uploadError) {
|
||||
console.error(` Upload failed: ${uploadError.message}`)
|
||||
continue
|
||||
}
|
||||
|
||||
// Update import record
|
||||
const { error: updateError } = await supabase
|
||||
.from('sie_imports')
|
||||
.update({ file_storage_path: storagePath })
|
||||
.eq('id', imp.id)
|
||||
|
||||
if (updateError) {
|
||||
console.error(` DB update failed: ${updateError.message}`)
|
||||
continue
|
||||
}
|
||||
|
||||
uploaded++
|
||||
console.log(` Archived to ${storagePath}`)
|
||||
}
|
||||
|
||||
console.log(`\nDone: ${matched} matched, ${uploaded} uploaded, ${sieFiles.length - matched} unmatched`)
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error(err)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -1,7 +1,13 @@
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
|
||||
const isHosted = process.env.NEXT_PUBLIC_SELF_HOSTED !== "true";
|
||||
|
||||
Sentry.init({
|
||||
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
|
||||
tracesSampleRate: 0.1,
|
||||
enabled: !!process.env.NEXT_PUBLIC_SENTRY_DSN,
|
||||
enabled: isHosted && !!process.env.NEXT_PUBLIC_SENTRY_DSN,
|
||||
environment: process.env.NODE_ENV,
|
||||
tracesSampleRate: process.env.NODE_ENV === "production" ? 0.1 : 1.0,
|
||||
replaysSessionSampleRate: 0,
|
||||
replaysOnErrorSampleRate: 1.0,
|
||||
integrations: [Sentry.replayIntegration()],
|
||||
});
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
|
||||
const isHosted = process.env.NEXT_PUBLIC_SELF_HOSTED !== "true";
|
||||
|
||||
Sentry.init({
|
||||
dsn: process.env.SENTRY_DSN,
|
||||
tracesSampleRate: 0.1,
|
||||
enabled: !!process.env.SENTRY_DSN,
|
||||
dsn: process.env.SENTRY_DSN || process.env.NEXT_PUBLIC_SENTRY_DSN,
|
||||
enabled: isHosted && !!(process.env.SENTRY_DSN || process.env.NEXT_PUBLIC_SENTRY_DSN),
|
||||
environment: process.env.NODE_ENV,
|
||||
tracesSampleRate: process.env.NODE_ENV === "production" ? 0.1 : 1.0,
|
||||
});
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
|
||||
const isHosted = process.env.NEXT_PUBLIC_SELF_HOSTED !== "true";
|
||||
|
||||
Sentry.init({
|
||||
dsn: process.env.SENTRY_DSN,
|
||||
tracesSampleRate: 0.1,
|
||||
enabled: !!process.env.SENTRY_DSN,
|
||||
dsn: process.env.SENTRY_DSN || process.env.NEXT_PUBLIC_SENTRY_DSN,
|
||||
enabled: isHosted && !!(process.env.SENTRY_DSN || process.env.NEXT_PUBLIC_SENTRY_DSN),
|
||||
environment: process.env.NODE_ENV,
|
||||
tracesSampleRate: process.env.NODE_ENV === "production" ? 0.1 : 1.0,
|
||||
});
|
||||
|
||||
@@ -1016,7 +1016,6 @@ export interface CreateFiscalPeriodInput {
|
||||
export interface OnboardingProgress {
|
||||
hasCustomers: boolean
|
||||
hasInvoices: boolean
|
||||
hasReceipts: boolean
|
||||
hasBankConnected: boolean
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user