diff --git a/app/(dashboard)/reports/page.tsx b/app/(dashboard)/reports/page.tsx index ffe23d72..abc15bca 100644 --- a/app/(dashboard)/reports/page.tsx +++ b/app/(dashboard)/reports/page.tsx @@ -230,76 +230,74 @@ export default function ReportsPage() { - {/* Desktop: grid tab navigation */} -
- {/* Bokslut (Financial Statements) */} -
-

- Bokslut -

- - + {/* Desktop: inline grouped tab navigation */} +
+ {/* Bokslut */} +
+ Bokslut + + Saldobalans - + Resultaträkning - + Balansräkning
- {/* Skatt & moms (Tax & VAT) */} -
-

- Skatt & moms -

- - +
+ + {/* Skatt & moms */} +
+ Skatt & moms + + Momsdeklaration {isEnskildFirma && ( - + NE-bilaga )} {isAktiebolag && ( - + INK2 )}
- {/* Huvudböcker (Ledgers) */} -
-

- Huvudböcker -

- - +
+ + {/* Huvudböcker */} +
+ Huvudböcker + + Huvudbok - + Grundbok - + Kundreskontra - + Leverantörsreskontra
- {/* Avstämning (Reconciliation) */} -
-

- Avstämning -

- - +
+ + {/* Avstämning */} +
+ Avstämning + + Bankavstämning diff --git a/components/extensions/general/ArcimMigrationWorkspace.tsx b/components/extensions/general/ArcimMigrationWorkspace.tsx index 0a13c961..6015f533 100644 --- a/components/extensions/general/ArcimMigrationWorkspace.tsx +++ b/components/extensions/general/ArcimMigrationWorkspace.tsx @@ -122,17 +122,57 @@ interface PreviewData { } | null } +interface SIEFileStatus { + fiscalYear: number + alreadyImported: boolean + importedAt: string | null +} + interface SIEData { parsed: ParsedSIEFile mappings: AccountMapping[] mappingStats: { total: number; mapped: number; unmapped: number } rawContent: string[] + fileStatuses: SIEFileStatus[] + allImported: boolean + newFileCount: number basAccounts: BASAccount[] } // ── Provider selection step ────────────────────────────────────── -const COMING_SOON_PROVIDERS = new Set(['visma', 'bjornlunden', 'briox']) +interface ConnectionStatus { + consents: { + id: string + provider: ArcimProvider + status: number + companyName?: string + createdAt?: string + }[] + sieImports: { + id: string + filename: string + status: string + accounts_count: number | null + transactions_count: number | null + company_name: string | null + fiscal_year_start: string | null + fiscal_year_end: string | null + imported_at: string | null + created_at: string + }[] + entityCounts: { + customers: number + suppliers: number + invoices: number + } +} + +const COMING_SOON_PROVIDERS = new Set( + process.env.NODE_ENV === 'development' + ? ['bjornlunden', 'briox'] + : ['visma', 'bjornlunden', 'briox'] +) const PROVIDER_LOGOS: Record = { fortnox: '/logos/fortnox.svg', @@ -142,53 +182,159 @@ const PROVIDER_LOGOS: Record = { briox: '/logos/Briox_logo.png', } -function ProviderStep({ onSelect }: { onSelect: (provider: ArcimProvider) => void }) { +function ProviderStep({ + onSelect, + onResync, + onDisconnect, + connectionStatus, + isLoadingStatus, +}: { + onSelect: (provider: ArcimProvider) => void + onResync: (provider: ArcimProvider, consentId: string) => void + onDisconnect: (consentId: string) => void + connectionStatus: ConnectionStatus | null + isLoadingStatus: boolean +}) { + const activeConsents = connectionStatus?.consents.filter(c => c.status === 1) ?? [] + return (
+ {/* Existing connections */} + {activeConsents.length > 0 && ( + + + Aktiva anslutningar + + Du har redan anslutna leverantörer. Synka igen för att hämta ny data. + + + + {activeConsents.map((consent) => { + const providerInfo = ARCIM_PROVIDERS.find(p => p.id === consent.provider) + const completedImports = connectionStatus?.sieImports.filter(i => i.status === 'completed') ?? [] + const lastImport = completedImports[0] + + return ( +
+ {providerInfo?.name +
+
+

{providerInfo?.name ?? consent.provider}

+ + + Ansluten + +
+
+ {consent.companyName && ( +

{consent.companyName}

+ )} + {lastImport ? ( +

+ Senaste import: {new Date(lastImport.imported_at ?? lastImport.created_at).toLocaleDateString('sv-SE')} + {lastImport.transactions_count != null && ` — ${lastImport.transactions_count} verifikationer`} +

+ ) : ( +

+ Ansluten {consent.createdAt ? new Date(consent.createdAt).toLocaleDateString('sv-SE') : ''} +

+ )} + {(connectionStatus?.entityCounts.customers ?? 0) > 0 && ( +

+ {connectionStatus?.entityCounts.customers} kunder, {connectionStatus?.entityCounts.suppliers} leverantörer, {connectionStatus?.entityCounts.invoices} fakturor +

+ )} +
+
+
+ + +
+
+ ) + })} +
+
+ )} + + {/* Provider selection */} - Välj ditt nuvarande bokföringssystem + {activeConsents.length > 0 ? 'Anslut ytterligare system' : 'Välj ditt nuvarande bokföringssystem'} Vi hämtar bokföringsdata via SIE och kunder, leverantörer och fakturor via API:et. -
- {ARCIM_PROVIDERS.map((provider) => { - const comingSoon = COMING_SOON_PROVIDERS.has(provider.id) - return ( -
- - ) - })} -
+ + ) + })} +
+ )}
@@ -285,14 +431,21 @@ function ConnectStep({ {authType === 'oauth' && authUrl && !isLoading && (

- Klicka nedan för att logga in i {providerName} i ett nytt fönster. - När du är klar skickas du tillbaka hit automatiskt. + Klicka nedan för att logga in i {providerName}. + Fönstret stängs automatiskt när du är klar.

-
)} @@ -532,12 +685,14 @@ function MappingStep({ function OptionsStep({ options, sieAvailable, + sieData, onChange, onStart, onBack, }: { options: MigrationOptions sieAvailable: boolean + sieData: SIEData | null onChange: (options: MigrationOptions) => void onStart: () => void onBack: () => void @@ -548,6 +703,10 @@ function OptionsStep({ onChange({ ...options, [key]: !options[key] }) } + const fileStatuses = sieData?.fileStatuses ?? [] + const allSieImported = sieData?.allImported ?? false + const newFileCount = sieData?.newFileCount ?? 0 + const selectedItems: string[] = [] if (options.importCompanyInfo) selectedItems.push('Företagsinformation') if (sieAvailable && options.importSIEData) selectedItems.push('Bokföringsdata (SIE)') @@ -579,11 +738,39 @@ function OptionsStep({ } label="Bokföringsdata (SIE)" - description="Kontoplan, ingående balanser och verifikationer" - checked={options.importSIEData} - onChange={() => toggleOption('importSIEData')} + description={allSieImported + ? 'All bokföringsdata är redan importerad — inga ändringar' + : newFileCount > 0 + ? `${newFileCount} ny(a) räkenskapsår att importera` + : 'Kontoplan, ingående balanser och verifikationer' + } + checked={options.importSIEData && !allSieImported} + onChange={() => !allSieImported && toggleOption('importSIEData')} + disabled={allSieImported} /> - {options.importSIEData && ( + {/* Per-file import status */} + {fileStatuses.length > 0 && ( +
+ {fileStatuses.map((fs) => ( +
+ {fs.alreadyImported ? ( + <> + + + Räkenskapsår {fs.fiscalYear} — importerad {fs.importedAt ? new Date(fs.importedAt).toLocaleDateString('sv-SE') : ''} + + + ) : ( + <> + + Räkenskapsår {fs.fiscalYear} — ny data att importera + + )} +
+ ))} +
+ )} + {options.importSIEData && !allSieImported && (
@@ -679,17 +866,22 @@ function OptionRow({ description, checked, onChange, + disabled, }: { icon: React.ReactNode label: string description: string checked: boolean onChange: () => void + disabled?: boolean }) { return (
!disabled && onChange()} >
{icon}
@@ -698,7 +890,8 @@ function OptionRow({
!disabled && onChange()} + disabled={disabled} onClick={(e) => e.stopPropagation()} />
@@ -994,6 +1187,16 @@ function ResultStep({ const allSieSucceeded = sieResults.length > 0 && sieResults.every(r => r.success) const anySieFailed = sieResults.some(r => r.errors.length > 0 && r.journalEntriesCreated === 0) + // Check if anything meaningful was imported via entities + // Company info is always re-fetched (upsert) so it doesn't count as "new" + const entityImported = results && ( + (results.customers && (results.customers.imported > 0 || results.customers.skipped > 0)) || + (results.suppliers && (results.suppliers.imported > 0 || results.suppliers.skipped > 0)) || + (results.salesInvoices && (results.salesInvoices.imported > 0 || results.salesInvoices.skipped > 0)) || + (results.supplierInvoices && (results.supplierInvoices.imported > 0 || results.supplierInvoices.skipped > 0)) + ) + const nothingNew = sieResults.length === 0 && !entityImported + // Overall status const overallIcon = anySieFailed ? 'error' as const : (!allSieSucceeded || totalErrors > 0) ? 'warning' as const : 'success' as const @@ -1004,13 +1207,16 @@ function ResultStep({ - - {anySieFailed ? 'Migrering delvis genomförd' : + + {nothingNew ? 'Allt är uppdaterat' : + anySieFailed ? 'Migrering delvis genomförd' : !allSieSucceeded ? 'Migrering klar med anmärkningar' : 'Migrering klar'} - {totalJournalEntries > 0 && ( + {nothingNew ? ( + 'Det finns ingen ny data att importera från leverantören.' + ) : totalJournalEntries > 0 ? ( <> {totalJournalEntries.toLocaleString('sv-SE')} @@ -1023,7 +1229,7 @@ function ResultStep({ )} - )} + ) : null} @@ -1044,60 +1250,71 @@ function ResultStep({ )} {/* ── API import results (company info, customers, etc.) ── */} - {results && ( -
-

- - Övriga data -

-
- {results.companyInfo && ( - } - label="Företagsinformation" - status={results.companyInfo.imported ? 'success' : 'skipped'} - statusText={results.companyInfo.imported ? 'Importerad' : 'Hoppades över'} - /> - )} - {results.customers && ( - } - label="Kunder" - status="success" - statusText={`${results.customers.imported} importerade`} - detail={results.customers.skipped > 0 ? `${results.customers.skipped} fanns redan` : undefined} - /> - )} - {results.suppliers && ( - } - label="Leverantörer" - status="success" - statusText={`${results.suppliers.imported} importerade`} - detail={results.suppliers.skipped > 0 ? `${results.suppliers.skipped} fanns redan` : undefined} - /> - )} - {results.salesInvoices && ( - } - label="Kundfakturor" - status="success" - statusText={`${results.salesInvoices.imported} importerade`} - detail={results.salesInvoices.skipped > 0 ? `${results.salesInvoices.skipped} hoppades över` : undefined} - /> - )} - {results.supplierInvoices && ( - } - label="Leverantörsfakturor" - status="success" - statusText={`${results.supplierInvoices.imported} importerade`} - detail={results.supplierInvoices.skipped > 0 ? `${results.supplierInvoices.skipped} hoppades över` : undefined} - /> - )} + {results && (() => { + const hasCompanyInfo = results.companyInfo?.imported + const hasCustomers = results.customers && (results.customers.imported > 0 || results.customers.skipped > 0) + const hasSuppliers = results.suppliers && (results.suppliers.imported > 0 || results.suppliers.skipped > 0) + const hasSalesInvoices = results.salesInvoices && (results.salesInvoices.imported > 0 || results.salesInvoices.skipped > 0) + const hasSupplierInvoices = results.supplierInvoices && (results.supplierInvoices.imported > 0 || results.supplierInvoices.skipped > 0) + const hasAnything = hasCompanyInfo || hasCustomers || hasSuppliers || hasSalesInvoices || hasSupplierInvoices + + if (!hasAnything) return null + + return ( +
+

+ + Övriga data +

+
+ {hasCompanyInfo && ( + } + label="Företagsinformation" + status="success" + statusText="Importerad" + /> + )} + {hasCustomers && ( + } + label="Kunder" + status="success" + statusText={`${results.customers!.imported} importerade`} + detail={results.customers!.skipped > 0 ? `${results.customers!.skipped} fanns redan` : undefined} + /> + )} + {hasSuppliers && ( + } + label="Leverantörer" + status="success" + statusText={`${results.suppliers!.imported} importerade`} + detail={results.suppliers!.skipped > 0 ? `${results.suppliers!.skipped} fanns redan` : undefined} + /> + )} + {hasSalesInvoices && ( + } + label="Kundfakturor" + status="success" + statusText={`${results.salesInvoices!.imported} importerade`} + detail={results.salesInvoices!.skipped > 0 ? `${results.salesInvoices!.skipped} hoppades över` : undefined} + /> + )} + {hasSupplierInvoices && ( + } + label="Leverantörsfakturor" + status="success" + statusText={`${results.supplierInvoices!.imported} importerade`} + detail={results.supplierInvoices!.skipped > 0 ? `${results.supplierInvoices!.skipped} hoppades över` : undefined} + /> + )} +
-
- )} + ) + })()} {/* ── Next steps ── */} @@ -1193,8 +1410,12 @@ export default function ArcimMigrationWorkspace(_props: WorkspaceComponentProps) const [step, setStep] = useState('provider') const [isLoading, setIsLoading] = useState(false) + const [isLoadingStatus, setIsLoadingStatus] = useState(true) const [error, setError] = useState(null) + // Connection status (existing connections + import history) + const [connectionStatus, setConnectionStatus] = useState(null) + // Connection state const [selectedProvider, setSelectedProvider] = useState(null) const [consentId, setConsentId] = useState(null) @@ -1228,6 +1449,28 @@ export default function ArcimMigrationWorkspace(_props: WorkspaceComponentProps) ? ((currentUserStepIndex + 1) / userSteps.length) * 100 : 100 + // ── Fetch connection status on mount ─────────────────────────── + + const fetchStatus = useCallback(async () => { + try { + setIsLoadingStatus(true) + const res = await fetch('/api/extensions/ext/arcim-migration/status') + if (res.ok) { + const data = await res.json() + setConnectionStatus(data) + } + } catch { + // Non-critical — just means we can't show existing connections + } finally { + setIsLoadingStatus(false) + } + }, []) + + useEffect(() => { + fetchStatus() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + // ── Step handlers ────────────────────────────────────────────── const loadPreview = useCallback(async (cId: string) => { @@ -1279,6 +1522,12 @@ export default function ArcimMigrationWorkspace(_props: WorkspaceComponentProps) setConsentId(data.consentId) setAuthType(data.authType) + if (data.alreadyConnected) { + // Existing connection — skip auth, go straight to preview + await loadPreview(data.consentId) + return + } + if (data.authType === 'oauth' && data.authUrl) { setAuthUrl(data.authUrl) } @@ -1288,7 +1537,37 @@ export default function ArcimMigrationWorkspace(_props: WorkspaceComponentProps) } finally { setIsLoading(false) } - }, []) + }, [loadPreview]) + + // Re-sync with existing consent — go straight to preview + const handleResync = useCallback(async (provider: ArcimProvider, existingConsentId: string) => { + setSelectedProvider(provider) + setConsentId(existingConsentId) + setMigrationOptions(DEFAULT_OPTIONS) + setMigrationResults(null) + setSieImportResults([]) + setSieData(null) + await loadPreview(existingConsentId) + }, [loadPreview]) + + // Disconnect an existing consent + const handleDisconnect = useCallback(async (consentIdToDelete: string) => { + try { + const res = await fetch('/api/extensions/ext/arcim-migration/disconnect', { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ consentId: consentIdToDelete }), + }) + if (!res.ok) { + const data = await res.json().catch(() => ({})) + throw new Error(data.error || 'Kunde inte koppla från') + } + toast({ title: 'Frånkopplad', description: 'Anslutningen har tagits bort.' }) + await fetchStatus() + } catch (err) { + toast({ title: 'Fel', description: err instanceof Error ? err.message : 'Något gick fel', variant: 'destructive' }) + } + }, [toast, fetchStatus]) // Handle token submission for token-based providers (Bokio, etc.) const handleTokenSubmit = useCallback(async (apiToken: string, companyId: string) => { @@ -1352,12 +1631,26 @@ export default function ArcimMigrationWorkspace(_props: WorkspaceComponentProps) } }, [loadPreview]) - // Check for OAuth callback on mount + // Check for OAuth callback on mount (fallback for non-popup flow) useEffect(() => { handleOAuthReturn() // eslint-disable-next-line react-hooks/exhaustive-deps }, []) + // Listen for postMessage from OAuth popup + useEffect(() => { + function handleMessage(event: MessageEvent) { + if (event.origin !== window.location.origin) return + if (event.data?.type === 'arcim-oauth-success' && event.data.consentId) { + loadPreview(event.data.consentId) + } else if (event.data?.type === 'arcim-oauth-error') { + setError('OAuth-anslutningen misslyckades. Försök igen.') + } + } + window.addEventListener('message', handleMessage) + return () => window.removeEventListener('message', handleMessage) + }, [loadPreview]) + // Load SIE data when entering mapping step const loadSIEData = useCallback(async () => { if (!consentId) return @@ -1376,8 +1669,13 @@ export default function ArcimMigrationWorkspace(_props: WorkspaceComponentProps) const data = await res.json() setSieData(data) - // Auto-skip mapping step if all accounts are mapped - if (data.mappingStats.unmapped === 0) { + // If all SIE files are already imported, disable SIE import by default + if (data.allImported) { + setMigrationOptions(prev => ({ ...prev, importSIEData: false })) + } + + // Auto-skip mapping step if all accounts are mapped or all files already imported + if (data.mappingStats.unmapped === 0 || data.allImported) { setStep('options') } } catch (err) { @@ -1431,38 +1729,42 @@ export default function ArcimMigrationWorkspace(_props: WorkspaceComponentProps) setMigrationProgress(10) setSieImportResults([]) - // Import all fiscal years' SIE content - for (let i = 0; i < sieData.rawContent.length; i++) { - const progress = 10 + Math.round((i / sieData.rawContent.length) * 40) - setMigrationProgress(progress) - setMigrationStep(`Importerar bokföringsdata (SIE) — fil ${i + 1} av ${sieData.rawContent.length}...`) + // Only import files that haven't been imported yet + const filesToImport = sieData.rawContent + .map((content, i) => ({ content, status: sieData.fileStatuses?.[i] })) + .filter(f => !f.status?.alreadyImported) - const res = await fetch('/api/extensions/ext/arcim-migration/import-sie', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - rawContent: sieData.rawContent[i], - mappings: sieData.mappings, - options: { - createFiscalPeriod: true, - importOpeningBalances: true, - importTransactions: true, - voucherSeries: migrationOptions.voucherSeries, - }, - }), - }) + if (filesToImport.length === 0) { + // All files already imported — skip SIE phase + setSieImportResults([]) + } else { + for (let i = 0; i < filesToImport.length; i++) { + const progress = 10 + Math.round((i / filesToImport.length) * 40) + setMigrationProgress(progress) + setMigrationStep(`Importerar bokföringsdata (SIE) — fil ${i + 1} av ${filesToImport.length}...`) - if (!res.ok) { - const data = await res.json().catch(() => ({})) - throw new Error(data.error || `SIE import HTTP ${res.status}`) - } + const res = await fetch('/api/extensions/ext/arcim-migration/import-sie', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + rawContent: filesToImport[i].content, + mappings: sieData.mappings, + options: { + createFiscalPeriod: true, + importOpeningBalances: true, + importTransactions: true, + voucherSeries: migrationOptions.voucherSeries, + }, + }), + }) - const result = await res.json() as ImportResult - setSieImportResults(prev => [...prev, result]) + if (!res.ok) { + const data = await res.json().catch(() => ({})) + throw new Error(data.error || `SIE import HTTP ${res.status}`) + } - if (!result.success && result.errors.length > 0) { - // Log but don't fail — continue with API import - // Non-critical — continue with API import + const result = await res.json() as ImportResult + setSieImportResults(prev => [...prev, result]) } } } @@ -1527,7 +1829,9 @@ export default function ArcimMigrationWorkspace(_props: WorkspaceComponentProps) setMigrationResults(null) setSieImportResults([]) setError(null) - }, []) + // Refresh status so provider step shows updated import history + fetchStatus() + }, [fetchStatus]) // ── Render ───────────────────────────────────────────────────── @@ -1562,7 +1866,13 @@ export default function ArcimMigrationWorkspace(_props: WorkspaceComponentProps) {/* Step content */} {step === 'provider' && ( - + )} {step === 'connect' && selectedProvider && ( @@ -1606,6 +1916,7 @@ export default function ArcimMigrationWorkspace(_props: WorkspaceComponentProps) preview?.sieAvailable ? setStep('mapping') : setStep('preview')} diff --git a/components/settings/TeamPanel.tsx b/components/settings/TeamPanel.tsx index ea26cb43..06e521be 100644 --- a/components/settings/TeamPanel.tsx +++ b/components/settings/TeamPanel.tsx @@ -59,9 +59,6 @@ export function TeamPanel() { {teamName || 'Team'} - - Ditt team har en ägare. Bjud in medarbetare via enskilda företag. -
diff --git a/components/ui/empty-state.tsx b/components/ui/empty-state.tsx index a64cd6ec..c9c7402a 100644 --- a/components/ui/empty-state.tsx +++ b/components/ui/empty-state.tsx @@ -105,8 +105,6 @@ export function EmptyInvoices() { description="Skapa din första faktura på under 60 sekunder. Vi fyller i dina uppgifter automatiskt." actionLabel="Skapa faktura" actionHref="/invoices/new" - secondaryActionLabel="Lägg till kund först" - secondaryActionHref="/customers/new" /> ) } diff --git a/extensions/general/arcim-migration/index.ts b/extensions/general/arcim-migration/index.ts index fa7b0411..0cb43c0a 100644 --- a/extensions/general/arcim-migration/index.ts +++ b/extensions/general/arcim-migration/index.ts @@ -3,45 +3,47 @@ import { NextResponse } from 'next/server' import { createConsent, getConsent, + listConsents, generateOtc, getAuthUrl, exchangeAuthToken, submitProviderToken, deleteConsent, - fetchCompanyInfo, - fetchSIEExport, -} from './lib/arcim-client' + resolveConsent, + fetchCompanyInfoDirect, +} from './lib/provider-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 { parseSIEFile, validateSIEFile, calculateFileHash } 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' +import { BAS_REFERENCE, getBASReference } from '@/lib/bookkeeping/bas-reference' +import { fetchAllRows } from '@/lib/supabase/fetch-all' +import { FortnoxClient } from '@/lib/providers/fortnox/client' +import type { ProviderName } from '@/lib/providers/types' /** Fiscal years we support importing — older data is not needed */ const ALLOWED_FISCAL_YEARS = new Set([2024, 2025, 2026]) +const fortnoxClient = new FortnoxClient() + /** - * Arcim Migration extension + * Provider 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. + * (Fortnox, Visma, Bokio, Björn Lundén, Briox) into gnubok by talking + * directly to each provider's API. * * 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 + * files fetched from providers. Entity data (customers, suppliers, + * invoices) is imported via the provider REST APIs. */ export const arcimMigrationExtension: Extension = { id: 'arcim-migration', - name: 'Systemmigration (Arcim Sync)', - version: '1.0.0', + name: 'Systemmigration', + version: '2.0.0', apiRoutes: [ // ── List available providers ─────────────────────────────────── @@ -53,6 +55,68 @@ export const arcimMigrationExtension: Extension = { }, }, + // ── Check existing connections and import history ────────────── + { + method: 'GET', + path: '/status', + handler: async (request: Request, ctx?: ExtensionContext) => { + 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 companyId = ctx?.companyId ?? user.id + + try { + // Get accepted consents only (status 1) — not abandoned/created ones + const allConsents = await listConsents(companyId) + const consents = allConsents.filter(c => c.status === 1) + + // Get SIE import history + const { data: sieImports } = await supabase + .from('sie_imports') + .select('id, filename, status, accounts_count, transactions_count, company_name, fiscal_year_start, fiscal_year_end, imported_at, created_at') + .eq('company_id', companyId) + .order('created_at', { ascending: false }) + .limit(10) + + // Get entity counts (to show what's already been imported) + const [ + { count: customerCount }, + { count: supplierCount }, + { count: invoiceCount }, + ] = await Promise.all([ + supabase.from('customers').select('*', { count: 'exact', head: true }).eq('company_id', companyId), + supabase.from('suppliers').select('*', { count: 'exact', head: true }).eq('company_id', companyId), + supabase.from('invoices').select('*', { count: 'exact', head: true }).eq('company_id', companyId), + ]) + + return NextResponse.json({ + consents: consents.map(c => ({ + id: c.id, + provider: c.provider, + status: c.status, + companyName: c.companyName, + createdAt: c.createdAt, + })), + sieImports: sieImports ?? [], + entityCounts: { + customers: customerCount ?? 0, + suppliers: supplierCount ?? 0, + invoices: invoiceCount ?? 0, + }, + }) + } catch (error) { + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Failed to fetch status' }, + { status: 500 } + ) + } + }, + }, + // ── Start consent flow (create consent + OTC) ───────────────── { method: 'POST', @@ -66,6 +130,8 @@ export const arcimMigrationExtension: Extension = { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const companyId = ctx?.companyId ?? user.id + const { provider, companyName, orgNumber } = await request.json() as { provider: ArcimProvider companyName?: string @@ -82,15 +148,39 @@ export const arcimMigrationExtension: Extension = { } try { - // Create consent in Arcim Sync + // Reuse existing accepted consent if one exists for this provider + const existingConsents = await listConsents(companyId) + const existing = existingConsents.find(c => c.provider === provider && c.status === 1) + + if (existing) { + // Already connected — skip OAuth, go straight to preview + if (ctx?.settings) { + await ctx.settings.set('consent_id', existing.id) + await ctx.settings.set('provider', provider) + } + + return NextResponse.json({ + consentId: existing.id, + authType: providerInfo.authType, + alreadyConnected: true, + }) + } + + // Clean up abandoned consents (status 0 = Created but never completed OAuth) + const abandoned = existingConsents.filter(c => c.provider === provider && c.status === 0) + for (const a of abandoned) { + await deleteConsent(a.id) + } + + // Create new consent const consent = await createConsent( + companyId, 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) @@ -100,15 +190,14 @@ export const arcimMigrationExtension: Extension = { // Generate OTC for OAuth flow const otc = await generateOtc(consent.id) - // Build the OAuth callback URL using the current app URL (localhost in dev, production in prod) + // Build the OAuth callback URL const appUrl = process.env.NEXT_PUBLIC_APP_URL || '' const callbackUrl = `${appUrl}/api/extensions/ext/arcim-migration/callback` - // Encode consentId + provider in state so the callback doesn't depend on session storage + // Encode consentId + provider in state const statePayload = JSON.stringify({ otc: otc.code, consentId: consent.id, provider }) const stateEncoded = Buffer.from(statePayload).toString('base64url') - // Pass callbackUrl as redirectUri so Fortnox redirects here (works for localhost and production) const { url } = await getAuthUrl(provider, stateEncoded, callbackUrl) return NextResponse.json({ @@ -162,7 +251,6 @@ export const arcimMigrationExtension: Extension = { } // 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' }, @@ -170,7 +258,6 @@ export const arcimMigrationExtension: Extension = { ) } - // Bokio and BL require companyId if ((provider === 'bokio' || provider === 'bjornlunden') && !companyId) { return NextResponse.json( { error: 'companyId is required for this provider' }, @@ -192,10 +279,6 @@ export const arcimMigrationExtension: Extension = { }, // ── OAuth callback ──────────────────────────────────────────── - // This handler is called by the OAuth provider redirect. It does NOT - // require user auth — the request comes from the provider, not the user's - // browser session. Authentication is validated via the OTC code + consent. - // The 'skipAuth' flag is checked by the extension dispatch route. { method: 'GET', path: '/callback', @@ -211,24 +294,19 @@ export const arcimMigrationExtension: Extension = { } try { - // Decode state — supports both new format (base64url JSON with consentId/provider) - // and legacy format (plain OTC code string) let consentId: string | null = null let provider: ArcimProvider | null = null - let otcCode: string = stateRaw try { const decoded = JSON.parse(Buffer.from(stateRaw, 'base64url').toString()) - if (decoded.consentId && decoded.provider && decoded.otc) { + if (decoded.consentId && decoded.provider) { consentId = decoded.consentId provider = decoded.provider as ArcimProvider - otcCode = decoded.otc } } catch { - // Legacy: state is just the OTC code — fall back to ctx.settings + // Legacy fallback } - // Fall back to session-based settings if state didn't contain the data if (!consentId || !provider) { consentId = ctx?.settings ? await ctx.settings.get('consent_id') @@ -242,18 +320,43 @@ export const arcimMigrationExtension: Extension = { return NextResponse.json({ error: 'No active migration session' }, { status: 400 }) } - // The redirectUri used for the token exchange must match the one used in the auth URL const appUrl = process.env.NEXT_PUBLIC_APP_URL || '' const redirectUri = `${appUrl}/api/extensions/ext/arcim-migration/callback` - await exchangeAuthToken(consentId, provider, otcCode, code, redirectUri) + // Exchange OAuth code directly with the provider + await exchangeAuthToken(consentId, provider, code, redirectUri) - // Redirect to import page with success - return NextResponse.redirect(`${appUrl}/import?migration=connected&consentId=${consentId}`) + // Return an HTML page that notifies the opener tab and closes itself + const html = `

Anslutningen lyckades. Du kan stänga denna flik.

` + + return new Response(html, { + status: 200, + headers: { 'Content-Type': 'text/html' }, + }) } catch (error) { log.error('OAuth callback error:', error) const appUrl = process.env.NEXT_PUBLIC_APP_URL || '' - return NextResponse.redirect(`${appUrl}/import?migration=error`) + + const html = `

Något gick fel. Du kan stänga denna flik.

` + + return new Response(html, { + status: 200, + headers: { 'Content-Type': 'text/html' }, + }) } }, }, @@ -271,6 +374,8 @@ export const arcimMigrationExtension: Extension = { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const companyId = ctx?.companyId ?? user.id + const url = new URL(request.url) const consentId = url.searchParams.get('consentId') @@ -279,7 +384,6 @@ export const arcimMigrationExtension: Extension = { } try { - // Verify consent is accepted const consent = await getConsent(consentId) if (consent.status !== 1) { return NextResponse.json( @@ -288,45 +392,64 @@ export const arcimMigrationExtension: Extension = { ) } - // Fetch company info for preview (non-blocking — continue if it fails) + // Resolve consent to get access token + const resolved = await resolveConsent(companyId, consentId) + const provider = resolved.consent.provider as ProviderName + + // Fetch company info directly from provider let mapped = null try { - const companyInfo = await fetchCompanyInfo(consentId) + const companyInfo = await fetchCompanyInfoDirect(provider, resolved.accessToken, resolved.providerCompanyId) mapped = companyInfo ? mapCompanyInfo(companyInfo) : null } catch (err) { log.info('Company info fetch failed:', err instanceof Error ? err.message : String(err)) } - // Try to fetch SIE stats (non-blocking — continue if it fails) + // Try to fetch SIE data (Fortnox has native SIE export) 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 (provider === 'fortnox') { + try { + log.info(`Fetching SIE export from Fortnox for consent ${consentId}...`) + // Fortnox SIE export endpoint: /3/sie/{type}?financialyear={id} + // First get financial years + const fyResponse = await fortnoxClient.get>( + resolved.accessToken, + '/financialyears' + ) + const years = (fyResponse['FinancialYears'] as Record[] | undefined) ?? [] + const allowedYears = years + .map(fy => ({ + id: fy['Id'] as number, + fromDate: fy['FromDate'] as string, + toDate: fy['ToDate'] as string, + })) + .filter(fy => { + const year = new Date(fy.fromDate).getFullYear() + return ALLOWED_FISCAL_YEARS.has(year) + }) - // Only keep fiscal years we need (2024–2026) - const filteredFiles = sieResult.files.filter(f => ALLOWED_FISCAL_YEARS.has(f.fiscalYear)) - if (filteredFiles.length < sieResult.files.length) { - const skippedYears = sieResult.files - .filter(f => !ALLOWED_FISCAL_YEARS.has(f.fiscalYear)) - .map(f => f.fiscalYear) - log.info(`Filtered out fiscal years: ${skippedYears.join(', ')} (only importing ${[...ALLOWED_FISCAL_YEARS].join(', ')})`) + if (allowedYears.length > 0) { + // Fetch SIE type 4 for the most recent allowed year to get stats + const latestYear = allowedYears[allowedYears.length - 1] + const sieContent = await fortnoxClient.getText( + resolved.accessToken, + `/sie/4?financialyear=${latestYear.id}` + ) + if (sieContent) { + const parsed = parseSIEFile(sieContent) + sieAvailable = true + sieStats = { + accountCount: parsed.accounts.length, + transactionCount: parsed.vouchers.length, + fiscalYears: allowedYears.map(fy => new Date(fy.fromDate).getFullYear()), + } + } + } + } catch (err) { + log.info('SIE export failed:', err instanceof Error ? err.message : String(err)) } - - if (filteredFiles.length > 0) { - sieAvailable = true - const totalAccounts = Math.max(...filteredFiles.map(f => f.accountCount)) - const totalTransactions = filteredFiles.reduce((sum, f) => sum + f.transactionCount, 0) - const fiscalYears = filteredFiles.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('No SIE files within allowed fiscal years') - } - } catch (err) { - log.info('SIE export failed:', err instanceof Error ? err.message : String(err)) } return NextResponse.json({ @@ -363,6 +486,8 @@ export const arcimMigrationExtension: Extension = { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const companyId = ctx?.companyId ?? user.id + const url = new URL(request.url) const consentId = url.searchParams.get('consentId') @@ -371,22 +496,77 @@ export const arcimMigrationExtension: Extension = { } try { - // Fetch SIE from gateway and filter to allowed fiscal years (2024–2026) - const sieResult = await fetchSIEExport(consentId, 4) - const filteredFiles = sieResult.files.filter(f => ALLOWED_FISCAL_YEARS.has(f.fiscalYear)) - if (filteredFiles.length === 0) { + // Resolve consent + const resolved = await resolveConsent(companyId, consentId) + const provider = resolved.consent.provider as ProviderName + + if (provider !== 'fortnox') { + return NextResponse.json( + { error: `SIE export is currently only supported for Fortnox. Provider: ${provider}` }, + { status: 400 } + ) + } + + // Fetch financial years from Fortnox + const fyResponse = await fortnoxClient.get>( + resolved.accessToken, + '/financialyears' + ) + const years = (fyResponse['FinancialYears'] as Record[] | undefined) ?? [] + const allowedYears = years + .map(fy => ({ + id: fy['Id'] as number, + fromDate: fy['FromDate'] as string, + toDate: fy['ToDate'] as string, + })) + .filter(fy => { + const year = new Date(fy.fromDate).getFullYear() + return ALLOWED_FISCAL_YEARS.has(year) + }) + + if (allowedYears.length === 0) { + return NextResponse.json({ error: 'No SIE data available for fiscal years 2024–2026' }, { status: 404 }) + } + + // Fetch SIE type 4 for each allowed year + const sieFiles: { fiscalYear: number; rawContent: string }[] = [] + for (const fy of allowedYears) { + try { + const sieContent = await fortnoxClient.getText( + resolved.accessToken, + `/sie/4?financialyear=${fy.id}` + ) + if (sieContent) { + sieFiles.push({ + fiscalYear: new Date(fy.fromDate).getFullYear(), + rawContent: sieContent, + }) + } + } catch (err) { + log.info(`Failed to fetch SIE for year ${fy.id}:`, err instanceof Error ? err.message : String(err)) + } + } + + if (sieFiles.length === 0) { return NextResponse.json({ error: 'No SIE data available for fiscal years 2024–2026' }, { status: 404 }) } // Parse most recent file for preview/validation - const sieFile = filteredFiles[filteredFiles.length - 1] + const sieFile = sieFiles[sieFiles.length - 1] const parsed = parseSIEFile(sieFile.rawContent) const validation = validateSIEFile(parsed) + if (!validation.valid) { + return NextResponse.json({ + error: 'validation', + message: 'SIE file validation failed', + validation, + }, { status: 400 }) + } + // Collect ALL unique accounts across ALL fiscal year files - // so mappings cover every account that will be imported const allAccountsMap = new Map() - for (const file of filteredFiles) { + for (const file of sieFiles) { const fileParsed = parseSIEFile(file.rawContent) for (const acc of fileParsed.accounts) { if (!allAccountsMap.has(acc.number)) { @@ -394,14 +574,12 @@ export const arcimMigrationExtension: Extension = { } } } - // 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, ctx?.companyId ?? user.id) + const existingMappings = await loadMappings(supabase, companyId) const existingRecords = [...existingMappings.values()].map(m => ({ id: '', user_id: user.id, @@ -414,7 +592,7 @@ export const arcimMigrationExtension: Extension = { updated_at: '', })) - // Suggest mappings using accounts from ALL fiscal years + // Suggest mappings const basAccounts = BAS_REFERENCE.map(b => ({ account_number: b.account_number, account_name: b.account_name, @@ -422,13 +600,32 @@ export const arcimMigrationExtension: Extension = { const mappings = suggestMappings(allAccounts, basAccounts, existingRecords) const mappingStats = getMappingStats(mappings) - log.info(`Account mapping: ${allAccounts.length} unique accounts across ${filteredFiles.length} files, ${mappingStats.unmapped} unmapped`) + log.info(`Account mapping: ${allAccounts.length} unique accounts across ${sieFiles.length} files, ${mappingStats.unmapped} unmapped`) - // Generate preview const preview = generateImportPreview(parsed, mappings) - // Collect all raw SIE content (filtered fiscal years only) - const allRawContent = filteredFiles.map(f => f.rawContent) + // Check each file's hash against existing imports + const fileStatuses: { fiscalYear: number; rawContent: string; alreadyImported: boolean; importedAt: string | null }[] = [] + for (const file of sieFiles) { + const fileHash = await calculateFileHash(file.rawContent) + const { data: existingImport } = await supabase + .from('sie_imports') + .select('imported_at') + .eq('company_id', companyId) + .eq('file_hash', fileHash) + .eq('status', 'completed') + .maybeSingle() + + fileStatuses.push({ + fiscalYear: file.fiscalYear, + rawContent: file.rawContent, + alreadyImported: !!existingImport, + importedAt: existingImport?.imported_at ?? null, + }) + } + + const allImported = fileStatuses.every(f => f.alreadyImported) + const newFiles = fileStatuses.filter(f => !f.alreadyImported) return NextResponse.json({ parsed, @@ -436,7 +633,14 @@ export const arcimMigrationExtension: Extension = { mappingStats, preview, validation, - rawContent: allRawContent, + rawContent: fileStatuses.map(f => f.rawContent), + fileStatuses: fileStatuses.map(f => ({ + fiscalYear: f.fiscalYear, + alreadyImported: f.alreadyImported, + importedAt: f.importedAt, + })), + allImported, + newFileCount: newFiles.length, basAccounts: BAS_REFERENCE, }) } catch (error) { @@ -462,6 +666,8 @@ export const arcimMigrationExtension: Extension = { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const companyId = ctx?.companyId ?? user.id + const { rawContent, mappings, options } = await request.json() as { rawContent: string mappings: import('@/lib/import/types').AccountMapping[] @@ -478,14 +684,110 @@ export const arcimMigrationExtension: Extension = { } try { - // Parse the SIE content const parsed = parseSIEFile(rawContent) - // Save the user's mappings for future use + // Validate all accounts are mapped (same as manual upload) + const unmapped = mappings.filter((m: import('@/lib/import/types').AccountMapping) => !m.targetAccount) + if (unmapped.length > 0) { + return NextResponse.json({ + error: 'validation', + message: `${unmapped.length} account(s) are not mapped`, + unmappedAccounts: unmapped.map((m: import('@/lib/import/types').AccountMapping) => ({ + account: m.sourceAccount, + name: m.sourceName, + })), + }, { status: 400 }) + } + + // Auto-activate mapped BAS accounts not yet in user's chart (same as manual upload) + const mappedAccountNumbers = [ + ...new Set(mappings.filter((m: import('@/lib/import/types').AccountMapping) => m.targetAccount).map((m: import('@/lib/import/types').AccountMapping) => m.targetAccount)), + ] + + const allCompanyAccounts = await fetchAllRows(({ from, to }) => + supabase + .from('chart_of_accounts') + .select('account_number') + .eq('company_id', companyId) + .range(from, to) + ) + const existingNumbers = new Set(allCompanyAccounts.map((a: { account_number: string }) => a.account_number)) + + const mappingNameLookup = new Map() + for (const m of mappings) { + if (m.targetAccount) { + mappingNameLookup.set(m.targetAccount, m.targetName || m.sourceName) + } + } + + const accountsToActivate = mappedAccountNumbers + .filter((num) => !existingNumbers.has(num)) + .map((num) => { + const ref = getBASReference(num) + if (ref) { + return { + user_id: user.id, + company_id: companyId, + account_number: ref.account_number, + account_name: ref.account_name, + account_class: ref.account_class, + account_group: ref.account_group, + account_type: ref.account_type, + normal_balance: ref.normal_balance, + plan_type: 'full_bas' as const, + is_active: true, + is_system_account: false, + description: ref.description, + sru_code: ref.sru_code, + sort_order: parseInt(ref.account_number), + } + } + + const accountClass = parseInt(num.charAt(0), 10) + const accountGroup = num.substring(0, 2) + const accountName = mappingNameLookup.get(num) || `Konto ${num}` + const accountType = + accountClass === 1 ? 'asset' + : accountClass === 2 ? 'liability' + : accountClass === 3 ? 'revenue' + : 'expense' + const normalBalance = + accountClass <= 1 || accountClass >= 4 ? 'debit' : 'credit' + + return { + user_id: user.id, + company_id: companyId, + account_number: num, + account_name: accountName, + account_class: accountClass, + account_group: accountGroup, + account_type: accountType, + normal_balance: normalBalance, + plan_type: 'full_bas' as const, + is_active: true, + is_system_account: false, + description: accountName, + sru_code: null, + sort_order: parseInt(num), + } + }) + + if (accountsToActivate.length > 0) { + const { error: activateError } = await supabase + .from('chart_of_accounts') + .insert(accountsToActivate) + + if (activateError) { + return NextResponse.json({ + error: `Failed to activate accounts: ${activateError.message}`, + }, { status: 500 }) + } + log.info(`Auto-activated ${accountsToActivate.length} accounts`) + } + await saveMappings(supabase, user.id, mappings) - // Execute the import via core engine - const result = await executeSIEImport(supabase, ctx?.companyId ?? user.id, user.id, parsed, mappings, { + const result = await executeSIEImport(supabase, companyId, user.id, parsed, mappings, { filename: `migration-sie-${Date.now()}.se`, fileContent: rawContent, createFiscalPeriod: options.createFiscalPeriod, @@ -525,6 +827,8 @@ export const arcimMigrationExtension: Extension = { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const companyId = ctx?.companyId ?? user.id + const { consentId, importCompanyInfo = true, @@ -546,7 +850,6 @@ export const arcimMigrationExtension: Extension = { } try { - // Verify consent const consent = await getConsent(consentId) if (consent.status !== 1) { return NextResponse.json( @@ -559,8 +862,8 @@ export const arcimMigrationExtension: Extension = { const results = await executeMigration({ consentId, + companyId, userId: user.id, - companyId: ctx?.companyId ?? user.id, supabase, importCompanyInfo, importCustomers, @@ -604,7 +907,6 @@ export const arcimMigrationExtension: Extension = { 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) diff --git a/extensions/general/arcim-migration/lib/entity-mapper.ts b/extensions/general/arcim-migration/lib/entity-mapper.ts index 5c6d70b2..78e1b81a 100644 --- a/extensions/general/arcim-migration/lib/entity-mapper.ts +++ b/extensions/general/arcim-migration/lib/entity-mapper.ts @@ -16,7 +16,7 @@ import type { CompanyInformationDto, PostalAddress, PartyDto, -} from '../types' +} from '@/lib/providers/dto' // ── Helpers ───────────────────────────────────────────────────────── diff --git a/extensions/general/arcim-migration/lib/migration-orchestrator.ts b/extensions/general/arcim-migration/lib/migration-orchestrator.ts index 7047c8d7..2817b547 100644 --- a/extensions/general/arcim-migration/lib/migration-orchestrator.ts +++ b/extensions/general/arcim-migration/lib/migration-orchestrator.ts @@ -1,8 +1,8 @@ /** * Migration orchestrator — coordinates the data migration from - * an external accounting system via Arcim Sync into gnubok. + * an external accounting system directly via provider APIs into gnubok. * - * Bookkeeping data (accounts, balances, vouchers) is now imported + * Bookkeeping data (accounts, balances, vouchers) is imported * via SIE files through the core SIE import engine. This orchestrator * handles only entity-level imports: * 1. Company info → pre-fill company_settings @@ -14,13 +14,15 @@ import type { SupabaseClient } from '@supabase/supabase-js' import type { MigrationProgress, MigrationResults } from '../types' +import type { ProviderName } from '@/lib/providers/types' +import { resolveConsent } from '@/lib/providers/resolve-consent' import { - fetchCompanyInfo, - fetchCustomers, - fetchSuppliers, - fetchSalesInvoices, - fetchSupplierInvoices, -} from './arcim-client' + fetchCompanyInfoDirect, + fetchCustomersDirect, + fetchSuppliersDirect, + fetchSalesInvoicesDirect, + fetchSupplierInvoicesDirect, +} from '@/lib/providers/provider-data-fetcher' import { mapCustomer, mapSupplier, @@ -32,8 +34,8 @@ import { export interface MigrationOptions { consentId: string - userId: string companyId: string + userId: string supabase: SupabaseClient importCompanyInfo?: boolean importCustomers?: boolean @@ -50,15 +52,21 @@ function emitProgress(options: MigrationOptions, progress: MigrationProgress) { // ── Main orchestrator ───────────────────────────────────────────── export async function executeMigration(options: MigrationOptions): Promise { - const { consentId, userId, companyId, supabase } = options + const { consentId, companyId, userId, supabase } = options const results: MigrationResults = {} + // Resolve consent to get access token and provider + const resolved = await resolveConsent(companyId, consentId) + const provider = resolved.consent.provider as ProviderName + const accessToken = resolved.accessToken + const providerCompanyId = resolved.providerCompanyId + 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) + const companyInfo = await fetchCompanyInfoDirect(provider, accessToken, providerCompanyId) if (companyInfo) { const mapped = mapCompanyInfo(companyInfo) const { data: existing } = await supabase @@ -100,7 +108,7 @@ export async function executeMigration(options: MigrationOptions): Promise i.status === 'sent' || i.status === 'overdue' || i.status === 'booked' ) @@ -324,7 +332,7 @@ export async function executeMigration(options: MigrationOptions): Promise i.status === 'sent' || i.status === 'overdue' || i.status === 'booked' || i.status === 'draft' ) diff --git a/extensions/general/arcim-migration/lib/provider-client.ts b/extensions/general/arcim-migration/lib/provider-client.ts new file mode 100644 index 00000000..3d51240d --- /dev/null +++ b/extensions/general/arcim-migration/lib/provider-client.ts @@ -0,0 +1,262 @@ +/** + * Direct provider client — replaces arcim-client.ts. + * + * Instead of making HTTP calls to the Arcim Sync gateway, this module + * performs consent/OTC operations directly against Supabase and delegates + * data fetching to the provider clients in lib/providers/. + */ + +import { createServiceClient } from '@/lib/supabase/server' +import type { ProviderName } from '@/lib/providers/types' +import { getOAuthConfig } from '@/lib/providers/oauth-config' +import { buildFortnoxAuthUrl } from '@/lib/providers/fortnox/oauth' +import { exchangeFortnoxCode } from '@/lib/providers/fortnox/oauth' +import { buildVismaAuthUrl, exchangeVismaCode } from '@/lib/providers/visma/oauth' +import { refreshBjornLundenToken } from '@/lib/providers/bjornlunden/oauth' +import type { ConsentRecord, OtcResponse } from '../types' + +// Re-export data fetching functions from the provider layer +export { resolveConsent } from '@/lib/providers/resolve-consent' +export { + fetchCompanyInfoDirect, + fetchCustomersDirect, + fetchSuppliersDirect, + fetchSalesInvoicesDirect, + fetchSupplierInvoicesDirect, +} from '@/lib/providers/provider-data-fetcher' + +// ── Consent lifecycle (direct Supabase) ───────────────────────────── + +export async function createConsent( + companyId: string, + provider: ProviderName, + name: string, + orgNumber?: string, + companyName?: string, +): Promise { + const supabase = createServiceClient() + + const { data, error } = await supabase + .from('provider_consents') + .insert({ + company_id: companyId, + name, + provider, + org_number: orgNumber, + company_name: companyName, + status: 0, // Created + }) + .select('*') + .single() + + if (error || !data) { + throw new Error(`Failed to create consent: ${error?.message}`) + } + + return { + id: data.id, + name: data.name, + provider: data.provider as ProviderName, + status: data.status, + orgNumber: data.org_number, + companyName: data.company_name, + } +} + +export async function listConsents(companyId: string): Promise { + const supabase = createServiceClient() + + const { data, error } = await supabase + .from('provider_consents') + .select('*') + .eq('company_id', companyId) + .in('status', [0, 1]) // Created or Accepted + .order('created_at', { ascending: false }) + + if (error) { + throw new Error(`Failed to list consents: ${error.message}`) + } + + return (data ?? []).map(d => ({ + id: d.id, + name: d.name, + provider: d.provider as ProviderName, + status: d.status, + orgNumber: d.org_number, + companyName: d.company_name, + createdAt: d.created_at, + updatedAt: d.updated_at, + })) +} + +export async function getConsent(consentId: string): Promise { + const supabase = createServiceClient() + + const { data, error } = await supabase + .from('provider_consents') + .select('*') + .eq('id', consentId) + .single() + + if (error || !data) { + throw new Error(`Consent not found: ${error?.message}`) + } + + return { + id: data.id, + name: data.name, + provider: data.provider as ProviderName, + status: data.status, + orgNumber: data.org_number, + companyName: data.company_name, + } +} + +export async function deleteConsent(consentId: string): Promise { + const supabase = createServiceClient() + + // Delete tokens first (cascade should handle this, but be explicit) + await supabase.from('provider_consent_tokens').delete().eq('consent_id', consentId) + await supabase.from('provider_otc').delete().eq('consent_id', consentId) + + const { error } = await supabase + .from('provider_consents') + .delete() + .eq('id', consentId) + + if (error) { + throw new Error(`Failed to delete consent: ${error.message}`) + } +} + +export async function generateOtc( + consentId: string, + expiresInMinutes: number = 60, +): Promise { + const supabase = createServiceClient() + + const code = crypto.randomUUID().replace(/-/g, '').slice(0, 16) + const expiresAt = new Date(Date.now() + expiresInMinutes * 60 * 1000).toISOString() + + const { error } = await supabase + .from('provider_otc') + .insert({ + code, + consent_id: consentId, + expires_at: expiresAt, + }) + + if (error) { + throw new Error(`Failed to generate OTC: ${error.message}`) + } + + return { code, consentId, expiresAt } +} + +// ── OAuth helpers (direct provider calls) ─────────────────────────── + +export async function getAuthUrl( + provider: ProviderName, + state?: string, + redirectUri?: string, +): Promise<{ url: string }> { + const config = getOAuthConfig(provider) + + // Override redirect URI if provided (extension callback URL) + const effectiveConfig = redirectUri + ? { ...config, redirectUri } + : config + + if (provider === 'fortnox') { + const url = buildFortnoxAuthUrl(effectiveConfig, { state }) + return { url } + } + + if (provider === 'visma') { + const url = buildVismaAuthUrl(effectiveConfig, { state }) + return { url } + } + + throw new Error(`OAuth is not supported for provider: ${provider}`) +} + +export async function exchangeAuthToken( + consentId: string, + provider: ProviderName, + code: string, + redirectUri?: string, +): Promise<{ success: boolean; consentId: string }> { + const config = getOAuthConfig(provider) + const effectiveConfig = redirectUri ? { ...config, redirectUri } : config + const supabase = createServiceClient() + + let tokenResponse: { access_token: string; refresh_token: string; expires_in: number } + + if (provider === 'fortnox') { + tokenResponse = await exchangeFortnoxCode(effectiveConfig, code) + } else if (provider === 'visma') { + tokenResponse = await exchangeVismaCode(effectiveConfig, code) + } else { + throw new Error(`OAuth exchange not supported for provider: ${provider}`) + } + + const expiresAt = new Date(Date.now() + tokenResponse.expires_in * 1000).toISOString() + + // Store tokens + await supabase + .from('provider_consent_tokens') + .upsert({ + consent_id: consentId, + provider, + access_token: tokenResponse.access_token, + refresh_token: tokenResponse.refresh_token, + token_expires_at: expiresAt, + }) + + // Mark consent as accepted + await supabase + .from('provider_consents') + .update({ status: 1 }) + .eq('id', consentId) + + return { success: true, consentId } +} + +export async function submitProviderToken( + consentId: string, + provider: ProviderName, + apiToken: string, + companyId?: string, +): Promise<{ success: boolean; consentId: string }> { + const supabase = createServiceClient() + + let accessToken = apiToken + let tokenExpiresAt: string | null = null + + // BL uses client credentials — get a real token + if (provider === 'bjornlunden') { + const tokenResponse = await refreshBjornLundenToken() + accessToken = tokenResponse.access_token + tokenExpiresAt = new Date(Date.now() + tokenResponse.expires_in * 1000).toISOString() + } + + // Store tokens + await supabase + .from('provider_consent_tokens') + .upsert({ + consent_id: consentId, + provider, + access_token: accessToken, + refresh_token: null, + token_expires_at: tokenExpiresAt, + provider_company_id: companyId, + }) + + // Mark consent as accepted + await supabase + .from('provider_consents') + .update({ status: 1 }) + .eq('id', consentId) + + return { success: true, consentId } +} diff --git a/extensions/general/arcim-migration/manifest.json b/extensions/general/arcim-migration/manifest.json index 9538ec2e..63afd338 100644 --- a/extensions/general/arcim-migration/manifest.json +++ b/extensions/general/arcim-migration/manifest.json @@ -4,16 +4,28 @@ "exportName": "arcimMigrationExtension", "entryPoint": "@/extensions/general/arcim-migration", "workspace": "@/components/extensions/general/ArcimMigrationWorkspace", - "requiredEnvVars": ["ARCIM_SYNC_GATEWAY_URL", "ARCIM_SYNC_API_KEY"], - "optionalEnvVars": [], - "npmDependencies": [], + "requiredEnvVars": [], + "optionalEnvVars": [ + "FORTNOX_CLIENT_ID", + "FORTNOX_CLIENT_SECRET", + "FORTNOX_REDIRECT_URI", + "VISMA_CLIENT_ID", + "VISMA_CLIENT_SECRET", + "VISMA_REDIRECT_URI", + "BRIOX_CLIENT_ID", + "BJORN_LUNDEN_CLIENT_ID", + "BJORN_LUNDEN_CLIENT_SECRET", + "UPSTASH_REDIS_REST_URL", + "UPSTASH_REDIS_REST_TOKEN" + ], + "npmDependencies": ["@upstash/redis", "@upstash/ratelimit"], "definition": { - "name": "Systemmigration (Arcim Sync)", + "name": "Systemmigration", "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." + "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 direkt med leverantören." } } diff --git a/extensions/general/arcim-migration/types.ts b/extensions/general/arcim-migration/types.ts index 8c28aecf..fc62eccc 100644 --- a/extensions/general/arcim-migration/types.ts +++ b/extensions/general/arcim-migration/types.ts @@ -1,198 +1,34 @@ /** - * Types for the Arcim Sync migration extension. + * Types for the provider 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. + * DTO types are now imported from the canonical source at lib/providers/dto.ts + * instead of being duplicated here. */ -// ── Arcim Sync canonical DTOs (subset we consume) ────────────────── +// Re-export canonical DTOs used by entity-mapper and migration-orchestrator +export type { + AmountType, + PostalAddress, + Contact, + PartyIdentification, + PartyLegalEntity, + PartyDto, + PaginatedResponse, + TaxSubtotalDto, + TaxTotalDto, + LegalMonetaryTotalDto, + PaymentStatusDto, + CompanyInformationDto, + CustomerDto, + SupplierDto, + InvoiceStatusCode, + SalesInvoiceLineDto, + SalesInvoiceDto, + SupplierInvoiceLineDto, + SupplierInvoiceDto, +} from '@/lib/providers/dto' -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 { - 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 -} +export type { CustomerType as ArcimCustomerType } from '@/lib/providers/dto' // ── Supported providers ───────────────────────────────────────────── diff --git a/lib/extensions/_generated/sector-definitions.ts b/lib/extensions/_generated/sector-definitions.ts index 151da4ab..0bd3653e 100644 --- a/lib/extensions/_generated/sector-definitions.ts +++ b/lib/extensions/_generated/sector-definitions.ts @@ -32,13 +32,13 @@ export const EXTENSION_DEFINITIONS: Record = { }, { "slug": "arcim-migration", - "name": "Systemmigration (Arcim Sync)", + "name": "Systemmigration", "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." + "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 direkt med leverantören." }, { "slug": "tic", diff --git a/lib/providers/bjornlunden/client.ts b/lib/providers/bjornlunden/client.ts new file mode 100644 index 00000000..fc901ade --- /dev/null +++ b/lib/providers/bjornlunden/client.ts @@ -0,0 +1,107 @@ +import { TokenBucketRateLimiter } from '../rate-limiter'; +import { withRetry } from '../retry'; +import { BL_BASE_URL, BL_RATE_LIMIT } from './config'; + +export class BjornLundenApiError extends Error { + constructor( + message: string, + public readonly statusCode: number, + public readonly body?: string, + ) { + super(message); + this.name = 'BjornLundenApiError'; + } +} + +function isRetryableError(error: unknown): boolean { + if (error instanceof BjornLundenApiError) { + if (error.statusCode === 401 || error.statusCode === 403 || error.statusCode === 404) { + return false; + } + return error.statusCode === 429 || error.statusCode >= 500; + } + return false; +} + +interface BLPaginatedResponse { + pageRequested: number; + totalPages: number; + totalRows: number; + data: T[]; +} + +export class BjornLundenClient { + private readonly rateLimiter: TokenBucketRateLimiter; + private readonly baseUrl: string; + + constructor(baseUrl?: string) { + this.baseUrl = baseUrl ?? BL_BASE_URL; + this.rateLimiter = new TokenBucketRateLimiter(BL_RATE_LIMIT, 'ratelimit:bjornlunden'); + } + + async get(accessToken: string, userKey: string, path: string): Promise { + return withRetry( + async () => { + await this.rateLimiter.acquire(); + const url = `${this.baseUrl}${path}`; + const response = await fetch(url, { + headers: { + Authorization: `Bearer ${accessToken}`, + 'User-Key': userKey, + Accept: 'application/json', + }, + }); + + if (!response.ok) { + const body = await response.text().catch(() => ''); + throw new BjornLundenApiError( + `Björn Lunden API error: ${response.status} ${response.statusText}`, + response.status, + body, + ); + } + + return response.json() as Promise; + }, + { + maxAttempts: 3, + initialDelayMs: 1000, + shouldRetry: isRetryableError, + }, + ); + } + + async getPage( + accessToken: string, + userKey: string, + relativePath: string, + options?: { page?: number; pageSize?: number }, + ): Promise<{ items: T[]; page: number; totalPages: number; totalCount: number }> { + const params = new URLSearchParams(); + params.set('pageRequested', String(options?.page ?? 1)); + params.set('rowsRequested', String(options?.pageSize ?? 50)); + params.set('rows', String(options?.pageSize ?? 50)); + + const path = `${relativePath}?${params.toString()}`; + const response = await this.get>(accessToken, userKey, path); + + return { + items: Array.isArray(response.data) ? response.data : [], + page: response.pageRequested ?? (options?.page ?? 1), + totalPages: response.totalPages ?? 1, + totalCount: response.totalRows ?? 0, + }; + } + + async getAll(accessToken: string, userKey: string, path: string): Promise { + const response = await this.get>(accessToken, userKey, path); + if (Array.isArray(response)) { + return response; + } + return Array.isArray(response.data) ? response.data : []; + } + + async getDetail(accessToken: string, userKey: string, path: string): Promise { + return this.get(accessToken, userKey, path); + } +} diff --git a/lib/providers/bjornlunden/config.ts b/lib/providers/bjornlunden/config.ts new file mode 100644 index 00000000..fba19610 --- /dev/null +++ b/lib/providers/bjornlunden/config.ts @@ -0,0 +1,67 @@ +import { ResourceType } from '../dto'; +import type { BjornLundenResourceConfig, RateLimitConfig } from '../types'; +import { + mapBLToSalesInvoice, + mapBLToSupplierInvoice, + mapBLToCustomer, + mapBLToSupplier, + mapBLToJournal, + mapBLToAccountingAccount, + mapBLToCompanyInformation, +} from './mapper'; + +export const BL_BASE_URL = 'https://apigateway.blinfo.se/bla-api/v1/sp'; +export const BL_RATE_LIMIT: RateLimitConfig = { maxRequests: 10, windowMs: 1000 }; + +export const BL_RESOURCE_CONFIGS: Partial> = { + [ResourceType.SalesInvoices]: { + listEndpoint: '/customerinvoice/batch', + detailEndpoint: '/customerinvoice/{id}', + idField: 'invoiceNumber', + mapper: mapBLToSalesInvoice, + paginated: true, + }, + [ResourceType.SupplierInvoices]: { + listEndpoint: '/supplierinvoice/batch', + detailEndpoint: '/supplierinvoice/byId/{id}', + idField: 'entityId', + mapper: mapBLToSupplierInvoice, + paginated: true, + }, + [ResourceType.Customers]: { + listEndpoint: '/customer', + detailEndpoint: '/customer/{id}', + idField: 'id', + mapper: mapBLToCustomer, + paginated: false, + }, + [ResourceType.Suppliers]: { + listEndpoint: '/supplier', + detailEndpoint: '/supplier/{id}', + idField: 'id', + mapper: mapBLToSupplier, + paginated: false, + }, + [ResourceType.Journals]: { + listEndpoint: '/journal/entry/batch', + detailEndpoint: '/journal/entry/{id}', + idField: 'entityId', + mapper: mapBLToJournal, + paginated: true, + }, + [ResourceType.AccountingAccounts]: { + listEndpoint: '/account', + detailEndpoint: '/account/{id}', + idField: 'id', + mapper: mapBLToAccountingAccount, + paginated: false, + }, + [ResourceType.CompanyInformation]: { + listEndpoint: '/details', + detailEndpoint: '', + idField: '', + mapper: mapBLToCompanyInformation, + singleton: true, + paginated: false, + }, +}; diff --git a/lib/providers/bjornlunden/mapper.ts b/lib/providers/bjornlunden/mapper.ts new file mode 100644 index 00000000..ef22ecf3 --- /dev/null +++ b/lib/providers/bjornlunden/mapper.ts @@ -0,0 +1,302 @@ +import type { + SalesInvoiceDto, SalesInvoiceLineDto, InvoiceStatusCode, + LegalMonetaryTotalDto, PaymentStatusDto, + SupplierInvoiceDto, + CustomerDto, + SupplierDto, + JournalDto, AccountingEntryDto, + AccountingAccountDto, AccountType, + CompanyInformationDto, + AmountType, PartyDto, +} from '../dto'; + +function amount(value: number | undefined | null, currency: string = 'SEK'): AmountType { + return { value: value ?? 0, currencyCode: currency }; +} + +function deriveBLInvoiceStatus(raw: Record): InvoiceStatusCode { + if (raw['paid'] === true) return 'paid'; + if (raw['preliminary'] === true) return 'draft'; + const status = raw['status'] != null ? String(raw['status']).toLowerCase() : undefined; + if (status === 'cancelled') return 'cancelled'; + if (status === 'credited') return 'credited'; + if (status === 'sent') return 'sent'; + return 'booked'; +} + +/** + * Map BL Customer Invoice to SalesInvoiceDto. + * + * BL fields: entityId, invoiceNumber, invoiceDate, dueDate, currency, + * customerId, customerName, amount, amountInLocalCurrency, + * amountPaidInLocalCurrency, paid, preliminary, status + */ +export function mapBLToSalesInvoice(raw: Record): SalesInvoiceDto { + const currency = (raw['currency'] as string) ?? 'SEK'; + const totalAmount = (raw['amountInLocalCurrency'] as number) ?? (raw['amount'] as number) ?? 0; + const paidAmount = (raw['amountPaidInLocalCurrency'] as number) ?? 0; + const balance = totalAmount - paidAmount; + + const customer: PartyDto = { + name: (raw['customerName'] as string) ?? '', + identifications: raw['customerId'] ? [{ id: String(raw['customerId']), schemeId: 'BL:CUSTOMER_ID' }] : [], + }; + + const legalMonetaryTotal: LegalMonetaryTotalDto = { + lineExtensionAmount: amount(totalAmount, currency), + taxInclusiveAmount: amount(totalAmount, currency), + payableAmount: amount(totalAmount, currency), + }; + + const paymentStatus: PaymentStatusDto = { + paid: raw['paid'] === true, + balance: amount(balance, currency), + }; + + return { + id: String(raw['entityId'] ?? raw['invoiceNumber'] ?? ''), + invoiceNumber: String(raw['invoiceNumber'] ?? ''), + issueDate: (raw['invoiceDate'] as string) ?? '', + dueDate: raw['dueDate'] as string | undefined, + currencyCode: currency, + status: deriveBLInvoiceStatus(raw), + supplier: { name: '', identifications: [] }, + customer, + lines: [], // BL doesn't include line items in list responses + legalMonetaryTotal, + paymentStatus, + _raw: raw, + }; +} + +/** + * Map BL Supplier Invoice to SupplierInvoiceDto. + * + * BL fields: entityId, invoiceNumber, invoiceDate, dueDate, currency, + * supplierId, supplierName, amountInLocalCurrency, + * amountPaidInLocalCurrency, amountRemainingInLocalCurrency, paid, preliminary, status + */ +export function mapBLToSupplierInvoice(raw: Record): SupplierInvoiceDto { + const currency = (raw['currency'] as string) ?? 'SEK'; + const totalAmount = (raw['amountInLocalCurrency'] as number) ?? 0; + const paidAmount = (raw['amountPaidInLocalCurrency'] as number) ?? 0; + const remaining = (raw['amountRemainingInLocalCurrency'] as number) ?? (totalAmount - paidAmount); + + const supplier: PartyDto = { + name: (raw['supplierName'] as string) ?? '', + identifications: raw['supplierId'] ? [{ id: String(raw['supplierId']), schemeId: 'BL:SUPPLIER_ID' }] : [], + }; + + const legalMonetaryTotal: LegalMonetaryTotalDto = { + lineExtensionAmount: amount(totalAmount, currency), + taxInclusiveAmount: amount(totalAmount, currency), + payableAmount: amount(totalAmount, currency), + }; + + const paymentStatus: PaymentStatusDto = { + paid: raw['paid'] === true, + balance: amount(remaining, currency), + }; + + return { + id: String(raw['entityId'] ?? raw['invoiceNumber'] ?? ''), + invoiceNumber: String(raw['invoiceNumber'] ?? ''), + issueDate: (raw['invoiceDate'] as string) ?? '', + dueDate: raw['dueDate'] as string | undefined, + currencyCode: currency, + status: deriveBLInvoiceStatus(raw), + supplier, + buyer: { name: '', identifications: [] }, + lines: [], // BL doesn't include line items in list responses + legalMonetaryTotal, + paymentStatus, + _raw: raw, + }; +} + +/** + * Map BL Customer to CustomerDto. + * + * BL fields: entityId, id, name, organisationNumber, street, box, zip, city, + * country, phone, email, currency, vatNumber, paymentTerms, closed + */ +export function mapBLToCustomer(raw: Record): CustomerDto { + const name = (raw['name'] as string) ?? ''; + const orgNumber = raw['organisationNumber'] as string | undefined; + + const party: PartyDto = { + name, + identifications: orgNumber ? [{ id: orgNumber, schemeId: 'SE:ORGNR' }] : [], + postalAddress: { + streetName: raw['street'] as string | undefined, + additionalStreetName: raw['box'] as string | undefined, + postalZone: raw['zip'] as string | undefined, + cityName: raw['city'] as string | undefined, + countryCode: raw['country'] as string | undefined, + }, + legalEntity: orgNumber ? { + registrationName: name, + companyId: orgNumber, + companyIdSchemeId: 'SE:ORGNR', + } : undefined, + contact: { + telephone: raw['phone'] as string | undefined, + email: raw['email'] as string | undefined, + }, + }; + + return { + id: String(raw['id'] ?? raw['entityId'] ?? ''), + customerNumber: String(raw['id'] ?? ''), + type: 'company', + party, + active: raw['closed'] !== true, + vatNumber: raw['vatNumber'] as string | undefined, + defaultPaymentTermsDays: raw['paymentTerms'] != null ? Number(raw['paymentTerms']) : undefined, + _raw: raw, + }; +} + +/** + * Map BL Supplier to SupplierDto. + * + * BL fields: entityId, id, name, organisationId, address1, address2, zipCode, city, + * countryCode, phone, email, bg, pg, iban, vatNr, paymentTerms, closed + */ +export function mapBLToSupplier(raw: Record): SupplierDto { + const name = (raw['name'] as string) ?? ''; + const orgNumber = raw['organisationId'] as string | undefined; + + const party: PartyDto = { + name, + identifications: orgNumber ? [{ id: orgNumber, schemeId: 'SE:ORGNR' }] : [], + postalAddress: { + streetName: raw['address1'] as string | undefined, + additionalStreetName: raw['address2'] as string | undefined, + postalZone: raw['zipCode'] as string | undefined, + cityName: raw['city'] as string | undefined, + countryCode: raw['countryCode'] as string | undefined, + }, + legalEntity: orgNumber ? { + registrationName: name, + companyId: orgNumber, + companyIdSchemeId: 'SE:ORGNR', + } : undefined, + contact: { + telephone: raw['phone'] as string | undefined, + email: raw['email'] as string | undefined, + }, + }; + + return { + id: String(raw['id'] ?? raw['entityId'] ?? ''), + supplierNumber: String(raw['id'] ?? ''), + party, + active: raw['closed'] !== true, + vatNumber: raw['vatNr'] as string | undefined, + bankGiro: raw['bg'] as string | undefined, + plusGiro: raw['pg'] as string | undefined, + bankAccount: raw['iban'] as string | undefined, + defaultPaymentTermsDays: raw['paymentTerms'] != null ? Number(raw['paymentTerms']) : undefined, + _raw: raw, + }; +} + +/** + * Map BL Journal/Ledger Entry to JournalDto. + * + * BL fields: entityId, journalId, journalEntryId, journalEntryDate, + * journalEntryText, financialYearId, ledgerEntries[{ accountId, amount, text }], + * totalCreditSum, totalDebitSum + */ +export function mapBLToJournal(raw: Record): JournalDto { + const rawEntries = (raw['ledgerEntries'] as Record[] | undefined) ?? []; + const entries: AccountingEntryDto[] = rawEntries.map((entry) => { + // BL LedgerEntry has a single `amount` field: positive = debit, negative = credit + const amt = (entry['amount'] as number) ?? 0; + return { + accountNumber: String(entry['accountId'] ?? ''), + debit: amt > 0 ? amt : 0, + credit: amt < 0 ? Math.abs(amt) : 0, + description: entry['text'] as string | undefined, + }; + }); + + const totalCredit = (raw['totalCreditSum'] as number) ?? 0; + const totalDebit = (raw['totalDebitSum'] as number) ?? entries.reduce((sum, e) => sum + e.debit, 0); + + return { + id: String(raw['entityId'] ?? raw['journalEntryId'] ?? ''), + journalNumber: String(raw['journalId'] ?? raw['journalEntryId'] ?? ''), + description: raw['journalEntryText'] as string | undefined, + registrationDate: (raw['journalEntryDate'] as string) ?? '', + fiscalYear: raw['financialYearId'] != null ? Number(raw['financialYearId']) : undefined, + entries, + totalDebit: { value: totalDebit, currencyCode: 'SEK' }, + totalCredit: { value: totalCredit, currencyCode: 'SEK' }, + _raw: raw, + }; +} + +/** + * Map BL Account to AccountingAccountDto. + * + * BL fields: entityId, id (account number), name, vatCode, sruCode, closed, type + * Type derived from BAS plan number ranges. + */ +export function mapBLToAccountingAccount(raw: Record): AccountingAccountDto { + const num = Number(raw['id']); + + let type: AccountType | undefined; + if (num >= 1000 && num < 2000) type = 'asset'; + else if (num >= 2000 && num < 3000) type = 'liability'; + else if (num >= 3000 && num < 4000) type = 'revenue'; + else if (num >= 4000 && num < 9000) type = 'expense'; + + return { + accountNumber: String(raw['id'] ?? ''), + name: (raw['name'] as string) ?? '', + type, + vatCode: raw['vatCode'] as string | undefined, + sruCode: raw['sruCode'] != null ? String(raw['sruCode']) : undefined, + active: raw['closed'] !== true, + balanceCarriedForward: (raw['debit'] != null || raw['credit'] != null) + ? (Number(raw['debit'] ?? 0) - Number(raw['credit'] ?? 0)) + : undefined, + _raw: raw, + }; +} + +/** + * Map BL Company Details to CompanyInformationDto. + * + * BL fields: name, orgNumber, street, box, zip, city, country, + * phone, email, bg, pg, iban, vatNumber, preferredSettings.currency + */ +export function mapBLToCompanyInformation(raw: Record): CompanyInformationDto { + const settings = raw['preferredSettings'] as Record | undefined; + + return { + companyName: (raw['name'] as string) ?? '', + organizationNumber: raw['orgNumber'] as string | undefined, + legalEntity: { + registrationName: (raw['name'] as string) ?? '', + companyId: raw['orgNumber'] as string | undefined, + companyIdSchemeId: 'SE:ORGNR', + }, + address: { + streetName: raw['street'] as string | undefined, + additionalStreetName: raw['box'] as string | undefined, + postalZone: raw['zip'] as string | undefined, + cityName: raw['city'] as string | undefined, + countryCode: raw['country'] as string | undefined, + }, + contact: { + telephone: raw['phone'] as string | undefined, + email: raw['email'] as string | undefined, + }, + vatNumber: raw['vatNumber'] as string | undefined, + baseCurrency: (settings?.['currency'] as string) ?? 'SEK', + _raw: raw, + }; +} diff --git a/lib/providers/bjornlunden/oauth.ts b/lib/providers/bjornlunden/oauth.ts new file mode 100644 index 00000000..4f615a53 --- /dev/null +++ b/lib/providers/bjornlunden/oauth.ts @@ -0,0 +1,44 @@ +import type { TokenResponse } from '../types'; + +const BL_AUTH_URL = 'https://apigateway.blinfo.se/auth/oauth/v2/token'; + +export async function fetchBjornLundenToken( + clientId: string, + clientSecret: string, +): Promise { + const response = await fetch(BL_AUTH_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ + grant_type: 'client_credentials', + client_id: clientId, + client_secret: clientSecret, + }).toString(), + }); + + if (!response.ok) { + const body = await response.text().catch(() => ''); + throw new Error(`Björn Lunden token request failed: ${response.status} ${body}`); + } + + const data = await response.json() as Record; + return { + access_token: data.access_token as string, + refresh_token: '', + token_type: (data.token_type as string) ?? 'Bearer', + expires_in: (data.expires_in as number) ?? 3600, + }; +} + +export async function refreshBjornLundenToken(): Promise { + const clientId = process.env.BJORN_LUNDEN_CLIENT_ID ?? ''; + const clientSecret = process.env.BJORN_LUNDEN_CLIENT_SECRET ?? ''; + if (!clientId || !clientSecret) { + throw new Error('BJORN_LUNDEN_CLIENT_ID and BJORN_LUNDEN_CLIENT_SECRET must be set'); + } + return fetchBjornLundenToken(clientId, clientSecret); +} + +export const storeBjornLundenToken = refreshBjornLundenToken; diff --git a/lib/providers/bokio/client.ts b/lib/providers/bokio/client.ts new file mode 100644 index 00000000..b58fdb6a --- /dev/null +++ b/lib/providers/bokio/client.ts @@ -0,0 +1,149 @@ +import { TokenBucketRateLimiter } from '../rate-limiter'; +import { withRetry } from '../retry'; +import { BOKIO_BASE_URL, BOKIO_RATE_LIMIT } from './config'; + +export class BokioApiError extends Error { + constructor( + message: string, + public readonly statusCode: number, + public readonly body?: string, + ) { + super(message); + this.name = 'BokioApiError'; + } +} + +function isRetryableError(error: unknown): boolean { + if (error instanceof BokioApiError) { + if (error.statusCode === 401 || error.statusCode === 403 || error.statusCode === 404) { + return false; + } + return error.statusCode === 429 || error.statusCode >= 500; + } + return false; +} + +interface BokioPaginatedResponse { + items: T[]; + totalItems: number; + totalPages: number; + currentPage: number; +} + +export class BokioClient { + private readonly rateLimiter: TokenBucketRateLimiter; + private readonly baseUrl: string; + + constructor(baseUrl?: string) { + this.baseUrl = baseUrl ?? BOKIO_BASE_URL; + this.rateLimiter = new TokenBucketRateLimiter(BOKIO_RATE_LIMIT, 'ratelimit:bokio'); + } + + async get(accessToken: string, path: string): Promise { + return withRetry( + async () => { + await this.rateLimiter.acquire(); + const url = `${this.baseUrl}${path}`; + const response = await fetch(url, { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/json', + }, + }); + + if (!response.ok) { + const body = await response.text().catch(() => ''); + throw new BokioApiError( + `Bokio API error: ${response.status} ${response.statusText}`, + response.status, + body, + ); + } + + return await response.json() as T; + }, + { + maxAttempts: 3, + initialDelayMs: 1000, + shouldRetry: isRetryableError, + }, + ); + } + + /** + * Fetch a paginated list endpoint. + * Bokio returns `{ data: [...], pagination: { page, pageSize, totalPages, totalCount } }`. + */ + async getPage( + accessToken: string, + companyId: string, + relativePath: string, + options?: { + page?: number; + pageSize?: number; + query?: string; + }, + ): Promise<{ items: T[]; page: number; totalPages: number; totalCount: number }> { + const params = new URLSearchParams(); + params.set('page', String(options?.page ?? 1)); + params.set('pageSize', String(options?.pageSize ?? 50)); + if (options?.query) { + params.set('query', options.query); + } + + const path = `/companies/${companyId}${relativePath}?${params.toString()}`; + const response = await this.get>(accessToken, path); + + return { + items: Array.isArray(response.items) ? response.items : [], + page: response.currentPage ?? (options?.page ?? 1), + totalPages: response.totalPages ?? 1, + totalCount: response.totalItems ?? 0, + }; + } + + /** + * Fetch a non-paginated list endpoint (e.g. chart-of-accounts). + * Returns the full `data` array. + */ + async getAll( + accessToken: string, + companyId: string, + relativePath: string, + ): Promise { + const path = `/companies/${companyId}${relativePath}`; + const response = await this.get(accessToken, path); + // Bokio returns a raw array for some endpoints (e.g. chart-of-accounts) + if (Array.isArray(response)) { + return response; + } + return Array.isArray(response.items) ? response.items : []; + } + + /** + * Fetch a single resource detail. + * Bokio returns the object directly (no wrapper). + */ + async getDetail( + accessToken: string, + companyId: string, + relativePath: string, + ): Promise { + const path = `/companies/${companyId}${relativePath}`; + return this.get(accessToken, path); + } + + async getCompany( + accessToken: string, + companyId: string, + ): Promise { + try { + return await this.get(accessToken, `/companies/${companyId}`); + } catch (err) { + if (err instanceof BokioApiError && err.statusCode === 404) { + return null; + } + throw err; + } + } +} diff --git a/lib/providers/bokio/config.ts b/lib/providers/bokio/config.ts new file mode 100644 index 00000000..9482830e --- /dev/null +++ b/lib/providers/bokio/config.ts @@ -0,0 +1,51 @@ +import { ResourceType } from '../dto'; +import type { BokioResourceConfig, RateLimitConfig } from '../types'; +import { + mapBokioToSalesInvoice, + mapBokioToCustomer, + mapBokioToJournal, + mapBokioToAccountingAccount, + mapBokioToCompanyInformation, +} from './mapper'; + +export const BOKIO_BASE_URL = 'https://api.bokio.se/v1'; +export const BOKIO_RATE_LIMIT: RateLimitConfig = { maxRequests: 5, windowMs: 1000 }; + +export const BOKIO_RESOURCE_CONFIGS: Partial> = { + [ResourceType.SalesInvoices]: { + listEndpoint: '/invoices', + detailEndpoint: '/invoices/{id}', + idField: 'id', + mapper: mapBokioToSalesInvoice, + paginated: true, + }, + [ResourceType.Customers]: { + listEndpoint: '/customers', + detailEndpoint: '/customers/{id}', + idField: 'id', + mapper: mapBokioToCustomer, + paginated: true, + }, + [ResourceType.Journals]: { + listEndpoint: '/journal-entries', + detailEndpoint: '/journal-entries/{id}', + idField: 'id', + mapper: mapBokioToJournal, + paginated: true, + }, + [ResourceType.AccountingAccounts]: { + listEndpoint: '/chart-of-accounts', + detailEndpoint: '/chart-of-accounts/{id}', + idField: 'number', + mapper: mapBokioToAccountingAccount, + paginated: false, + }, + [ResourceType.CompanyInformation]: { + listEndpoint: '', + detailEndpoint: '', + idField: 'id', + mapper: mapBokioToCompanyInformation, + singleton: true, + paginated: false, + }, +}; diff --git a/lib/providers/bokio/mapper.ts b/lib/providers/bokio/mapper.ts new file mode 100644 index 00000000..98df69cb --- /dev/null +++ b/lib/providers/bokio/mapper.ts @@ -0,0 +1,227 @@ +import type { + SalesInvoiceDto, SalesInvoiceLineDto, InvoiceStatusCode, + LegalMonetaryTotalDto, PaymentStatusDto, + CustomerDto, + JournalDto, AccountingEntryDto, + AccountingAccountDto, AccountType, + CompanyInformationDto, + AmountType, PartyDto, +} from '../dto'; + +function amount(value: number | undefined | null, currency: string = 'SEK'): AmountType { + return { value: value ?? 0, currencyCode: currency }; +} + +function deriveInvoiceStatus(raw: Record): InvoiceStatusCode { + const status = (raw['status'] as string | undefined)?.toLowerCase(); + if (status === 'cancelled') return 'cancelled'; + if (status === 'paid') return 'paid'; + if (status === 'overdue') return 'overdue'; + if (status === 'published') return 'sent'; + if (status === 'draft') return 'draft'; + return 'draft'; +} + +function buildParty(name: string, orgNumber?: string, address?: Record): PartyDto { + return { + name, + identifications: orgNumber ? [{ id: orgNumber, schemeId: 'SE:ORGNR' }] : [], + postalAddress: address ? { + streetName: address['line1'] as string | undefined, + additionalStreetName: address['line2'] as string | undefined, + cityName: address['city'] as string | undefined, + postalZone: address['postalCode'] as string | undefined, + countryCode: address['country'] as string | undefined, + } : undefined, + legalEntity: orgNumber ? { + registrationName: name, + companyId: orgNumber, + companyIdSchemeId: 'SE:ORGNR', + } : undefined, + }; +} + +/** + * Map Bokio Invoice to SalesInvoiceDto. + * + * Bokio Invoice fields: + * - id, invoiceNumber, status (draft|published|paid|overdue|cancelled) + * - invoiceDate, dueDate, currency, totalAmount, totalTax, paidAmount + * - customerRef: { id, name }, lineItems: [{ id, description, quantity, unitPrice, taxRate, unitType }] + */ +export function mapBokioToSalesInvoice(raw: Record): SalesInvoiceDto { + const currency = (raw['currency'] as string) ?? 'SEK'; + const totalAmount = (raw['totalAmount'] as number) ?? 0; + const totalTax = (raw['totalTax'] as number) ?? 0; + const paidAmount = (raw['paidAmount'] as number) ?? 0; + const balance = totalAmount - paidAmount; + + const customerRef = raw['customerRef'] as Record | undefined; + const rawLines = (raw['lineItems'] as Record[] | undefined) ?? []; + + const lines: SalesInvoiceLineDto[] = rawLines.map((line, idx) => { + const unitPrice = line['unitPrice'] as number | undefined; + const quantity = line['quantity'] as number | undefined; + const lineTotal = unitPrice != null && quantity != null ? unitPrice * quantity : 0; + + return { + id: String(line['id'] ?? idx + 1), + description: line['description'] as string | undefined, + quantity, + unitCode: line['unitType'] as string | undefined, + unitPrice: unitPrice != null ? amount(unitPrice, currency) : undefined, + lineExtensionAmount: amount(lineTotal, currency), + taxPercent: line['taxRate'] as number | undefined, + }; + }); + + const legalMonetaryTotal: LegalMonetaryTotalDto = { + lineExtensionAmount: amount(totalAmount - totalTax, currency), + taxInclusiveAmount: amount(totalAmount, currency), + payableAmount: amount(totalAmount, currency), + }; + + const paymentStatus: PaymentStatusDto = { + paid: paidAmount >= totalAmount && totalAmount > 0, + balance: amount(balance, currency), + }; + + return { + id: String(raw['id'] ?? ''), + invoiceNumber: String(raw['invoiceNumber'] ?? raw['id'] ?? ''), + issueDate: (raw['invoiceDate'] as string) ?? '', + dueDate: raw['dueDate'] as string | undefined, + currencyCode: currency, + status: deriveInvoiceStatus(raw), + supplier: buildParty(''), + customer: buildParty( + (customerRef?.['name'] as string) ?? '', + ), + lines, + legalMonetaryTotal, + paymentStatus, + _raw: raw, + }; +} + +/** + * Map Bokio Customer to CustomerDto. + * + * Bokio Customer fields: + * - id, name, type (company|individual), orgNumber, vatNumber, paymentTerms + * - address: { line1, line2, city, postalCode, country } + * - contactsDetails: [{ email, phone, name }] + */ +export function mapBokioToCustomer(raw: Record): CustomerDto { + const name = (raw['name'] as string) ?? ''; + const orgNumber = raw['orgNumber'] as string | undefined; + const address = raw['address'] as Record | undefined; + const contacts = (raw['contactsDetails'] as Record[] | undefined) ?? []; + const firstContact = contacts[0]; + + const party = buildParty(name, orgNumber, address); + if (firstContact) { + party.contact = { + email: firstContact['email'] as string | undefined, + telephone: firstContact['phone'] as string | undefined, + name: firstContact['name'] as string | undefined, + }; + } + + return { + id: String(raw['id'] ?? ''), + customerNumber: String(raw['id'] ?? ''), + type: raw['type'] === 'individual' ? 'private' : 'company', + party, + active: true, + vatNumber: raw['vatNumber'] as string | undefined, + defaultPaymentTermsDays: raw['paymentTerms'] != null ? Number(raw['paymentTerms']) : undefined, + _raw: raw, + }; +} + +/** + * Map Bokio JournalEntry to JournalDto. + * + * Bokio JournalEntry fields: + * - id, date, title, number (int), createdAt + * - items: [{ accountNumber (int), debit, credit, description }] + */ +export function mapBokioToJournal(raw: Record): JournalDto { + const rawItems = (raw['items'] as Record[] | undefined) ?? []; + const entries: AccountingEntryDto[] = rawItems.map((item) => ({ + accountNumber: String(item['account'] ?? item['accountNumber'] ?? ''), + debit: (item['debit'] as number) ?? 0, + credit: (item['credit'] as number) ?? 0, + description: item['description'] as string | undefined, + })); + + return { + id: String(raw['id'] ?? ''), + journalNumber: String(raw['journalEntryNumber'] ?? raw['number'] ?? raw['id'] ?? ''), + description: raw['title'] as string | undefined, + registrationDate: (raw['date'] as string) ?? '', + entries, + createdAt: raw['createdAt'] as string | undefined, + _raw: raw, + }; +} + +/** + * Map Bokio Account to AccountingAccountDto. + * + * Bokio Account fields: + * - number (int, used as ID), name, category (asset|liability|income|cost), isActive + */ +export function mapBokioToAccountingAccount(raw: Record): AccountingAccountDto { + // Bokio uses 'account' (int) as field name, not 'number' or 'accountNumber' + const rawNum = raw['account'] ?? raw['accountNumber'] ?? raw['number']; + const num = Number(rawNum); + + // Bokio returns accountType: 'basePlanAccount' — derive type from BAS plan number range + let type: AccountType | undefined; + if (num >= 1000 && num < 2000) type = 'asset'; + else if (num >= 2000 && num < 3000) type = 'liability'; + else if (num >= 3000 && num < 4000) type = 'revenue'; + else if (num >= 4000 && num < 9000) type = 'expense'; + + return { + accountNumber: String(rawNum ?? ''), + name: (raw['name'] as string) ?? '', + type, + active: raw['isActive'] !== false, + balanceCarriedForward: raw['accountBalance'] != null ? Number(raw['accountBalance']) : undefined, + _raw: raw, + }; +} + +/** + * Map Bokio Company to CompanyInformationDto. + * + * Bokio Company fields: + * - id, name, orgNumber, vatNumber, currency, country + * - address: { line1, line2, city, postalCode, country } + */ +export function mapBokioToCompanyInformation(raw: Record): CompanyInformationDto { + const address = raw['address'] as Record | undefined; + + return { + companyName: (raw['name'] as string) ?? '', + organizationNumber: raw['orgNumber'] as string | undefined, + legalEntity: { + registrationName: (raw['name'] as string) ?? '', + companyId: raw['orgNumber'] as string | undefined, + companyIdSchemeId: 'SE:ORGNR', + }, + address: address ? { + streetName: address['line1'] as string | undefined, + additionalStreetName: address['line2'] as string | undefined, + cityName: address['city'] as string | undefined, + postalZone: address['postalCode'] as string | undefined, + countryCode: address['country'] as string | undefined, + } : undefined, + vatNumber: raw['vatNumber'] as string | undefined, + baseCurrency: raw['currency'] as string | undefined, + _raw: raw, + }; +} diff --git a/lib/providers/bokio/oauth.ts b/lib/providers/bokio/oauth.ts new file mode 100644 index 00000000..dac68554 --- /dev/null +++ b/lib/providers/bokio/oauth.ts @@ -0,0 +1,10 @@ +import type { TokenResponse } from '../types'; + +export function storeBokioToken(apiToken: string): TokenResponse { + return { + access_token: apiToken, + refresh_token: '', + token_type: 'Bearer', + expires_in: 0, + }; +} diff --git a/lib/providers/briox/client.ts b/lib/providers/briox/client.ts new file mode 100644 index 00000000..e6ce890f --- /dev/null +++ b/lib/providers/briox/client.ts @@ -0,0 +1,159 @@ +import { TokenBucketRateLimiter } from '../rate-limiter'; +import { withRetry } from '../retry'; +import { BRIOX_BASE_URL, BRIOX_RATE_LIMIT } from './config'; + +export class BrioxApiError extends Error { + constructor( + message: string, + public readonly statusCode: number, + public readonly body?: string, + ) { + super(message); + this.name = 'BrioxApiError'; + } +} + +function isRetryableError(error: unknown): boolean { + if (error instanceof BrioxApiError) { + if (error.statusCode === 401 || error.statusCode === 403 || error.statusCode === 404) { + return false; + } + return error.statusCode === 429 || error.statusCode >= 500; + } + return false; +} + +interface BrioxListResponse { + data: Record & { + metainformation?: { + total_pages: number; + current_page: number; + total_count: number; + }; + }; +} + +export class BrioxClient { + private readonly rateLimiter: TokenBucketRateLimiter; + private readonly baseUrl: string; + + constructor(baseUrl?: string) { + this.baseUrl = baseUrl ?? BRIOX_BASE_URL; + this.rateLimiter = new TokenBucketRateLimiter(BRIOX_RATE_LIMIT, 'ratelimit:briox'); + } + + async get(accessToken: string, path: string): Promise { + return withRetry( + async () => { + await this.rateLimiter.acquire(); + const url = `${this.baseUrl}${path}`; + const response = await fetch(url, { + headers: { + Authorization: accessToken, + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + const body = await response.text().catch(() => ''); + throw new BrioxApiError( + `Briox API error: ${response.status} ${response.statusText}`, + response.status, + body, + ); + } + + return response.json() as Promise; + }, + { + maxAttempts: 3, + initialDelayMs: 1000, + shouldRetry: isRetryableError, + }, + ); + } + + async getPage( + accessToken: string, + path: string, + listKey: string, + options?: { + page?: number; + pageSize?: number; + fromModifiedDate?: string; + }, + ): Promise<{ items: T[]; page: number; totalPages: number; totalCount: number }> { + const params = new URLSearchParams(); + params.set('page', String(options?.page ?? 1)); + if (options?.pageSize) { + params.set('limit', String(options.pageSize)); + } + if (options?.fromModifiedDate) { + params.set('frommodifieddate', options.fromModifiedDate); + } + + const separator = path.includes('?') ? '&' : '?'; + const fullPath = `${path}${separator}${params.toString()}`; + + const response = await this.get(accessToken, fullPath); + + const meta = response.data?.metainformation; + const totalPages = meta?.total_pages ?? 1; + const currentPage = meta?.current_page ?? (options?.page ?? 1); + const totalCount = meta?.total_count ?? 0; + + const items = listKey ? response.data?.[listKey] : response.data; + + return { + items: Array.isArray(items) ? items as T[] : [], + page: currentPage, + totalPages, + totalCount, + }; + } + + async getPaginated( + accessToken: string, + path: string, + listKey: string, + options?: { + fromModifiedDate?: string; + pageSize?: number; + }, + ): Promise { + const allItems: T[] = []; + let page = 1; + let totalPages = 1; + + do { + const result = await this.getPage(accessToken, path, listKey, { + page, + pageSize: options?.pageSize, + fromModifiedDate: options?.fromModifiedDate, + }); + + allItems.push(...result.items); + totalPages = result.totalPages; + page++; + } while (page <= totalPages); + + return allItems; + } + + async getCurrentFinancialYear(accessToken: string): Promise { + const response = await this.get<{ + data: { + financialyears: { id: string; fromdate: string; todate: string }[]; + }; + }>(accessToken, '/financialyear'); + + const years = response.data?.financialyears ?? []; + if (years.length === 0) { + throw new BrioxApiError('No financial years found in Briox', 404); + } + const now = new Date().toISOString().slice(0, 10); + const completed = years.filter((y) => y.todate < now); + return completed.length > 0 ? completed[completed.length - 1]!.id : years[0]!.id; + } +} diff --git a/lib/providers/briox/config.ts b/lib/providers/briox/config.ts new file mode 100644 index 00000000..47486b8c --- /dev/null +++ b/lib/providers/briox/config.ts @@ -0,0 +1,79 @@ +import { ResourceType } from '../dto'; +import type { BrioxResourceConfig, RateLimitConfig } from '../types'; +import { + mapBrioxToSalesInvoice, + mapBrioxToSupplierInvoice, + mapBrioxToCustomer, + mapBrioxToSupplier, + mapBrioxToJournal, + mapBrioxToAccountingAccount, + mapBrioxToCompanyInformation, +} from './mapper'; + +export const BRIOX_BASE_URL = 'https://api-se.briox.services/v2'; +export const BRIOX_TOKEN_URL = 'https://api-se.briox.services/v2/token'; +export const BRIOX_REFRESH_URL = 'https://api-se.briox.services/v2/tokenrefresh'; +export const BRIOX_RATE_LIMIT: RateLimitConfig = { maxRequests: 10, windowMs: 1000 }; + +export const BRIOX_RESOURCE_CONFIGS: Partial> = { + [ResourceType.SalesInvoices]: { + listEndpoint: '/customerinvoice', + detailEndpoint: '/customerinvoice/{id}', + listKey: 'invoices', + idField: 'id', + mapper: mapBrioxToSalesInvoice, + supportsModifiedFilter: true, + }, + [ResourceType.SupplierInvoices]: { + listEndpoint: '/supplierinvoice', + detailEndpoint: '/supplierinvoice/{id}', + listKey: 'supplierinvoices', + idField: 'id', + mapper: mapBrioxToSupplierInvoice, + supportsModifiedFilter: true, + }, + [ResourceType.Customers]: { + listEndpoint: '/customer', + detailEndpoint: '/customer/{id}', + listKey: 'customers', + idField: 'id', + mapper: mapBrioxToCustomer, + supportsModifiedFilter: true, + }, + [ResourceType.Suppliers]: { + listEndpoint: '/supplier', + detailEndpoint: '/supplier/{id}', + listKey: 'suppliers', + idField: 'id', + mapper: mapBrioxToSupplier, + supportsModifiedFilter: true, + }, + [ResourceType.Journals]: { + listEndpoint: '/journal', + detailEndpoint: '/journal/{id}', + listKey: 'journals', + idField: 'id', + mapper: mapBrioxToJournal, + supportsModifiedFilter: false, + yearScoped: true, + supportsEntryHydration: true, + detailKey: 'journal', + }, + [ResourceType.AccountingAccounts]: { + listEndpoint: '/account', + detailEndpoint: '/account/{id}', + listKey: 'accounts', + idField: 'id', + mapper: mapBrioxToAccountingAccount, + supportsModifiedFilter: false, + }, + [ResourceType.CompanyInformation]: { + listEndpoint: '/user/info', + detailEndpoint: '/user/info', + listKey: '', + idField: 'id', + mapper: mapBrioxToCompanyInformation, + supportsModifiedFilter: false, + singleton: true, + }, +}; diff --git a/lib/providers/briox/mapper.ts b/lib/providers/briox/mapper.ts new file mode 100644 index 00000000..12027dd3 --- /dev/null +++ b/lib/providers/briox/mapper.ts @@ -0,0 +1,275 @@ +import type { + SalesInvoiceDto, SalesInvoiceLineDto, InvoiceStatusCode, + LegalMonetaryTotalDto, PaymentStatusDto, + SupplierInvoiceDto, SupplierInvoiceLineDto, + CustomerDto, SupplierDto, + JournalDto, AccountingEntryDto, + AccountingAccountDto, AccountType, + CompanyInformationDto, + AmountType, PartyDto, +} from '../dto'; + +function amount(value: number | undefined | null, currency: string = 'SEK'): AmountType { + return { value: value ?? 0, currencyCode: currency }; +} + +function deriveInvoiceStatus(raw: Record): InvoiceStatusCode { + const status = raw['status'] as string | undefined; + if (status === 'cancelled') return 'cancelled'; + if (status === 'credited') return 'credited'; + if (status === 'paid' || raw['fully_paid'] === true) return 'paid'; + if (status === 'booked' || raw['booked'] === true) return 'booked'; + if (status === 'sent' || raw['sent'] === true) return 'sent'; + if (status === 'overdue') return 'overdue'; + return 'draft'; +} + +function buildParty(name: string, orgNumber?: string, raw?: Record): PartyDto { + return { + name, + identifications: orgNumber ? [{ id: orgNumber, schemeId: 'SE:ORGNR' }] : [], + postalAddress: raw ? { + streetName: (raw['address1'] ?? raw['address']) as string | undefined, + additionalStreetName: raw['address2'] as string | undefined, + cityName: raw['city'] as string | undefined, + postalZone: (raw['zip_code'] ?? raw['postal_code']) as string | undefined, + countryCode: raw['country'] as string | undefined, + } : undefined, + legalEntity: orgNumber ? { + registrationName: name, + companyId: orgNumber, + companyIdSchemeId: 'SE:ORGNR', + } : undefined, + contact: { + email: raw?.['email'] as string | undefined, + telephone: raw?.['phone'] as string | undefined, + }, + }; +} + +export function mapBrioxToSalesInvoice(raw: Record): SalesInvoiceDto { + const currency = (raw['currency_code'] as string) ?? 'SEK'; + const total = raw['total_amount'] as number ?? 0; + const balance = raw['balance'] as number ?? 0; + + const rows = (raw['rows'] as Record[] | undefined) ?? []; + const lines: SalesInvoiceLineDto[] = rows.map((row, idx) => ({ + id: String(row['id'] ?? idx + 1), + description: row['description'] as string | undefined, + quantity: row['quantity'] as number | undefined, + unitCode: row['unit'] as string | undefined, + unitPrice: row['price'] != null ? amount(row['price'] as number, currency) : undefined, + lineExtensionAmount: amount(row['total'] as number ?? 0, currency), + taxPercent: row['vat_rate'] as number | undefined, + accountNumber: row['account_number'] != null ? String(row['account_number']) : undefined, + articleNumber: row['article_number'] as string | undefined, + itemName: row['description'] as string | undefined, + })); + + const legalMonetaryTotal: LegalMonetaryTotalDto = { + lineExtensionAmount: amount(raw['net_amount'] as number ?? total, currency), + taxInclusiveAmount: amount(total, currency), + payableAmount: amount(total, currency), + }; + + const paymentStatus: PaymentStatusDto = { + paid: balance === 0 && total > 0, + balance: amount(balance, currency), + }; + + return { + id: String(raw['id'] ?? ''), + invoiceNumber: String(raw['invoice_number'] ?? raw['id'] ?? ''), + issueDate: (raw['invoice_date'] as string) ?? '', + dueDate: raw['due_date'] as string | undefined, + currencyCode: currency, + status: deriveInvoiceStatus(raw), + supplier: buildParty(''), + customer: buildParty( + (raw['customer_name'] ?? '') as string, + raw['customer_org_number'] as string | undefined, + ), + lines, + legalMonetaryTotal, + paymentStatus, + paymentTerms: raw['payment_terms'] as string | undefined, + note: raw['remarks'] as string | undefined, + buyerReference: raw['your_reference'] as string | undefined, + orderReference: raw['your_order_number'] as string | undefined, + updatedAt: raw['modified_date'] as string | undefined, + _raw: raw, + }; +} + +export function mapBrioxToSupplierInvoice(raw: Record): SupplierInvoiceDto { + const currency = (raw['currency_code'] as string) ?? 'SEK'; + const total = raw['total_amount'] as number ?? 0; + const balance = raw['balance'] as number ?? 0; + + const rows = (raw['rows'] as Record[] | undefined) ?? []; + const lines: SupplierInvoiceLineDto[] = rows.map((row, idx) => ({ + id: String(row['id'] ?? idx + 1), + description: row['description'] as string | undefined, + quantity: row['quantity'] as number | undefined, + unitPrice: row['price'] != null ? amount(row['price'] as number, currency) : undefined, + lineExtensionAmount: amount(row['total'] as number ?? 0, currency), + accountNumber: row['account_number'] != null ? String(row['account_number']) : undefined, + })); + + const legalMonetaryTotal: LegalMonetaryTotalDto = { + lineExtensionAmount: amount(raw['net_amount'] as number ?? total, currency), + taxInclusiveAmount: amount(total, currency), + payableAmount: amount(total, currency), + }; + + const paymentStatus: PaymentStatusDto = { + paid: balance === 0 && total > 0, + balance: amount(balance, currency), + }; + + return { + id: String(raw['id'] ?? ''), + invoiceNumber: String(raw['invoice_number'] ?? raw['id'] ?? ''), + issueDate: (raw['invoice_date'] as string) ?? '', + dueDate: raw['due_date'] as string | undefined, + currencyCode: currency, + status: deriveInvoiceStatus(raw), + supplier: buildParty( + (raw['supplier_name'] ?? '') as string, + raw['supplier_org_number'] as string | undefined, + ), + buyer: buildParty(''), + lines, + legalMonetaryTotal, + paymentStatus, + ocrNumber: raw['ocr'] as string | undefined, + updatedAt: raw['modified_date'] as string | undefined, + _raw: raw, + }; +} + +export function mapBrioxToCustomer(raw: Record): CustomerDto { + const name = (raw['name'] as string) ?? ''; + const orgNumber = raw['org_number'] as string | undefined; + + return { + id: String(raw['id'] ?? ''), + customerNumber: String(raw['customer_number'] ?? raw['id'] ?? ''), + type: raw['type'] === 'private' ? 'private' : 'company', + party: buildParty(name, orgNumber, raw), + active: raw['active'] !== false, + vatNumber: raw['vat_number'] as string | undefined, + defaultPaymentTermsDays: raw['payment_terms_days'] != null ? Number(raw['payment_terms_days']) : undefined, + note: raw['note'] as string | undefined, + updatedAt: raw['modified_date'] as string | undefined, + _raw: raw, + }; +} + +export function mapBrioxToSupplier(raw: Record): SupplierDto { + const name = (raw['name'] as string) ?? ''; + const orgNumber = raw['org_number'] as string | undefined; + + return { + id: String(raw['id'] ?? ''), + supplierNumber: String(raw['supplier_number'] ?? raw['id'] ?? ''), + party: buildParty(name, orgNumber, raw), + active: raw['active'] !== false, + vatNumber: raw['vat_number'] as string | undefined, + bankAccount: raw['bank_account'] as string | undefined, + bankGiro: raw['bank_giro'] as string | undefined, + plusGiro: raw['plus_giro'] as string | undefined, + defaultPaymentTermsDays: raw['payment_terms_days'] != null ? Number(raw['payment_terms_days']) : undefined, + note: raw['note'] as string | undefined, + updatedAt: raw['modified_date'] as string | undefined, + _raw: raw, + }; +} + +export function mapBrioxToJournal(raw: Record): JournalDto { + // Briox detail API returns rows as "journal_rows" (list endpoint omits them) + const rows = (raw['journal_rows'] as Record[] | undefined) + ?? (raw['journalrows'] as Record[] | undefined) ?? []; + const entries: AccountingEntryDto[] = rows.map((row) => ({ + // Briox uses "account" (not "account_number") for the account field + accountNumber: String(row['account'] ?? row['account_number'] ?? ''), + accountName: row['account_name'] as string | undefined, + // Briox returns debit/credit as strings + debit: Number(row['debit'] ?? 0), + credit: Number(row['credit'] ?? 0), + transactionDate: (row['transactiondate'] ?? row['transaction_date']) as string | undefined, + description: (row['transactioninfo'] ?? row['description']) as string | undefined, + })); + + return { + id: String(raw['id'] ?? ''), + journalNumber: String(raw['id'] ?? raw['journal_number'] ?? ''), + series: raw['series'] ? { + id: String(raw['series']), + } : undefined, + // Briox uses "descr" for the journal description + description: (raw['descr'] ?? raw['description']) as string | undefined, + // Briox uses "transactiondate" for the date + registrationDate: ((raw['transactiondate'] ?? raw['journal_date'] ?? raw['date']) as string) ?? '', + fiscalYear: raw['year'] != null ? Number(raw['year']) : (raw['financial_year'] != null ? Number(raw['financial_year']) : undefined), + entries, + _raw: raw, + }; +} + +export function mapBrioxToAccountingAccount(raw: Record): AccountingAccountDto { + // Briox uses "id" as the account number field + const num = Number(raw['id'] ?? raw['account_number'] ?? raw['number']); + let type: AccountType | undefined; + if (num >= 1000 && num < 2000) type = 'asset'; + else if (num >= 2000 && num < 3000) type = 'liability'; + else if (num >= 3000 && num < 4000) type = 'revenue'; + else if (num >= 4000 && num < 9000) type = 'expense'; + + return { + accountNumber: String(raw['id'] ?? raw['account_number'] ?? raw['number'] ?? ''), + // Briox uses "description" for the account name + name: ((raw['description'] ?? raw['name']) as string) ?? '', + type, + // Briox returns active as "1"/"0" strings + active: raw['active'] !== false && raw['active'] !== '0' && raw['active'] !== 0, + vatCode: raw['vat_code'] != null ? String(raw['vat_code']) : undefined, + // Briox uses "incoming_balance" for opening balance + balanceCarriedForward: raw['incoming_balance'] != null ? Number(raw['incoming_balance']) : undefined, + _raw: raw, + }; +} + +export function mapBrioxToCompanyInformation(raw: Record): CompanyInformationDto { + // /user/info returns { info: { company_name, accounts: [...] } } + const info = (raw['info'] as Record | undefined) ?? raw; + const accounts = (info['accounts'] as Record[] | undefined) ?? []; + const account = accounts[0] as Record | undefined; + const addr = account?.['address'] as Record | undefined; + + const companyName = (info['company_name'] ?? account?.['database_label'] ?? '') as string; + const orgNumber = account?.['organization_number'] as string | undefined; + + return { + companyName, + organizationNumber: orgNumber, + legalEntity: { + registrationName: companyName, + companyId: orgNumber, + companyIdSchemeId: 'SE:ORGNR', + }, + address: { + streetName: addr?.['addressline1'] as string | undefined, + additionalStreetName: addr?.['addressline2'] as string | undefined, + cityName: addr?.['city'] as string | undefined, + postalZone: addr?.['zip'] as string | undefined, + countryCode: (addr?.['countrycode'] ?? addr?.['country']) as string | undefined, + }, + contact: { + email: (account?.['email'] ?? info['email']) as string | undefined, + telephone: (account?.['phone'] ?? info['phone']) as string | undefined, + website: account?.['website'] as string | undefined, + }, + _raw: raw, + }; +} diff --git a/lib/providers/briox/oauth.ts b/lib/providers/briox/oauth.ts new file mode 100644 index 00000000..2081abf5 --- /dev/null +++ b/lib/providers/briox/oauth.ts @@ -0,0 +1,68 @@ +import { BRIOX_TOKEN_URL, BRIOX_REFRESH_URL } from './config'; +import type { TokenResponse } from '../types'; + +interface BrioxTokenData { + access_token: string; + refresh_token: string; + client_id: number; + expire_date: string; + expire_timestamp: number; +} + +interface BrioxTokenApiResponse { + data: BrioxTokenData; +} + +function toTokenResponse(brioxData: BrioxTokenData): TokenResponse { + const expiresIn = brioxData.expire_timestamp - Math.floor(Date.now() / 1000); + return { + access_token: brioxData.access_token, + refresh_token: brioxData.refresh_token, + token_type: 'Bearer', + expires_in: expiresIn > 0 ? expiresIn : 3600, + }; +} + +export async function exchangeBrioxCode( + clientId: string, + applicationToken: string, +): Promise { + const url = `${BRIOX_TOKEN_URL}?clientid=${encodeURIComponent(clientId)}&token=${encodeURIComponent(applicationToken)}`; + + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + const body = await response.text().catch(() => ''); + throw new Error(`Briox token exchange failed: ${response.status} ${body}`); + } + + const result = await response.json() as BrioxTokenApiResponse; + return toTokenResponse(result.data); +} + +export async function refreshBrioxToken( + clientId: string, + refreshToken: string, +): Promise { + const url = `${BRIOX_REFRESH_URL}?refreshtoken=${encodeURIComponent(refreshToken)}&token=${encodeURIComponent(refreshToken)}`; + + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + const body = await response.text().catch(() => ''); + throw new Error(`Briox token refresh failed: ${response.status} ${body}`); + } + + const result = await response.json() as BrioxTokenApiResponse; + return toTokenResponse(result.data); +} diff --git a/lib/providers/dto.ts b/lib/providers/dto.ts new file mode 100644 index 00000000..9d2bfed2 --- /dev/null +++ b/lib/providers/dto.ts @@ -0,0 +1,448 @@ +// Canonical DTO types for provider data normalization + +// ============================================ +// Resource Type +// ============================================ + +export const ResourceType = { + SalesInvoices: 'salesinvoices', + SupplierInvoices: 'supplierinvoices', + Customers: 'customers', + Suppliers: 'suppliers', + Journals: 'journals', + AccountingAccounts: 'accountingaccounts', + CompanyInformation: 'companyinformation', + AccountingPeriods: 'accountingperiods', + FinancialDimensions: 'financialdimensions', + BalanceSheet: 'balancesheet', + IncomeStatement: 'incomestatement', + TrialBalances: 'trialbalances', + Payments: 'payments', + Attachments: 'attachments', +} as const; + +export type ResourceType = (typeof ResourceType)[keyof typeof ResourceType]; + +// ============================================ +// Common +// ============================================ + +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 FinancialDimensionRef { + dimensionId: string; + dimensionValueId: string; + name?: string; +} + +export interface AllowanceChargeDto { + chargeIndicator: boolean; + reason?: string; + amount: AmountType; + taxPercent?: number; +} + +export interface TaxTotalDto { + taxAmount: AmountType; + taxSubtotals?: TaxSubtotalDto[]; +} + +export interface TaxSubtotalDto { + taxableAmount: AmountType; + taxAmount: AmountType; + taxCategory?: string; + percent?: number; +} + +export interface PaginatedResponse { + data: T[]; + page: number; + pageSize: number; + totalCount: number; + hasMore: boolean; +} + +// ============================================ +// Sales Invoice +// ============================================ + +export type InvoiceStatusCode = 'draft' | 'sent' | 'booked' | 'paid' | 'overdue' | 'cancelled' | 'credited'; + +export interface LegalMonetaryTotalDto { + lineExtensionAmount: AmountType; + taxExclusiveAmount?: AmountType; + taxInclusiveAmount?: AmountType; + allowanceTotalAmount?: AmountType; + chargeTotalAmount?: AmountType; + payableRoundingAmount?: AmountType; + payableAmount: AmountType; +} + +export interface PaymentStatusDto { + paid: boolean; + balance: AmountType; + lastPaymentDate?: string; +} + +export interface SalesInvoiceLineDto { + id: string; + description?: string; + quantity?: number; + unitCode?: string; + unitPrice?: AmountType; + lineExtensionAmount: AmountType; + taxPercent?: number; + taxAmount?: AmountType; + accountNumber?: string; + itemName?: string; + articleNumber?: string; + financialDimensions?: FinancialDimensionRef[]; +} + +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[]; + allowanceCharges?: AllowanceChargeDto[]; + taxTotal?: TaxTotalDto; + legalMonetaryTotal: LegalMonetaryTotalDto; + paymentStatus: PaymentStatusDto; + paymentTerms?: string; + note?: string; + buyerReference?: string; + orderReference?: string; + financialDimensions?: FinancialDimensionRef[]; + createdAt?: string; + updatedAt?: string; + _raw?: Record; +} + +// ============================================ +// 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; + articleNumber?: string; + financialDimensions?: FinancialDimensionRef[]; +} + +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[]; + allowanceCharges?: AllowanceChargeDto[]; + taxTotal?: TaxTotalDto; + legalMonetaryTotal: LegalMonetaryTotalDto; + paymentStatus: PaymentStatusDto; + paymentTerms?: string; + note?: string; + ocrNumber?: string; + financialDimensions?: FinancialDimensionRef[]; + createdAt?: string; + updatedAt?: string; + _raw?: Record; +} + +// ============================================ +// Customer +// ============================================ + +export type CustomerType = 'company' | 'private'; + +export interface CustomerDto { + id: string; + customerNumber: string; + type?: CustomerType; + party: PartyDto; + deliveryAddresses?: PostalAddress[]; + financialDimensions?: FinancialDimensionRef[]; + active: boolean; + vatNumber?: string; + defaultPaymentTermsDays?: number; + note?: string; + createdAt?: string; + updatedAt?: string; + _raw?: Record; +} + +// ============================================ +// Supplier +// ============================================ + +export interface SupplierDto { + id: string; + supplierNumber: string; + party: PartyDto; + deliveryAddresses?: PostalAddress[]; + financialDimensions?: FinancialDimensionRef[]; + active: boolean; + vatNumber?: string; + bankAccount?: string; + bankGiro?: string; + plusGiro?: string; + defaultPaymentTermsDays?: number; + note?: string; + createdAt?: string; + updatedAt?: string; + _raw?: Record; +} + +// ============================================ +// Journal +// ============================================ + +export interface AccountingEntryDto { + accountNumber: string; + accountName?: string; + debit: number; + credit: number; + transactionDate?: string; + description?: string; + financialDimensions?: FinancialDimensionRef[]; +} + +export interface AccountingSeriesDto { + id: string; + description?: string; +} + +export interface JournalDto { + id: string; + journalNumber: string; + series?: AccountingSeriesDto; + description?: string; + registrationDate: string; + fiscalYear?: number; + entries: AccountingEntryDto[]; + totalDebit?: AmountType; + totalCredit?: AmountType; + createdAt?: string; + updatedAt?: string; + _raw?: Record; +} + +// ============================================ +// Accounting Account +// ============================================ + +export type AccountType = 'asset' | 'liability' | 'equity' | 'revenue' | 'expense' | 'other'; + +export interface AccountingAccountDto { + accountNumber: string; + name: string; + description?: string; + type?: AccountType; + vatCode?: string; + active: boolean; + balanceBroughtForward?: number; + balanceCarriedForward?: number; + sruCode?: string; + createdAt?: string; + updatedAt?: string; + _raw?: Record; +} + +// ============================================ +// Company Information +// ============================================ + +export interface CompanyInformationDto { + companyName: string; + organizationNumber?: string; + legalEntity?: PartyLegalEntity; + address?: PostalAddress; + contact?: Contact; + vatNumber?: string; + fiscalYearStart?: string; + baseCurrency?: string; + _raw?: Record; +} + +// ============================================ +// Payment +// ============================================ + +export type PaymentMethodCode = 'bank_transfer' | 'card' | 'cash' | 'autogiro' | 'bankgiro' | 'plusgiro' | 'swish' | 'other'; + +export interface PaymentDto { + id: string; + paymentNumber?: string; + invoiceId: string; + paymentDate: string; + amount: AmountType; + paymentMethod?: PaymentMethodCode; + reference?: string; + note?: string; + createdAt?: string; + updatedAt?: string; + _raw?: Record; +} + +// ============================================ +// Accounting Period +// ============================================ + +export type PeriodStatus = 'open' | 'closed' | 'locked'; + +export interface AccountingPeriodDto { + id: string; + fiscalYear: number; + fromDate: string; + toDate: string; + status?: PeriodStatus; + description?: string; + _raw?: Record; +} + +// ============================================ +// Financial Dimension +// ============================================ + +export interface FinancialDimensionValueDto { + id: string; + code: string; + name: string; + active: boolean; +} + +export interface FinancialDimensionDto { + id: string; + name: string; + description?: string; + values: FinancialDimensionValueDto[]; + _raw?: Record; +} + +// ============================================ +// Reports +// ============================================ + +export interface FinancialReportCategoryDto { + name: string; + amount: AmountType; + children?: FinancialReportCategoryDto[]; + accounts?: { accountNumber: string; name?: string; amount: AmountType }[]; +} + +export interface BalanceSheetDto { + fiscalYear: number; + periodEnd: string; + baseCurrency: string; + assets: FinancialReportCategoryDto; + liabilities: FinancialReportCategoryDto; + equity: FinancialReportCategoryDto; + _raw?: Record; +} + +export interface IncomeStatementDto { + fiscalYear: number; + periodStart: string; + periodEnd: string; + baseCurrency: string; + revenue: FinancialReportCategoryDto; + expenses: FinancialReportCategoryDto; + netIncome: AmountType; + _raw?: Record; +} + +export interface TrialBalanceEntryDto { + accountNumber: string; + accountName?: string; + openingDebit: number; + openingCredit: number; + periodDebit: number; + periodCredit: number; + closingDebit: number; + closingCredit: number; +} + +export interface TrialBalanceDto { + fiscalYear: number; + periodStart: string; + periodEnd: string; + baseCurrency: string; + entries: TrialBalanceEntryDto[]; + _raw?: Record; +} + +// ============================================ +// Attachment +// ============================================ + +export type AttachmentType = 'pdf' | 'image' | 'xml' | 'other'; + +export interface AttachmentDto { + id: string; + fileName: string; + mimeType?: string; + type?: AttachmentType; + size?: number; + downloadUrl?: string; + createdAt?: string; + _raw?: Record; +} diff --git a/lib/providers/fortnox/client.ts b/lib/providers/fortnox/client.ts new file mode 100644 index 00000000..9e2109b6 --- /dev/null +++ b/lib/providers/fortnox/client.ts @@ -0,0 +1,185 @@ +import { TokenBucketRateLimiter } from '../rate-limiter'; +import { withRetry } from '../retry'; +import { FORTNOX_BASE_URL, FORTNOX_RATE_LIMIT } from './config'; + +export class FortnoxApiError extends Error { + constructor( + message: string, + public readonly statusCode: number, + public readonly body?: string, + public readonly retryAfterMs?: number, + ) { + super(message); + this.name = 'FortnoxApiError'; + } +} + +function isRetryableError(error: unknown): boolean { + if (error instanceof FortnoxApiError) { + if (error.statusCode === 401 || error.statusCode === 403 || error.statusCode === 404) { + return false; + } + return error.statusCode === 429 || error.statusCode >= 500; + } + return false; +} + +export class FortnoxClient { + private readonly rateLimiter: TokenBucketRateLimiter; + private readonly baseUrl: string; + + constructor(baseUrl?: string) { + this.baseUrl = baseUrl ?? FORTNOX_BASE_URL; + this.rateLimiter = new TokenBucketRateLimiter(FORTNOX_RATE_LIMIT, 'ratelimit:fortnox'); + } + + async get(accessToken: string, path: string): Promise { + return withRetry( + async () => { + await this.rateLimiter.acquire(); + const url = `${this.baseUrl}${path}`; + const response = await fetch(url, { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + const body = await response.text().catch(() => ''); + let retryAfterMs: number | undefined; + if (response.status === 429) { + const retryAfter = response.headers.get('Retry-After'); + retryAfterMs = retryAfter ? Math.ceil(parseFloat(retryAfter)) * 1000 : undefined; + } + throw new FortnoxApiError( + `Fortnox API error: ${response.status} ${response.statusText}`, + response.status, + body, + retryAfterMs, + ); + } + + return response.json() as Promise; + }, + { + maxAttempts: 6, + initialDelayMs: 2000, + maxDelayMs: 60_000, + shouldRetry: isRetryableError, + getDelayMs: (error) => { + if (error instanceof FortnoxApiError && error.retryAfterMs) { + return error.retryAfterMs; + } + return undefined; + }, + }, + ); + } + + async getText(accessToken: string, path: string): Promise { + return withRetry( + async () => { + await this.rateLimiter.acquire(); + const url = `${this.baseUrl}${path}`; + const response = await fetch(url, { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }); + + if (!response.ok) { + const body = await response.text().catch(() => ''); + let retryAfterMs: number | undefined; + if (response.status === 429) { + const retryAfter = response.headers.get('Retry-After'); + retryAfterMs = retryAfter ? Math.ceil(parseFloat(retryAfter)) * 1000 : undefined; + } + throw new FortnoxApiError( + `Fortnox API error: ${response.status} ${response.statusText}`, + response.status, + body, + retryAfterMs, + ); + } + + return response.text(); + }, + { + maxAttempts: 6, + initialDelayMs: 2000, + maxDelayMs: 60_000, + shouldRetry: isRetryableError, + getDelayMs: (error) => { + if (error instanceof FortnoxApiError && error.retryAfterMs) { + return error.retryAfterMs; + } + return undefined; + }, + }, + ); + } + + async getPage( + accessToken: string, + path: string, + listKey: string, + options?: { page?: number; pageSize?: number; lastModified?: string }, + ): Promise<{ items: T[]; page: number; totalPages: number; totalCount: number }> { + const params = new URLSearchParams(); + params.set('page', String(options?.page ?? 1)); + if (options?.pageSize) { + params.set('limit', String(options.pageSize)); + } + if (options?.lastModified) { + params.set('lastmodified', options.lastModified); + } + + const separator = path.includes('?') ? '&' : '?'; + const fullPath = `${path}${separator}${params.toString()}`; + + const response = await this.get>(accessToken, fullPath); + + const meta = response['MetaInformation'] as + | { '@TotalPages': number; '@CurrentPage': number; '@TotalResources': number } + | undefined; + + const totalPages = meta?.['@TotalPages'] ?? 1; + const currentPage = meta?.['@CurrentPage'] ?? 1; + const totalCount = meta?.['@TotalResources'] ?? 0; + + const items = response[listKey]; + return { + items: Array.isArray(items) ? (items as T[]) : [], + page: currentPage, + totalPages, + totalCount, + }; + } + + async getPaginated( + accessToken: string, + path: string, + listKey: string, + options?: { lastModified?: string; pageSize?: number }, + ): Promise { + const allItems: T[] = []; + let page = 1; + let totalPages = 1; + + do { + const result = await this.getPage(accessToken, path, listKey, { + page, + pageSize: options?.pageSize, + lastModified: options?.lastModified, + }); + + allItems.push(...result.items); + totalPages = result.totalPages; + page++; + } while (page <= totalPages); + + return allItems; + } +} diff --git a/lib/providers/fortnox/config.ts b/lib/providers/fortnox/config.ts new file mode 100644 index 00000000..4e373cc9 --- /dev/null +++ b/lib/providers/fortnox/config.ts @@ -0,0 +1,92 @@ +import { ResourceType } from '../dto'; +import type { FortnoxResourceConfig, RateLimitConfig } from '../types'; +import { + mapFortnoxToSalesInvoice, + mapFortnoxToSupplierInvoice, + mapFortnoxToCustomer, + mapFortnoxToSupplier, + mapFortnoxToJournal, + mapFortnoxToAccountingAccount, + mapFortnoxToCompanyInformation, +} from './mapper'; + +export const FORTNOX_BASE_URL = 'https://api.fortnox.se/3'; +export const FORTNOX_AUTH_URL = 'https://apps.fortnox.se/oauth-v1/auth'; +export const FORTNOX_TOKEN_URL = 'https://apps.fortnox.se/oauth-v1/token'; +export const FORTNOX_RATE_LIMIT: RateLimitConfig = { maxRequests: 4, windowMs: 1000 }; + +export const FORTNOX_RESOURCE_CONFIGS: Partial> = { + [ResourceType.SalesInvoices]: { + listEndpoint: '/invoices', + listKey: 'Invoices', + detailEndpoint: '/invoices/{id}', + detailKey: 'Invoice', + idField: 'DocumentNumber', + mapper: mapFortnoxToSalesInvoice, + supportsLastModified: true, + }, + [ResourceType.SupplierInvoices]: { + listEndpoint: '/supplierinvoices', + listKey: 'SupplierInvoices', + detailEndpoint: '/supplierinvoices/{id}', + detailKey: 'SupplierInvoice', + idField: 'GivenNumber', + mapper: mapFortnoxToSupplierInvoice, + supportsLastModified: true, + }, + [ResourceType.Customers]: { + listEndpoint: '/customers', + listKey: 'Customers', + detailEndpoint: '/customers/{id}', + detailKey: 'Customer', + idField: 'CustomerNumber', + mapper: mapFortnoxToCustomer, + supportsLastModified: true, + }, + [ResourceType.Suppliers]: { + listEndpoint: '/suppliers', + listKey: 'Suppliers', + detailEndpoint: '/suppliers/{id}', + detailKey: 'Supplier', + idField: 'SupplierNumber', + mapper: mapFortnoxToSupplier, + supportsLastModified: true, + }, + [ResourceType.Journals]: { + listEndpoint: '/vouchers', + listKey: 'Vouchers', + detailEndpoint: '/vouchers/{id}', + detailKey: 'Voucher', + idField: 'VoucherNumber', + mapper: mapFortnoxToJournal, + supportsLastModified: false, + supportsEntryHydration: true, + resolveDetailPath: (resourceId, query) => { + const dashIdx = resourceId.indexOf('-'); + const series = dashIdx >= 0 ? resourceId.slice(0, dashIdx) : resourceId; + const number = dashIdx >= 0 ? resourceId.slice(dashIdx + 1) : resourceId; + const fy = query?.['financialyear'] ?? ''; + const params = fy ? `?financialyear=${fy}` : ''; + return `/vouchers/${series}/${number}${params}`; + }, + }, + [ResourceType.AccountingAccounts]: { + listEndpoint: '/accounts', + listKey: 'Accounts', + detailEndpoint: '/accounts/{id}', + detailKey: 'Account', + idField: 'Number', + mapper: mapFortnoxToAccountingAccount, + supportsLastModified: false, + }, + [ResourceType.CompanyInformation]: { + listEndpoint: '/companyinformation', + listKey: 'CompanyInformation', + detailEndpoint: '/companyinformation', + detailKey: 'CompanyInformation', + idField: 'OrganizationNumber', + mapper: mapFortnoxToCompanyInformation, + supportsLastModified: false, + singleton: true, + }, +}; diff --git a/lib/providers/fortnox/mapper.ts b/lib/providers/fortnox/mapper.ts new file mode 100644 index 00000000..73ab21a3 --- /dev/null +++ b/lib/providers/fortnox/mapper.ts @@ -0,0 +1,274 @@ +import type { + SalesInvoiceDto, SalesInvoiceLineDto, InvoiceStatusCode, + LegalMonetaryTotalDto, PaymentStatusDto, + SupplierInvoiceDto, SupplierInvoiceLineDto, + CustomerDto, SupplierDto, + JournalDto, AccountingEntryDto, + AccountingAccountDto, AccountType, + CompanyInformationDto, + PaymentDto, + AmountType, PartyDto, +} from '../dto'; + +function amount(value: number | undefined | null, currency: string = 'SEK'): AmountType { + return { value: value ?? 0, currencyCode: currency }; +} + +function deriveInvoiceStatus(raw: Record): InvoiceStatusCode { + if (raw['Cancelled'] === true) return 'cancelled'; + if (raw['Credit'] === true) return 'credited'; + if (raw['FullyPaid'] === true || raw['Balance'] === 0) return 'paid'; + if (raw['Booked'] === true) return 'booked'; + if (raw['Sent'] === true) return 'sent'; + return 'draft'; +} + +function buildParty(name: string, orgNumber?: string, address?: Record): PartyDto { + return { + name, + identifications: orgNumber ? [{ id: orgNumber, schemeId: 'SE:ORGNR' }] : [], + postalAddress: address ? { + streetName: (address['Address1'] ?? address['Address']) as string | undefined, + additionalStreetName: address['Address2'] as string | undefined, + cityName: (address['City'] ?? address['CityName']) as string | undefined, + postalZone: (address['ZipCode'] ?? address['PostalCode']) as string | undefined, + countryCode: address['Country'] as string | undefined, + } : undefined, + legalEntity: orgNumber ? { + registrationName: name, + companyId: orgNumber, + companyIdSchemeId: 'SE:ORGNR', + } : undefined, + contact: { + email: (address?.['Email'] ?? address?.['EmailInvoice']) as string | undefined, + telephone: address?.['Phone1'] as string | undefined, + }, + }; +} + +export function mapFortnoxToSalesInvoice(raw: Record): SalesInvoiceDto { + const currency = (raw['Currency'] as string) ?? 'SEK'; + const total = raw['Total'] as number ?? 0; + const balance = raw['Balance'] as number ?? 0; + + const rows = (raw['InvoiceRows'] as Record[] | undefined) ?? []; + const lines: SalesInvoiceLineDto[] = rows.map((row, idx) => ({ + id: String(row['RowId'] ?? idx + 1), + description: row['Description'] as string | undefined, + quantity: row['DeliveredQuantity'] as number | undefined, + unitCode: row['Unit'] as string | undefined, + unitPrice: row['Price'] != null ? amount(row['Price'] as number, currency) : undefined, + lineExtensionAmount: amount(row['Total'] as number ?? 0, currency), + taxPercent: row['VAT'] as number | undefined, + accountNumber: row['AccountNumber'] != null ? String(row['AccountNumber']) : undefined, + articleNumber: row['ArticleNumber'] as string | undefined, + itemName: row['Description'] as string | undefined, + })); + + const legalMonetaryTotal: LegalMonetaryTotalDto = { + lineExtensionAmount: amount(raw['Net'] as number ?? total, currency), + taxInclusiveAmount: amount(total, currency), + payableAmount: amount(total, currency), + }; + + const paymentStatus: PaymentStatusDto = { + paid: balance === 0 && total > 0, + balance: amount(balance, currency), + }; + + return { + id: String(raw['DocumentNumber'] ?? ''), + invoiceNumber: String(raw['DocumentNumber'] ?? ''), + issueDate: (raw['InvoiceDate'] as string) ?? '', + dueDate: raw['DueDate'] as string | undefined, + currencyCode: currency, + status: deriveInvoiceStatus(raw), + supplier: buildParty( + (raw['CompanyName'] ?? '') as string, + raw['OrganisationNumber'] as string | undefined, + ), + customer: buildParty( + (raw['CustomerName'] ?? '') as string, + raw['OrganisationNumber'] as string | undefined, + raw as Record, + ), + lines, + legalMonetaryTotal, + paymentStatus, + paymentTerms: raw['TermsOfPayment'] as string | undefined, + note: raw['Remarks'] as string | undefined, + buyerReference: raw['YourReference'] as string | undefined, + orderReference: raw['YourOrderNumber'] as string | undefined, + updatedAt: raw['@LastModified'] as string | undefined, + _raw: raw, + }; +} + +export function mapFortnoxToSupplierInvoice(raw: Record): SupplierInvoiceDto { + const currency = (raw['Currency'] as string) ?? 'SEK'; + const total = raw['Total'] as number ?? 0; + const balance = raw['Balance'] as number ?? 0; + + const rows = (raw['SupplierInvoiceRows'] as Record[] | undefined) ?? []; + const lines: SupplierInvoiceLineDto[] = rows.map((row, idx) => ({ + id: String(row['RowId'] ?? idx + 1), + description: row['Description'] as string | undefined, + quantity: row['Quantity'] as number | undefined, + unitPrice: row['Price'] != null ? amount(row['Price'] as number, currency) : undefined, + lineExtensionAmount: amount(row['Total'] as number ?? 0, currency), + accountNumber: row['Account'] != null ? String(row['Account']) : undefined, + articleNumber: row['ArticleNumber'] as string | undefined, + })); + + const legalMonetaryTotal: LegalMonetaryTotalDto = { + lineExtensionAmount: amount(raw['Net'] as number ?? total, currency), + taxInclusiveAmount: amount(total, currency), + payableAmount: amount(total, currency), + }; + + const paymentStatus: PaymentStatusDto = { + paid: balance === 0 && total > 0, + balance: amount(balance, currency), + }; + + return { + id: String(raw['GivenNumber'] ?? ''), + invoiceNumber: String(raw['GivenNumber'] ?? ''), + issueDate: (raw['InvoiceDate'] as string) ?? '', + dueDate: raw['DueDate'] as string | undefined, + currencyCode: currency, + status: deriveInvoiceStatus(raw), + supplier: buildParty( + (raw['SupplierName'] ?? '') as string, + raw['OrganisationNumber'] as string | undefined, + ), + buyer: buildParty(''), + lines, + legalMonetaryTotal, + paymentStatus, + ocrNumber: raw['OCR'] as string | undefined, + updatedAt: raw['@LastModified'] as string | undefined, + _raw: raw, + }; +} + +export function mapFortnoxToCustomer(raw: Record): CustomerDto { + const name = (raw['Name'] as string) ?? ''; + const orgNumber = raw['OrganisationNumber'] as string | undefined; + + return { + id: String(raw['CustomerNumber'] ?? ''), + customerNumber: String(raw['CustomerNumber'] ?? ''), + type: raw['Type'] === 'PRIVATE' ? 'private' : 'company', + party: buildParty(name, orgNumber, raw), + active: raw['Active'] !== false, + vatNumber: raw['VATNumber'] as string | undefined, + defaultPaymentTermsDays: raw['TermsOfPayment'] != null ? Number(raw['TermsOfPayment']) : undefined, + note: raw['Comments'] as string | undefined, + updatedAt: raw['@LastModified'] as string | undefined, + _raw: raw, + }; +} + +export function mapFortnoxToSupplier(raw: Record): SupplierDto { + const name = (raw['Name'] as string) ?? ''; + const orgNumber = raw['OrganisationNumber'] as string | undefined; + + return { + id: String(raw['SupplierNumber'] ?? ''), + supplierNumber: String(raw['SupplierNumber'] ?? ''), + party: buildParty(name, orgNumber, raw), + active: raw['Active'] !== false, + vatNumber: raw['VATNumber'] as string | undefined, + bankAccount: raw['BankAccountNumber'] as string | undefined, + bankGiro: raw['BG'] as string | undefined, + plusGiro: raw['PG'] as string | undefined, + defaultPaymentTermsDays: raw['TermsOfPayment'] != null ? Number(raw['TermsOfPayment']) : undefined, + note: raw['Comments'] as string | undefined, + updatedAt: raw['@LastModified'] as string | undefined, + _raw: raw, + }; +} + +export function mapFortnoxToJournal(raw: Record): JournalDto { + const voucherRows = (raw['VoucherRows'] as Record[] | undefined) ?? []; + const entries: AccountingEntryDto[] = voucherRows.map((row) => ({ + accountNumber: String(row['Account'] ?? ''), + accountName: row['AccountDescription'] as string | undefined, + debit: (row['Debit'] as number) ?? 0, + credit: (row['Credit'] as number) ?? 0, + transactionDate: row['TransactionDate'] as string | undefined, + description: row['Description'] as string | undefined, + })); + + return { + id: `${raw['VoucherSeries'] ?? ''}-${raw['VoucherNumber'] ?? ''}`, + journalNumber: String(raw['VoucherNumber'] ?? ''), + series: raw['VoucherSeries'] ? { + id: String(raw['VoucherSeries']), + description: raw['VoucherSeriesDescription'] as string | undefined, + } : undefined, + description: raw['Description'] as string | undefined, + registrationDate: (raw['TransactionDate'] as string) ?? '', + fiscalYear: raw['Year'] != null ? Number(raw['Year']) : undefined, + entries, + _raw: raw, + }; +} + +export function mapFortnoxToAccountingAccount(raw: Record): AccountingAccountDto { + let type: AccountType | undefined; + const num = Number(raw['Number']); + if (num >= 1000 && num < 2000) type = 'asset'; + else if (num >= 2000 && num < 3000) type = 'liability'; + else if (num >= 3000 && num < 4000) type = 'revenue'; + else if (num >= 4000 && num < 9000) type = 'expense'; + + return { + accountNumber: String(raw['Number'] ?? ''), + name: (raw['Description'] as string) ?? '', + type, + vatCode: raw['VATCode'] as string | undefined, + active: raw['Active'] !== false, + balanceBroughtForward: raw['BalanceBroughtForward'] as number | undefined, + balanceCarriedForward: raw['BalanceCarriedForward'] as number | undefined, + sruCode: raw['SRU'] != null ? String(raw['SRU']) : undefined, + _raw: raw, + }; +} + +export function mapFortnoxToCompanyInformation(raw: Record): CompanyInformationDto { + return { + companyName: (raw['CompanyName'] as string) ?? '', + organizationNumber: raw['OrganizationNumber'] as string | undefined, + legalEntity: { + registrationName: (raw['CompanyName'] as string) ?? '', + companyId: raw['OrganizationNumber'] as string | undefined, + companyIdSchemeId: 'SE:ORGNR', + }, + address: { + streetName: raw['Address'] as string | undefined, + cityName: raw['City'] as string | undefined, + postalZone: raw['ZipCode'] as string | undefined, + countryCode: raw['Country'] as string | undefined, + }, + contact: { + email: raw['Email'] as string | undefined, + telephone: raw['Phone1'] as string | undefined, + website: raw['WWW'] as string | undefined, + }, + _raw: raw, + }; +} + +export function mapFortnoxToPayment(raw: Record, invoiceId?: string): PaymentDto { + return { + id: String(raw['Number'] ?? ''), + paymentNumber: String(raw['Number'] ?? ''), + invoiceId: invoiceId ?? String(raw['InvoiceNumber'] ?? ''), + paymentDate: (raw['PaymentDate'] as string) ?? '', + amount: amount(raw['Amount'] as number ?? 0, (raw['Currency'] as string) ?? 'SEK'), + reference: raw['Reference'] as string | undefined, + _raw: raw, + }; +} diff --git a/lib/providers/fortnox/oauth.ts b/lib/providers/fortnox/oauth.ts new file mode 100644 index 00000000..af333ed1 --- /dev/null +++ b/lib/providers/fortnox/oauth.ts @@ -0,0 +1,105 @@ +import { FORTNOX_AUTH_URL, FORTNOX_TOKEN_URL } from './config'; +import type { OAuthConfig, TokenResponse } from '../types'; + +const DEFAULT_SCOPES = [ + 'companyinformation', + 'invoice', + 'supplierinvoice', + 'customer', + 'supplier', + 'bookkeeping', +]; + +export function buildFortnoxAuthUrl( + config: OAuthConfig, + options?: { scopes?: string[]; state?: string }, +): string { + const params = new URLSearchParams({ + client_id: config.clientId, + redirect_uri: config.redirectUri, + response_type: 'code', + access_type: 'offline', + }); + + const scopes = options?.scopes?.length ? options.scopes : DEFAULT_SCOPES; + params.set('scope', scopes.join(' ')); + + if (options?.state) { + params.set('state', options.state); + } + + return `${FORTNOX_AUTH_URL}?${params.toString()}`; +} + +function basicAuthHeader(config: OAuthConfig): string { + const encoded = btoa(`${config.clientId}:${config.clientSecret}`); + return `Basic ${encoded}`; +} + +export async function exchangeFortnoxCode( + config: OAuthConfig, + code: string, +): Promise { + const response = await fetch(FORTNOX_TOKEN_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Authorization: basicAuthHeader(config), + }, + body: new URLSearchParams({ + grant_type: 'authorization_code', + code, + redirect_uri: config.redirectUri, + }).toString(), + }); + + if (!response.ok) { + const body = await response.text().catch(() => ''); + throw new Error(`Fortnox token exchange failed: ${response.status} ${body}`); + } + + return response.json() as Promise; +} + +export async function refreshFortnoxToken( + config: OAuthConfig, + refreshToken: string, +): Promise { + const response = await fetch(FORTNOX_TOKEN_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Authorization: basicAuthHeader(config), + }, + body: new URLSearchParams({ + grant_type: 'refresh_token', + refresh_token: refreshToken, + }).toString(), + }); + + if (!response.ok) { + const body = await response.text().catch(() => ''); + throw new Error(`Fortnox token refresh failed: ${response.status} ${body}`); + } + + return response.json() as Promise; +} + +export async function revokeFortnoxToken( + config: OAuthConfig, + refreshToken: string, +): Promise { + const response = await fetch(FORTNOX_TOKEN_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Authorization: basicAuthHeader(config), + }, + body: new URLSearchParams({ + token: refreshToken, + token_type_hint: 'refresh_token', + }).toString(), + }); + + return response.ok; +} diff --git a/lib/providers/oauth-config.ts b/lib/providers/oauth-config.ts new file mode 100644 index 00000000..b5123759 --- /dev/null +++ b/lib/providers/oauth-config.ts @@ -0,0 +1,44 @@ +import type { OAuthConfig } from './types'; + +export function getOAuthConfig(provider: string): OAuthConfig { + if (provider === 'fortnox') { + return { + clientId: process.env.FORTNOX_CLIENT_ID ?? '', + clientSecret: process.env.FORTNOX_CLIENT_SECRET ?? '', + redirectUri: process.env.FORTNOX_REDIRECT_URI ?? '', + }; + } + if (provider === 'visma') { + return { + clientId: process.env.VISMA_CLIENT_ID ?? '', + clientSecret: process.env.VISMA_CLIENT_SECRET ?? '', + redirectUri: process.env.VISMA_REDIRECT_URI ?? '', + }; + } + if (provider === 'briox') { + return { + clientId: process.env.BRIOX_CLIENT_ID ?? '', + clientSecret: '', + redirectUri: '', + }; + } + if (provider === 'bokio') { + return { + clientId: '', + clientSecret: '', + redirectUri: '', + }; + } + if (provider === 'bjornlunden') { + return { + clientId: process.env.BJORN_LUNDEN_CLIENT_ID ?? '', + clientSecret: process.env.BJORN_LUNDEN_CLIENT_SECRET ?? '', + redirectUri: '', + }; + } + throw new Error(`Unknown provider: ${provider}`); +} + +export function validateProvider(provider: string): boolean { + return provider === 'fortnox' || provider === 'visma' || provider === 'briox' || provider === 'bokio' || provider === 'bjornlunden'; +} diff --git a/lib/providers/provider-data-fetcher.ts b/lib/providers/provider-data-fetcher.ts new file mode 100644 index 00000000..301bb8b6 --- /dev/null +++ b/lib/providers/provider-data-fetcher.ts @@ -0,0 +1,285 @@ +import type { + CompanyInformationDto, + CustomerDto, + SupplierDto, + SalesInvoiceDto, + SupplierInvoiceDto, +} from './dto'; +import type { ProviderName } from './types'; + +import { FortnoxClient } from './fortnox/client'; +import { FORTNOX_RESOURCE_CONFIGS } from './fortnox/config'; +import { VismaClient } from './visma/client'; +import { VISMA_RESOURCE_CONFIGS } from './visma/config'; +import { BrioxClient } from './briox/client'; +import { BRIOX_RESOURCE_CONFIGS } from './briox/config'; +import { BokioClient } from './bokio/client'; +import { BOKIO_RESOURCE_CONFIGS } from './bokio/config'; +import { BjornLundenClient } from './bjornlunden/client'; +import { BL_RESOURCE_CONFIGS } from './bjornlunden/config'; +import { ResourceType } from './dto'; + +// Singleton clients (they hold rate limiters) +const fortnoxClient = new FortnoxClient(); +const vismaClient = new VismaClient(); +const brioxClient = new BrioxClient(); +const bokioClient = new BokioClient(); +const bjornLundenClient = new BjornLundenClient(); + +// ── Helper to paginate Bokio (uses getPage with companyId) ────────── + +async function bokioPaginate( + accessToken: string, + companyId: string, + path: string, +): Promise { + const allItems: T[] = []; + let page = 1; + let totalPages = 1; + + do { + const result = await bokioClient.getPage(accessToken, companyId, path, { page }); + allItems.push(...result.items); + totalPages = result.totalPages; + page++; + } while (page <= totalPages); + + return allItems; +} + +// ── Helper to paginate BjornLunden (uses getPage with userKey) ────── + +async function blPaginate( + accessToken: string, + userKey: string, + path: string, +): Promise { + const allItems: T[] = []; + let page = 1; + let totalPages = 1; + + do { + const result = await bjornLundenClient.getPage(accessToken, userKey, path, { page }); + allItems.push(...result.items); + totalPages = result.totalPages; + page++; + } while (page <= totalPages); + + return allItems; +} + +// ── Public fetch functions ────────────────────────────────────────── + +export async function fetchCompanyInfoDirect( + provider: ProviderName, + accessToken: string, + providerCompanyId?: string, +): Promise { + try { + if (provider === 'fortnox') { + const config = FORTNOX_RESOURCE_CONFIGS[ResourceType.CompanyInformation]!; + const response = await fortnoxClient.get>(accessToken, config.listEndpoint); + const data = response[config.detailKey]; + return data ? config.mapper(data as Record) as CompanyInformationDto : null; + } + + if (provider === 'visma') { + const config = VISMA_RESOURCE_CONFIGS[ResourceType.CompanyInformation]!; + const response = await vismaClient.get>(accessToken, config.listEndpoint); + return config.mapper(response) as CompanyInformationDto; + } + + if (provider === 'briox') { + const config = BRIOX_RESOURCE_CONFIGS[ResourceType.CompanyInformation]!; + const response = await brioxClient.get>(accessToken, config.listEndpoint); + return config.mapper(response) as CompanyInformationDto; + } + + if (provider === 'bokio') { + const config = BOKIO_RESOURCE_CONFIGS[ResourceType.CompanyInformation]; + if (!config || !providerCompanyId) return null; + const response = await bokioClient.getCompany>(accessToken, providerCompanyId); + return response ? config.mapper(response) as CompanyInformationDto : null; + } + + if (provider === 'bjornlunden') { + const config = BL_RESOURCE_CONFIGS[ResourceType.CompanyInformation]!; + if (!providerCompanyId) return null; + const response = await bjornLundenClient.get>(accessToken, providerCompanyId, config.listEndpoint); + return config.mapper(response) as CompanyInformationDto; + } + + return null; + } catch (error) { + console.error(`[provider-data-fetcher] Failed to fetch company info from ${provider}:`, error); + return null; + } +} + +export async function fetchCustomersDirect( + provider: ProviderName, + accessToken: string, + providerCompanyId?: string, +): Promise { + if (provider === 'fortnox') { + const config = FORTNOX_RESOURCE_CONFIGS[ResourceType.Customers]!; + const items = await fortnoxClient.getPaginated>( + accessToken, config.listEndpoint, config.listKey, + ); + return items.map((item) => config.mapper(item) as CustomerDto); + } + + if (provider === 'visma') { + const config = VISMA_RESOURCE_CONFIGS[ResourceType.Customers]!; + const items = await vismaClient.getPaginated>(accessToken, config.listEndpoint); + return items.map((item) => config.mapper(item) as CustomerDto); + } + + if (provider === 'briox') { + const config = BRIOX_RESOURCE_CONFIGS[ResourceType.Customers]!; + const items = await brioxClient.getPaginated>(accessToken, config.listEndpoint, config.listKey); + return items.map((item) => config.mapper(item) as CustomerDto); + } + + if (provider === 'bokio') { + const config = BOKIO_RESOURCE_CONFIGS[ResourceType.Customers]; + if (!config || !providerCompanyId) return []; + const items = await bokioPaginate>(accessToken, providerCompanyId, config.listEndpoint); + return items.map((item) => config.mapper(item) as CustomerDto); + } + + if (provider === 'bjornlunden') { + const config = BL_RESOURCE_CONFIGS[ResourceType.Customers]!; + if (!providerCompanyId) return []; + const items = await blPaginate>(accessToken, providerCompanyId, config.listEndpoint); + return items.map((item) => config.mapper(item) as CustomerDto); + } + + return []; +} + +export async function fetchSuppliersDirect( + provider: ProviderName, + accessToken: string, + providerCompanyId?: string, +): Promise { + if (provider === 'fortnox') { + const config = FORTNOX_RESOURCE_CONFIGS[ResourceType.Suppliers]!; + const items = await fortnoxClient.getPaginated>( + accessToken, config.listEndpoint, config.listKey, + ); + return items.map((item) => config.mapper(item) as SupplierDto); + } + + if (provider === 'visma') { + const config = VISMA_RESOURCE_CONFIGS[ResourceType.Suppliers]!; + const items = await vismaClient.getPaginated>(accessToken, config.listEndpoint); + return items.map((item) => config.mapper(item) as SupplierDto); + } + + if (provider === 'briox') { + const config = BRIOX_RESOURCE_CONFIGS[ResourceType.Suppliers]!; + const items = await brioxClient.getPaginated>(accessToken, config.listEndpoint, config.listKey); + return items.map((item) => config.mapper(item) as SupplierDto); + } + + if (provider === 'bokio') { + const config = BOKIO_RESOURCE_CONFIGS[ResourceType.Suppliers]; + if (!config || !providerCompanyId) return []; + const items = await bokioPaginate>(accessToken, providerCompanyId, config.listEndpoint); + return items.map((item) => config.mapper(item) as SupplierDto); + } + + if (provider === 'bjornlunden') { + const config = BL_RESOURCE_CONFIGS[ResourceType.Suppliers]!; + if (!providerCompanyId) return []; + const items = await blPaginate>(accessToken, providerCompanyId, config.listEndpoint); + return items.map((item) => config.mapper(item) as SupplierDto); + } + + return []; +} + +export async function fetchSalesInvoicesDirect( + provider: ProviderName, + accessToken: string, + providerCompanyId?: string, +): Promise { + if (provider === 'fortnox') { + const config = FORTNOX_RESOURCE_CONFIGS[ResourceType.SalesInvoices]!; + const items = await fortnoxClient.getPaginated>( + accessToken, config.listEndpoint, config.listKey, + ); + return items.map((item) => config.mapper(item) as SalesInvoiceDto); + } + + if (provider === 'visma') { + const config = VISMA_RESOURCE_CONFIGS[ResourceType.SalesInvoices]!; + const items = await vismaClient.getPaginated>(accessToken, config.listEndpoint); + return items.map((item) => config.mapper(item) as SalesInvoiceDto); + } + + if (provider === 'briox') { + const config = BRIOX_RESOURCE_CONFIGS[ResourceType.SalesInvoices]!; + const items = await brioxClient.getPaginated>(accessToken, config.listEndpoint, config.listKey); + return items.map((item) => config.mapper(item) as SalesInvoiceDto); + } + + if (provider === 'bokio') { + const config = BOKIO_RESOURCE_CONFIGS[ResourceType.SalesInvoices]; + if (!config || !providerCompanyId) return []; + const items = await bokioPaginate>(accessToken, providerCompanyId, config.listEndpoint); + return items.map((item) => config.mapper(item) as SalesInvoiceDto); + } + + if (provider === 'bjornlunden') { + const config = BL_RESOURCE_CONFIGS[ResourceType.SalesInvoices]!; + if (!providerCompanyId) return []; + const items = await blPaginate>(accessToken, providerCompanyId, config.listEndpoint); + return items.map((item) => config.mapper(item) as SalesInvoiceDto); + } + + return []; +} + +export async function fetchSupplierInvoicesDirect( + provider: ProviderName, + accessToken: string, + providerCompanyId?: string, +): Promise { + if (provider === 'fortnox') { + const config = FORTNOX_RESOURCE_CONFIGS[ResourceType.SupplierInvoices]!; + const items = await fortnoxClient.getPaginated>( + accessToken, config.listEndpoint, config.listKey, + ); + return items.map((item) => config.mapper(item) as SupplierInvoiceDto); + } + + if (provider === 'visma') { + const config = VISMA_RESOURCE_CONFIGS[ResourceType.SupplierInvoices]!; + const items = await vismaClient.getPaginated>(accessToken, config.listEndpoint); + return items.map((item) => config.mapper(item) as SupplierInvoiceDto); + } + + if (provider === 'briox') { + const config = BRIOX_RESOURCE_CONFIGS[ResourceType.SupplierInvoices]!; + const items = await brioxClient.getPaginated>(accessToken, config.listEndpoint, config.listKey); + return items.map((item) => config.mapper(item) as SupplierInvoiceDto); + } + + if (provider === 'bokio') { + const config = BOKIO_RESOURCE_CONFIGS[ResourceType.SupplierInvoices]; + if (!config || !providerCompanyId) return []; + const items = await bokioPaginate>(accessToken, providerCompanyId, config.listEndpoint); + return items.map((item) => config.mapper(item) as SupplierInvoiceDto); + } + + if (provider === 'bjornlunden') { + const config = BL_RESOURCE_CONFIGS[ResourceType.SupplierInvoices]!; + if (!providerCompanyId) return []; + const items = await blPaginate>(accessToken, providerCompanyId, config.listEndpoint); + return items.map((item) => config.mapper(item) as SupplierInvoiceDto); + } + + return []; +} diff --git a/lib/providers/rate-limiter.ts b/lib/providers/rate-limiter.ts new file mode 100644 index 00000000..0eea1380 --- /dev/null +++ b/lib/providers/rate-limiter.ts @@ -0,0 +1,95 @@ +import { Ratelimit } from '@upstash/ratelimit'; +import { Redis } from '@upstash/redis'; +import type { RateLimitConfig } from './types'; + +let redis: Redis | null = null; + +function getRedis(): Redis | null { + if (redis) return redis; + const url = process.env.UPSTASH_REDIS_REST_URL; + const token = process.env.UPSTASH_REDIS_REST_TOKEN; + if (!url || !token) return null; + redis = new Redis({ url, token }); + return redis; +} + +/** + * Distributed rate limiter backed by Upstash Redis. + * Falls back to in-memory token bucket when Upstash env vars are not set (local dev). + */ +export class TokenBucketRateLimiter { + private readonly upstashLimiter: Ratelimit | null; + + // In-memory fallback fields + private tokens: number; + private lastRefill: number; + private readonly maxTokens: number; + private readonly refillRateMs: number; + + constructor(config: RateLimitConfig, prefix?: string) { + this.maxTokens = config.maxRequests; + this.tokens = config.maxRequests; + this.refillRateMs = config.windowMs / config.maxRequests; + this.lastRefill = Date.now(); + + const redisClient = getRedis(); + if (redisClient) { + this.upstashLimiter = new Ratelimit({ + redis: redisClient, + limiter: Ratelimit.slidingWindow(config.maxRequests, `${config.windowMs} ms`), + prefix: prefix ?? 'ratelimit', + }); + } else { + this.upstashLimiter = null; + } + } + + async acquire(): Promise { + if (this.upstashLimiter) { + return this.acquireDistributed(); + } + return this.acquireLocal(); + } + + private async acquireDistributed(): Promise { + const { success, reset } = await this.upstashLimiter!.limit('global'); + if (success) return; + + // Wait until the window resets, then retry + const waitMs = Math.max(0, reset - Date.now()); + await new Promise((resolve) => setTimeout(resolve, waitMs)); + + // Retry once after waiting + const retry = await this.upstashLimiter!.limit('global'); + if (!retry.success) { + // Still limited — wait for the new reset + const retryWait = Math.max(0, retry.reset - Date.now()); + await new Promise((resolve) => setTimeout(resolve, retryWait)); + } + } + + private refill(): void { + const now = Date.now(); + const elapsed = now - this.lastRefill; + const newTokens = Math.floor(elapsed / this.refillRateMs); + if (newTokens > 0) { + this.tokens = Math.min(this.maxTokens, this.tokens + newTokens); + this.lastRefill = now; + } + } + + private async acquireLocal(): Promise { + this.refill(); + if (this.tokens > 0) { + this.tokens--; + return; + } + + const waitMs = this.refillRateMs - (Date.now() - this.lastRefill); + await new Promise((resolve) => setTimeout(resolve, Math.max(0, waitMs))); + this.refill(); + if (this.tokens > 0) { + this.tokens--; + } + } +} diff --git a/lib/providers/resolve-consent.ts b/lib/providers/resolve-consent.ts new file mode 100644 index 00000000..4c68c85c --- /dev/null +++ b/lib/providers/resolve-consent.ts @@ -0,0 +1,121 @@ +import { createServiceClient } from '@/lib/supabase/server'; +import type { TokenResponse } from './types'; +import { getOAuthConfig } from './oauth-config'; +import { refreshFortnoxToken } from './fortnox/oauth'; +import { refreshVismaToken } from './visma/oauth'; +import { refreshBrioxToken } from './briox/oauth'; +import { refreshBjornLundenToken } from './bjornlunden/oauth'; + +export interface ResolvedConsent { + consent: Record; + accessToken: string; + providerCompanyId?: string; +} + +export async function resolveConsent(companyId: string, consentId: string): Promise { + const supabase = createServiceClient(); + + // Load consent + const { data: consentRows } = await supabase + .from('provider_consents') + .select('*') + .eq('id', consentId) + .eq('company_id', companyId) + .limit(1); + + if (!consentRows || consentRows.length === 0) { + throw { status: 404, message: 'Consent not found' }; + } + + const consent = consentRows[0]!; + if (consent.status !== 1) { + throw { status: 403, message: 'Consent is not in Accepted status' }; + } + + if (!consent.provider) { + throw { status: 400, message: 'Consent has no provider set — complete onboarding first' }; + } + + // Load tokens + const { data: tokenRows } = await supabase + .from('provider_consent_tokens') + .select('*') + .eq('consent_id', consentId) + .limit(1); + + if (!tokenRows || tokenRows.length === 0) { + throw { status: 401, message: 'No tokens found for this consent — complete OAuth first' }; + } + + const tokens = tokenRows[0]!; + + // Bokio: private API tokens that don't expire + if (consent.provider === 'bokio') { + return { + consent, + accessToken: tokens.access_token as string, + providerCompanyId: tokens.provider_company_id as string | undefined, + }; + } + + // Björn Lunden: client credentials — auto-refresh when expired + if (consent.provider === 'bjornlunden') { + if (tokens.token_expires_at && new Date(tokens.token_expires_at as string) < new Date()) { + const refreshed = await refreshBjornLundenToken(); + const newExpiresAt = new Date(Date.now() + refreshed.expires_in * 1000).toISOString(); + + await supabase + .from('provider_consent_tokens') + .update({ + access_token: refreshed.access_token, + token_expires_at: newExpiresAt, + }) + .eq('consent_id', consentId); + + return { + consent, + accessToken: refreshed.access_token, + providerCompanyId: tokens.provider_company_id as string | undefined, + }; + } + + return { + consent, + accessToken: tokens.access_token as string, + providerCompanyId: tokens.provider_company_id as string | undefined, + }; + } + + // Check expiry, auto-refresh if needed + if (tokens.token_expires_at && new Date(tokens.token_expires_at as string) < new Date()) { + if (!tokens.refresh_token) { + throw { status: 401, message: 'Access token expired and no refresh token available' }; + } + + const config = getOAuthConfig(consent.provider as string); + let refreshed: TokenResponse; + + if (consent.provider === 'fortnox') { + refreshed = await refreshFortnoxToken(config, tokens.refresh_token as string); + } else if (consent.provider === 'briox') { + refreshed = await refreshBrioxToken(config.clientId, tokens.refresh_token as string); + } else { + refreshed = await refreshVismaToken(config, tokens.refresh_token as string); + } + + const newExpiresAt = new Date(Date.now() + refreshed.expires_in * 1000).toISOString(); + + await supabase + .from('provider_consent_tokens') + .update({ + access_token: refreshed.access_token, + refresh_token: refreshed.refresh_token, + token_expires_at: newExpiresAt, + }) + .eq('consent_id', consentId); + + return { consent, accessToken: refreshed.access_token }; + } + + return { consent, accessToken: tokens.access_token as string }; +} diff --git a/lib/providers/retry.ts b/lib/providers/retry.ts new file mode 100644 index 00000000..76ffcfd7 --- /dev/null +++ b/lib/providers/retry.ts @@ -0,0 +1,48 @@ +export interface RetryOptions { + maxAttempts?: number; + initialDelayMs?: number; + maxDelayMs?: number; + backoffMultiplier?: number; + shouldRetry?: (error: unknown, attempt: number) => boolean; + /** Return a custom delay in ms for this error, or undefined to use default backoff. */ + getDelayMs?: (error: unknown, attempt: number) => number | undefined; +} + +const DEFAULT_OPTIONS = { + maxAttempts: 3, + initialDelayMs: 1000, + maxDelayMs: 30_000, + backoffMultiplier: 2, +}; + +export async function withRetry( + fn: () => Promise, + options?: RetryOptions, +): Promise { + const opts = { ...DEFAULT_OPTIONS, ...options }; + let lastError: unknown; + + for (let attempt = 1; attempt <= opts.maxAttempts; attempt++) { + try { + return await fn(); + } catch (error) { + lastError = error; + + if (attempt === opts.maxAttempts) break; + + if (options?.shouldRetry && !options.shouldRetry(error, attempt)) { + break; + } + + const customDelay = options?.getDelayMs?.(error, attempt); + const delay = customDelay ?? Math.min( + opts.initialDelayMs * opts.backoffMultiplier ** (attempt - 1), + opts.maxDelayMs, + ); + + await new Promise((resolve) => setTimeout(resolve, delay)); + } + } + + throw lastError; +} diff --git a/lib/providers/types.ts b/lib/providers/types.ts new file mode 100644 index 00000000..58abdbd1 --- /dev/null +++ b/lib/providers/types.ts @@ -0,0 +1,58 @@ +import type { ResourceType } from './dto'; + +export type ProviderName = 'fortnox' | 'visma' | 'briox' | 'bokio' | 'bjornlunden'; + +export interface RateLimitConfig { + maxRequests: number; + windowMs: number; +} + +export interface OAuthConfig { + clientId: string; + clientSecret: string; + redirectUri: string; +} + +export interface TokenResponse { + access_token: string; + refresh_token: string; + token_type: string; + expires_in: number; +} + +export interface ResourceConfig { + listEndpoint: string; + detailEndpoint: string; + idField: string; + mapper: (raw: Record) => unknown; + singleton?: boolean; +} + +export interface FortnoxResourceConfig extends ResourceConfig { + listKey: string; + detailKey: string; + supportsLastModified: boolean; + resolveDetailPath?: (resourceId: string, query?: Record) => string; + supportsEntryHydration?: boolean; +} + +export interface VismaResourceConfig extends ResourceConfig { + supportsModifiedFilter: boolean; + modifiedField?: string; +} + +export interface BrioxResourceConfig extends ResourceConfig { + listKey: string; + supportsModifiedFilter: boolean; + yearScoped?: boolean; + supportsEntryHydration?: boolean; + detailKey?: string; +} + +export interface BokioResourceConfig extends ResourceConfig { + paginated?: boolean; +} + +export interface BjornLundenResourceConfig extends ResourceConfig { + paginated?: boolean; +} diff --git a/lib/providers/visma/client.ts b/lib/providers/visma/client.ts new file mode 100644 index 00000000..8a71a571 --- /dev/null +++ b/lib/providers/visma/client.ts @@ -0,0 +1,140 @@ +import { TokenBucketRateLimiter } from '../rate-limiter'; +import { withRetry } from '../retry'; +import { VISMA_BASE_URL, VISMA_RATE_LIMIT } from './config'; + +export class VismaApiError extends Error { + constructor( + message: string, + public readonly statusCode: number, + public readonly body?: string, + ) { + super(message); + this.name = 'VismaApiError'; + } +} + +function isRetryableError(error: unknown): boolean { + if (error instanceof VismaApiError) { + if (error.statusCode === 401 || error.statusCode === 403 || error.statusCode === 404) { + return false; + } + return error.statusCode === 429 || error.statusCode >= 500; + } + return false; +} + +interface VismaPaginatedResponse { + Meta?: { TotalNumberOfPages?: number }; + Data: T[]; +} + +export class VismaClient { + private readonly rateLimiter: TokenBucketRateLimiter; + private readonly baseUrl: string; + + constructor(baseUrl?: string) { + this.baseUrl = baseUrl ?? VISMA_BASE_URL; + this.rateLimiter = new TokenBucketRateLimiter(VISMA_RATE_LIMIT, 'ratelimit:visma'); + } + + async get(accessToken: string, path: string): Promise { + return withRetry( + async () => { + await this.rateLimiter.acquire(); + const url = `${this.baseUrl}${path}`; + const response = await fetch(url, { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + const body = await response.text().catch(() => ''); + throw new VismaApiError( + `Visma API error: ${response.status} ${response.statusText}`, + response.status, + body, + ); + } + + return response.json() as Promise; + }, + { + maxAttempts: 3, + initialDelayMs: 1000, + shouldRetry: isRetryableError, + }, + ); + } + + async getPage( + accessToken: string, + path: string, + options?: { + page?: number; + pageSize?: number; + modifiedSince?: string; + modifiedField?: string; + }, + ): Promise<{ items: T[]; page: number; totalPages: number; totalCount: number }> { + const pageSize = options?.pageSize ?? 100; + const page = options?.page ?? 1; + + const params = new URLSearchParams(); + params.set('$top', String(pageSize)); + params.set('$skip', String((page - 1) * pageSize)); + + if (options?.modifiedSince && options?.modifiedField) { + params.set( + '$filter', + `${options.modifiedField} gt ${options.modifiedSince}`, + ); + } + + const separator = path.includes('?') ? '&' : '?'; + const fullPath = `${path}${separator}${params.toString()}`; + + const response = await this.get & { Meta?: { TotalNumberOfResults?: number; TotalNumberOfPages?: number } }>(accessToken, fullPath); + + const totalPages = response.Meta?.TotalNumberOfPages ?? 1; + const totalCount = response.Meta?.TotalNumberOfResults ?? 0; + + return { + items: Array.isArray(response.Data) ? response.Data : [], + page, + totalPages, + totalCount, + }; + } + + async getPaginated( + accessToken: string, + path: string, + options?: { + modifiedSince?: string; + modifiedField?: string; + pageSize?: number; + }, + ): Promise { + const allItems: T[] = []; + let page = 1; + let totalPages = 1; + + do { + const result = await this.getPage(accessToken, path, { + page, + pageSize: options?.pageSize, + modifiedSince: options?.modifiedSince, + modifiedField: options?.modifiedField, + }); + + allItems.push(...result.items); + totalPages = result.totalPages; + page++; + } while (page <= totalPages); + + return allItems; + } +} diff --git a/lib/providers/visma/config.ts b/lib/providers/visma/config.ts new file mode 100644 index 00000000..6aa32fb1 --- /dev/null +++ b/lib/providers/visma/config.ts @@ -0,0 +1,74 @@ +import { ResourceType } from '../dto'; +import type { VismaResourceConfig, RateLimitConfig } from '../types'; +import { + mapVismaToSalesInvoice, + mapVismaToSupplierInvoice, + mapVismaToCustomer, + mapVismaToSupplier, + mapVismaToJournal, + mapVismaToAccountingAccount, + mapVismaToCompanyInformation, +} from './mapper'; + +export const VISMA_BASE_URL = 'https://eaccountingapi.vismaonline.com/v2'; +export const VISMA_AUTH_URL = 'https://identity.vismaonline.com/connect/authorize'; +export const VISMA_TOKEN_URL = 'https://identity.vismaonline.com/connect/token'; +export const VISMA_REVOKE_URL = 'https://identity.vismaonline.com/connect/revocation'; +export const VISMA_RATE_LIMIT: RateLimitConfig = { maxRequests: 10, windowMs: 1000 }; + +export const VISMA_RESOURCE_CONFIGS: Partial> = { + [ResourceType.SalesInvoices]: { + listEndpoint: '/customerinvoices', + detailEndpoint: '/customerinvoices/{id}', + idField: 'Id', + mapper: mapVismaToSalesInvoice, + supportsModifiedFilter: true, + modifiedField: 'ModifiedUtc', + }, + [ResourceType.SupplierInvoices]: { + listEndpoint: '/supplierinvoices', + detailEndpoint: '/supplierinvoices/{id}', + idField: 'Id', + mapper: mapVismaToSupplierInvoice, + supportsModifiedFilter: true, + modifiedField: 'ModifiedUtc', + }, + [ResourceType.Customers]: { + listEndpoint: '/customers', + detailEndpoint: '/customers/{id}', + idField: 'Id', + mapper: mapVismaToCustomer, + supportsModifiedFilter: true, + modifiedField: 'ChangedUtc', + }, + [ResourceType.Suppliers]: { + listEndpoint: '/suppliers', + detailEndpoint: '/suppliers/{id}', + idField: 'Id', + mapper: mapVismaToSupplier, + supportsModifiedFilter: true, + modifiedField: 'ModifiedUtc', + }, + [ResourceType.Journals]: { + listEndpoint: '/vouchers', + detailEndpoint: '/vouchers/{id}', + idField: 'Id', + mapper: mapVismaToJournal, + supportsModifiedFilter: false, + }, + [ResourceType.AccountingAccounts]: { + listEndpoint: '/accounts', + detailEndpoint: '/accounts/{id}', + idField: 'Number', + mapper: mapVismaToAccountingAccount, + supportsModifiedFilter: false, + }, + [ResourceType.CompanyInformation]: { + listEndpoint: '/companysettings', + detailEndpoint: '/companysettings', + idField: 'CorporateIdentityNumber', + mapper: mapVismaToCompanyInformation, + supportsModifiedFilter: false, + singleton: true, + }, +}; diff --git a/lib/providers/visma/mapper.ts b/lib/providers/visma/mapper.ts new file mode 100644 index 00000000..865709f0 --- /dev/null +++ b/lib/providers/visma/mapper.ts @@ -0,0 +1,239 @@ +import type { + SalesInvoiceDto, SalesInvoiceLineDto, InvoiceStatusCode, + LegalMonetaryTotalDto, PaymentStatusDto, + SupplierInvoiceDto, SupplierInvoiceLineDto, + CustomerDto, SupplierDto, + JournalDto, AccountingEntryDto, + AccountingAccountDto, AccountType, + CompanyInformationDto, + AmountType, PartyDto, +} from '../dto'; + +function amount(value: number | undefined | null, currency: string = 'SEK'): AmountType { + return { value: value ?? 0, currencyCode: currency }; +} + +function deriveInvoiceStatus(raw: Record): InvoiceStatusCode { + const remaining = raw['RemainingAmount'] as number ?? 0; + const total = raw['TotalAmount'] as number ?? 0; + if (raw['IsCancelled'] === true) return 'cancelled'; + if (remaining === 0 && total > 0) return 'paid'; + if (raw['IsBooked'] === true) return 'booked'; + if (raw['IsSent'] === true || raw['SendType'] != null) return 'sent'; + return 'draft'; +} + +function buildParty(name: string, orgNumber?: string, raw?: Record): PartyDto { + return { + name, + identifications: orgNumber ? [{ id: orgNumber, schemeId: 'SE:ORGNR' }] : [], + postalAddress: raw ? { + streetName: (raw['InvoiceAddress1'] ?? raw['Address1']) as string | undefined, + additionalStreetName: (raw['InvoiceAddress2'] ?? raw['Address2']) as string | undefined, + cityName: (raw['InvoiceCity'] ?? raw['City']) as string | undefined, + postalZone: (raw['InvoicePostalCode'] ?? raw['PostalCode']) as string | undefined, + countryCode: raw['CountryCode'] as string | undefined, + } : undefined, + legalEntity: orgNumber ? { + registrationName: name, + companyId: orgNumber, + companyIdSchemeId: 'SE:ORGNR', + } : undefined, + contact: { + email: (raw?.['EmailAddress'] ?? raw?.['Email']) as string | undefined, + telephone: (raw?.['Telephone'] ?? raw?.['Phone']) as string | undefined, + }, + }; +} + +export function mapVismaToSalesInvoice(raw: Record): SalesInvoiceDto { + const currency = (raw['CurrencyCode'] as string) ?? 'SEK'; + const total = raw['TotalAmount'] as number ?? 0; + const remaining = raw['RemainingAmount'] as number ?? 0; + + const rows = (raw['Rows'] as Record[] | undefined) ?? []; + const lines: SalesInvoiceLineDto[] = rows.map((row, idx) => ({ + id: String(row['LineNumber'] ?? idx + 1), + description: row['Text'] as string | undefined, + quantity: row['Quantity'] as number | undefined, + unitCode: row['UnitAbbreviation'] as string | undefined, + unitPrice: row['UnitPrice'] != null ? amount(row['UnitPrice'] as number, currency) : undefined, + lineExtensionAmount: amount(row['LineTotal'] as number ?? 0, currency), + taxPercent: row['VatRatePercent'] as number | undefined, + accountNumber: row['AccountNumber'] != null ? String(row['AccountNumber']) : undefined, + articleNumber: row['ArticleNumber'] as string | undefined, + })); + + const legalMonetaryTotal: LegalMonetaryTotalDto = { + lineExtensionAmount: amount(total, currency), + payableAmount: amount(total, currency), + }; + + const paymentStatus: PaymentStatusDto = { + paid: remaining === 0 && total > 0, + balance: amount(remaining, currency), + }; + + return { + id: String(raw['Id'] ?? ''), + invoiceNumber: String(raw['InvoiceNumber'] ?? ''), + issueDate: (raw['InvoiceDate'] as string) ?? '', + dueDate: raw['DueDate'] as string | undefined, + currencyCode: currency, + status: deriveInvoiceStatus(raw), + supplier: buildParty(''), + customer: buildParty( + (raw['InvoiceCustomerName'] ?? '') as string, + undefined, + ), + lines, + legalMonetaryTotal, + paymentStatus, + createdAt: raw['CreatedUtc'] as string | undefined, + updatedAt: raw['ModifiedUtc'] as string | undefined, + _raw: raw, + }; +} + +export function mapVismaToSupplierInvoice(raw: Record): SupplierInvoiceDto { + const currency = (raw['CurrencyCode'] as string) ?? 'SEK'; + const total = raw['TotalAmount'] as number ?? 0; + const remaining = raw['RemainingAmount'] as number ?? 0; + + const rows = (raw['Rows'] as Record[] | undefined) ?? []; + const lines: SupplierInvoiceLineDto[] = rows.map((row, idx) => { + const debit = (row['DebetAmount'] as number) ?? 0; + const credit = (row['CreditAmount'] as number) ?? 0; + const rowAmount = debit || credit; + return { + id: String(row['LineNumber'] ?? idx + 1), + description: row['TransactionText'] as string | undefined, + quantity: row['Quantity'] as number | undefined, + lineExtensionAmount: amount(rowAmount, currency), + accountNumber: row['AccountNumber'] != null ? String(row['AccountNumber']) : undefined, + }; + }); + + const legalMonetaryTotal: LegalMonetaryTotalDto = { + lineExtensionAmount: amount(total, currency), + payableAmount: amount(total, currency), + }; + + const paymentStatus: PaymentStatusDto = { + paid: remaining === 0 && total > 0, + balance: amount(remaining, currency), + }; + + return { + id: String(raw['Id'] ?? ''), + invoiceNumber: String(raw['InvoiceNumber'] ?? ''), + issueDate: (raw['InvoiceDate'] as string) ?? '', + dueDate: raw['DueDate'] as string | undefined, + currencyCode: currency, + status: deriveInvoiceStatus(raw), + supplier: buildParty((raw['SupplierName'] ?? '') as string), + buyer: buildParty(''), + lines, + legalMonetaryTotal, + paymentStatus, + updatedAt: raw['ModifiedUtc'] as string | undefined, + _raw: raw, + }; +} + +export function mapVismaToCustomer(raw: Record): CustomerDto { + return { + id: String(raw['Id'] ?? ''), + customerNumber: String(raw['CustomerNumber'] ?? ''), + type: raw['IsPrivatePerson'] === true ? 'private' : 'company', + party: buildParty( + (raw['Name'] as string) ?? '', + raw['CorporateIdentityNumber'] as string | undefined, + raw, + ), + active: raw['IsActive'] !== false, + note: raw['Note'] as string | undefined, + updatedAt: raw['ChangedUtc'] as string | undefined, + _raw: raw, + }; +} + +export function mapVismaToSupplier(raw: Record): SupplierDto { + return { + id: String(raw['Id'] ?? ''), + supplierNumber: String(raw['SupplierNumber'] ?? ''), + party: buildParty( + (raw['Name'] as string) ?? '', + raw['CorporateIdentityNumber'] as string | undefined, + raw, + ), + active: raw['IsActive'] !== false, + bankAccount: raw['BankAccountNumber'] as string | undefined, + bankGiro: raw['BankGiro'] as string | undefined, + plusGiro: raw['PlusGiro'] as string | undefined, + updatedAt: raw['ModifiedUtc'] as string | undefined, + _raw: raw, + }; +} + +export function mapVismaToAccountingAccount(raw: Record): AccountingAccountDto { + const num = Number(raw['Number']); + let type: AccountType | undefined; + if (num >= 1000 && num < 2000) type = 'asset'; + else if (num >= 2000 && num < 3000) type = 'liability'; + else if (num >= 3000 && num < 4000) type = 'revenue'; + else if (num >= 4000 && num < 9000) type = 'expense'; + + return { + accountNumber: String(raw['Number'] ?? ''), + name: (raw['Name'] as string) ?? '', + type, + vatCode: raw['VatCodeId'] != null ? String(raw['VatCodeId']) : undefined, + active: raw['IsActive'] !== false, + _raw: raw, + }; +} + +export function mapVismaToCompanyInformation(raw: Record): CompanyInformationDto { + return { + companyName: (raw['Name'] as string) ?? '', + organizationNumber: raw['CorporateIdentityNumber'] as string | undefined, + legalEntity: { + registrationName: (raw['Name'] as string) ?? '', + companyId: raw['CorporateIdentityNumber'] as string | undefined, + companyIdSchemeId: 'SE:ORGNR', + }, + address: { + streetName: raw['Address1'] as string | undefined, + cityName: raw['City'] as string | undefined, + postalZone: raw['PostalCode'] as string | undefined, + countryCode: raw['CountryCode'] as string | undefined, + }, + contact: { + email: raw['Email'] as string | undefined, + telephone: raw['Phone'] as string | undefined, + }, + baseCurrency: raw['CurrencyCode'] as string | undefined, + _raw: raw, + }; +} + +export function mapVismaToJournal(raw: Record): JournalDto { + const rows = (raw['Rows'] as Record[] | undefined) ?? []; + const entries: AccountingEntryDto[] = rows.map((row) => ({ + accountNumber: String(row['AccountNumber'] ?? ''), + accountName: row['AccountName'] as string | undefined, + debit: (row['DebitAmount'] as number) ?? 0, + credit: (row['CreditAmount'] as number) ?? 0, + description: row['Description'] as string | undefined, + })); + + return { + id: String(raw['Id'] ?? ''), + journalNumber: String(raw['VoucherNumber'] ?? raw['Number'] ?? ''), + description: raw['Description'] as string | undefined, + registrationDate: (raw['VoucherDate'] as string) ?? '', + entries, + _raw: raw, + }; +} diff --git a/lib/providers/visma/oauth.ts b/lib/providers/visma/oauth.ts new file mode 100644 index 00000000..7f7899f4 --- /dev/null +++ b/lib/providers/visma/oauth.ts @@ -0,0 +1,106 @@ +import { VISMA_AUTH_URL, VISMA_TOKEN_URL, VISMA_REVOKE_URL } from './config'; +import type { OAuthConfig, TokenResponse } from '../types'; + +const DEFAULT_SCOPES = [ + 'ea:api', + 'offline_access', + 'ea:sales_readonly', + 'ea:accounting_readonly', + 'ea:purchase_readonly', +]; + +const EACCOUNTING_ACR_VALUE = 'service:44643EB1-3F76-4C1C-A672-402AE8085934'; + +export function buildVismaAuthUrl( + config: OAuthConfig, + options?: { scopes?: string[]; state?: string; acrValues?: string }, +): string { + const params = new URLSearchParams({ + client_id: config.clientId, + redirect_uri: config.redirectUri, + response_type: 'code', + acr_values: options?.acrValues ?? EACCOUNTING_ACR_VALUE, + }); + + const scopes = options?.scopes?.length ? options.scopes : DEFAULT_SCOPES; + params.set('scope', scopes.join(' ')); + + if (options?.state) { + params.set('state', options.state); + } + + return `${VISMA_AUTH_URL}?${params.toString()}`; +} + +function basicAuthHeader(config: OAuthConfig): string { + const encoded = btoa(`${config.clientId}:${config.clientSecret}`); + return `Basic ${encoded}`; +} + +export async function exchangeVismaCode( + config: OAuthConfig, + code: string, +): Promise { + const response = await fetch(VISMA_TOKEN_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Authorization: basicAuthHeader(config), + }, + body: new URLSearchParams({ + grant_type: 'authorization_code', + code, + redirect_uri: config.redirectUri, + }).toString(), + }); + + if (!response.ok) { + const body = await response.text().catch(() => ''); + throw new Error(`Visma token exchange failed: ${response.status} ${body}`); + } + + return response.json() as Promise; +} + +export async function refreshVismaToken( + config: OAuthConfig, + refreshToken: string, +): Promise { + const response = await fetch(VISMA_TOKEN_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Authorization: basicAuthHeader(config), + }, + body: new URLSearchParams({ + grant_type: 'refresh_token', + refresh_token: refreshToken, + }).toString(), + }); + + if (!response.ok) { + const body = await response.text().catch(() => ''); + throw new Error(`Visma token refresh failed: ${response.status} ${body}`); + } + + return response.json() as Promise; +} + +export async function revokeVismaToken( + config: OAuthConfig, + refreshToken: string, +): Promise { + const response = await fetch(VISMA_REVOKE_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Authorization: basicAuthHeader(config), + }, + body: new URLSearchParams({ + token: refreshToken, + token_type_hint: 'refresh_token', + }).toString(), + }); + + return response.ok; +} diff --git a/package-lock.json b/package-lock.json index dfc37cf1..7a2d31f1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,6 +27,8 @@ "@supabase/ssr": "^0.8.0", "@supabase/supabase-js": "^2.93.1", "@tailwindcss/typography": "^0.5.19", + "@upstash/ratelimit": "^2.0.8", + "@upstash/redis": "^1.37.0", "@use-gesture/react": "^10.3.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -7433,6 +7435,39 @@ "win32" ] }, + "node_modules/@upstash/core-analytics": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/@upstash/core-analytics/-/core-analytics-0.0.10.tgz", + "integrity": "sha512-7qJHGxpQgQr9/vmeS1PktEwvNAF7TI4iJDi8Pu2CFZ9YUGHZH4fOP5TfYlZ4aVxfopnELiE4BS4FBjyK7V1/xQ==", + "license": "MIT", + "dependencies": { + "@upstash/redis": "^1.28.3" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@upstash/ratelimit": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/@upstash/ratelimit/-/ratelimit-2.0.8.tgz", + "integrity": "sha512-YSTMBJ1YIxsoPkUMX/P4DDks/xV5YYCswWMamU8ZIfK9ly6ppjRnVOyBhMDXBmzjODm4UQKcxsJPvaeFAijp5w==", + "license": "MIT", + "dependencies": { + "@upstash/core-analytics": "^0.0.10" + }, + "peerDependencies": { + "@upstash/redis": "^1.34.3" + } + }, + "node_modules/@upstash/redis": { + "version": "1.37.0", + "resolved": "https://registry.npmjs.org/@upstash/redis/-/redis-1.37.0.tgz", + "integrity": "sha512-LqOJ3+XWPLSZ2rGSed5DYG3ixybxb8EhZu3yQqF7MdZX1wLBG/FRcI6xcUZXHy/SS7mmXWyadrud0HJHkOc+uw==", + "license": "MIT", + "dependencies": { + "uncrypto": "^0.1.3" + } + }, "node_modules/@use-gesture/core": { "version": "10.3.1", "resolved": "https://registry.npmjs.org/@use-gesture/core/-/core-10.3.1.tgz", @@ -15194,6 +15229,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/uncrypto": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/uncrypto/-/uncrypto-0.1.3.tgz", + "integrity": "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==", + "license": "MIT" + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", diff --git a/package.json b/package.json index 96543b90..24ea16f6 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,8 @@ "@supabase/ssr": "^0.8.0", "@supabase/supabase-js": "^2.93.1", "@tailwindcss/typography": "^0.5.19", + "@upstash/ratelimit": "^2.0.8", + "@upstash/redis": "^1.37.0", "@use-gesture/react": "^10.3.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/supabase/migrations/20260402010000_provider_consents.sql b/supabase/migrations/20260402010000_provider_consents.sql new file mode 100644 index 00000000..80c4ba35 --- /dev/null +++ b/supabase/migrations/20260402010000_provider_consents.sql @@ -0,0 +1,112 @@ +-- Provider consents for direct accounting provider integrations +-- Replaces the external dependency on the Arcim Sync gateway + +-- Provider consents (one per connected accounting company) +CREATE TABLE IF NOT EXISTS provider_consents ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + company_id UUID NOT NULL REFERENCES companies(id) ON DELETE CASCADE, + name TEXT NOT NULL, + status INTEGER NOT NULL DEFAULT 0, -- 0=Created, 1=Accepted, 2=Revoked, 3=Inactive + provider TEXT, -- fortnox, visma, briox, bokio, bjornlunden + org_number TEXT, + company_name TEXT, + etag TEXT NOT NULL DEFAULT gen_random_uuid()::text, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + expires_at TIMESTAMPTZ +); + +CREATE INDEX idx_provider_consents_company ON provider_consents(company_id); +CREATE INDEX idx_provider_consents_company_provider ON provider_consents(company_id, provider); + +ALTER TABLE provider_consents ENABLE ROW LEVEL SECURITY; + +CREATE POLICY provider_consents_select ON provider_consents + FOR SELECT USING (company_id IN (SELECT public.user_company_ids())); + +CREATE POLICY provider_consents_insert ON provider_consents + FOR INSERT WITH CHECK (company_id IN (SELECT public.user_company_ids())); + +CREATE POLICY provider_consents_update ON provider_consents + FOR UPDATE USING (company_id IN (SELECT public.user_company_ids())); + +CREATE POLICY provider_consents_delete ON provider_consents + FOR DELETE USING (company_id IN (SELECT public.user_company_ids())); + )); + +CREATE TRIGGER update_provider_consents_updated_at + BEFORE UPDATE ON provider_consents + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +-- Encrypted token storage for provider OAuth/API tokens +CREATE TABLE IF NOT EXISTS provider_consent_tokens ( + consent_id UUID PRIMARY KEY REFERENCES provider_consents(id) ON DELETE CASCADE, + provider TEXT NOT NULL, + access_token TEXT NOT NULL, + refresh_token TEXT, + token_expires_at TIMESTAMPTZ, + provider_company_id TEXT, + scopes TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +ALTER TABLE provider_consent_tokens ENABLE ROW LEVEL SECURITY; + +CREATE POLICY provider_consent_tokens_select ON provider_consent_tokens + FOR SELECT USING (consent_id IN ( + SELECT id FROM provider_consents WHERE company_id IN ( + SELECT company_id FROM team_members WHERE user_id = auth.uid() + ) + )); + +CREATE POLICY provider_consent_tokens_insert ON provider_consent_tokens + FOR INSERT WITH CHECK (consent_id IN ( + SELECT id FROM provider_consents WHERE company_id IN ( + SELECT company_id FROM team_members WHERE user_id = auth.uid() + ) + )); + +CREATE POLICY provider_consent_tokens_update ON provider_consent_tokens + FOR UPDATE USING (consent_id IN ( + SELECT id FROM provider_consents WHERE company_id IN ( + SELECT company_id FROM team_members WHERE user_id = auth.uid() + ) + )); + +CREATE TRIGGER update_provider_consent_tokens_updated_at + BEFORE UPDATE ON provider_consent_tokens + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +-- One-time codes for OAuth callback validation +CREATE TABLE IF NOT EXISTS provider_otc ( + code TEXT PRIMARY KEY, + consent_id UUID NOT NULL REFERENCES provider_consents(id) ON DELETE CASCADE, + expires_at TIMESTAMPTZ NOT NULL, + used_at TIMESTAMPTZ +); + +CREATE INDEX idx_provider_otc_consent ON provider_otc(consent_id); + +ALTER TABLE provider_otc ENABLE ROW LEVEL SECURITY; + +CREATE POLICY provider_otc_select ON provider_otc + FOR SELECT USING (consent_id IN ( + SELECT id FROM provider_consents WHERE company_id IN ( + SELECT company_id FROM team_members WHERE user_id = auth.uid() + ) + )); + +CREATE POLICY provider_otc_insert ON provider_otc + FOR INSERT WITH CHECK (consent_id IN ( + SELECT id FROM provider_consents WHERE company_id IN ( + SELECT company_id FROM team_members WHERE user_id = auth.uid() + ) + )); + +CREATE POLICY provider_otc_update ON provider_otc + FOR UPDATE USING (consent_id IN ( + SELECT id FROM provider_consents WHERE company_id IN ( + SELECT company_id FROM team_members WHERE user_id = auth.uid() + ) + ));