* refactor: remove unnecessary secondary action from EmptyInvoices component

* feat: add direct provider layer and provider_consents migration

Replace Arcim Sync gateway dependency with direct provider clients
for Fortnox, Visma, Briox, Bokio, and Björn Lundén. Adds OAuth
config, rate limiting, retry logic, data fetching, and consent
storage via new provider_consents/tokens/otc tables.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: migrate arcim extension to direct provider APIs

Replace Arcim Sync gateway calls with direct provider API access.
Use FortnoxClient.getText() for SIE endpoints that return plain text
instead of JSON. OAuth callback now returns HTML with postMessage
to communicate with the opener window instead of redirecting.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: use OAuth popup window instead of new tab

Open provider login in a centered popup that auto-closes on
completion via postMessage, keeping the user on a single tab.
Falls back to redirect flow if popup is blocked.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add connection status, consent reuse, and SIE duplicate detection

- Add listConsents() to query active consents by company
- Add GET /status route returning consents, SIE import history, and
  entity counts
- /connect reuses existing accepted consent instead of creating
  duplicates, and cleans up abandoned (status 0) consents
- /status only returns accepted (status 1) consents
- /sie-data checks each file's SHA-256 hash against sie_imports to
  report per-file import status (alreadyImported, importedAt)
- /sie-data blocks on SIE validation failure (mirrors manual upload)
- /import-sie validates unmapped accounts and auto-activates missing
  BAS accounts in chart_of_accounts (mirrors manual upload)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: show active connections, SIE file status, and smart re-sync UI

- ProviderStep shows active connections with last import date, entity
  counts, "Synka igen" button, and disconnect option
- Already-connected providers greyed out in selection grid
- OptionsStep shows per-fiscal-year import status (imported vs new)
- SIE toggle disabled with explanation when all files already imported
- handleStartMigration skips already-imported SIE files
- Auto-skip mapping step and disable SIE on re-sync when up to date
- Result step hides empty "0 importerade" rows and shows
  "Allt är uppdaterat" when nothing new was fetched
- OptionRow supports disabled state

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: adjust COMING_SOON_PROVIDERS based on NODE_ENV for development and production

* Removed duplicate

* Removed duplicate

* refactor: redesign reports page navigation from grid boxes to bordered card layout

Replace the 4-column grid of uneven TabsList boxes with a CSS grid card
using auto-sized columns separated by 1px border dividers. All sections
now share equal height via items-stretch, with clear visual separation
between groups.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: trigger Vercel deployment

* Update supabase/migrations/20260402010000_provider_consents.sql

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

* Update lib/providers/rate-limiter.ts

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

* chore: re-trigger checks after migration sync

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
This commit is contained in:
Mattsson
2026-04-07 10:30:20 +02:00
committed by GitHub
parent e2d9c8bb85
commit e42da5c32b
41 changed files with 5219 additions and 488 deletions
+35 -37
View File
@@ -230,76 +230,74 @@ export default function ReportsPage() {
</select>
</div>
{/* Desktop: grid tab navigation */}
<div className="hidden sm:grid sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-2">
{/* Bokslut (Financial Statements) */}
<div className="space-y-1">
<p className="text-[10px] font-semibold text-muted-foreground/70 uppercase tracking-[0.08em] px-1">
Bokslut
</p>
<TabsList className="flex flex-col h-auto w-full gap-0.5 p-1">
<TabsTrigger value="trial-balance" className="w-full justify-start">
{/* Desktop: inline grouped tab navigation */}
<div className="hidden sm:grid sm:grid-cols-[auto_1px_auto_1px_auto_1px_auto] items-stretch mb-5 rounded-xl border border-border bg-card shadow-sm">
{/* Bokslut */}
<div className="flex flex-col gap-3 px-5 py-4">
<span className="text-[11px] font-semibold text-muted-foreground/80 uppercase tracking-[0.1em]">Bokslut</span>
<TabsList className="flex flex-col h-auto bg-transparent p-0 gap-1 items-start">
<TabsTrigger value="trial-balance" className="w-full justify-start text-[13px]">
Saldobalans
</TabsTrigger>
<TabsTrigger value="income-statement" className="w-full justify-start">
<TabsTrigger value="income-statement" className="w-full justify-start text-[13px]">
Resultaträkning
</TabsTrigger>
<TabsTrigger value="balance-sheet" className="w-full justify-start">
<TabsTrigger value="balance-sheet" className="w-full justify-start text-[13px]">
Balansräkning
</TabsTrigger>
</TabsList>
</div>
{/* Skatt & moms (Tax & VAT) */}
<div className="space-y-1">
<p className="text-[10px] font-semibold text-muted-foreground/70 uppercase tracking-[0.08em] px-1">
Skatt & moms
</p>
<TabsList className="flex flex-col h-auto w-full gap-0.5 p-1">
<TabsTrigger value="vat-declaration" className="w-full justify-start">
<div className="bg-border" />
{/* Skatt & moms */}
<div className="flex flex-col gap-3 px-5 py-4">
<span className="text-[11px] font-semibold text-muted-foreground/80 uppercase tracking-[0.1em]">Skatt & moms</span>
<TabsList className="flex flex-col h-auto bg-transparent p-0 gap-1 items-start">
<TabsTrigger value="vat-declaration" className="w-full justify-start text-[13px]">
Momsdeklaration
</TabsTrigger>
{isEnskildFirma && (
<TabsTrigger value="ne-declaration" className="w-full justify-start">
<TabsTrigger value="ne-declaration" className="w-full justify-start text-[13px]">
NE-bilaga
</TabsTrigger>
)}
{isAktiebolag && (
<TabsTrigger value="ink2-declaration" className="w-full justify-start">
<TabsTrigger value="ink2-declaration" className="w-full justify-start text-[13px]">
INK2
</TabsTrigger>
)}
</TabsList>
</div>
{/* Huvudböcker (Ledgers) */}
<div className="space-y-1">
<p className="text-[10px] font-semibold text-muted-foreground/70 uppercase tracking-[0.08em] px-1">
Huvudböcker
</p>
<TabsList className="flex flex-col h-auto w-full gap-0.5 p-1">
<TabsTrigger value="huvudbok" className="w-full justify-start">
<div className="bg-border" />
{/* Huvudböcker */}
<div className="flex flex-col gap-3 px-5 py-4">
<span className="text-[11px] font-semibold text-muted-foreground/80 uppercase tracking-[0.1em]">Huvudböcker</span>
<TabsList className="flex flex-col h-auto bg-transparent p-0 gap-1 items-start">
<TabsTrigger value="huvudbok" className="w-full justify-start text-[13px]">
Huvudbok
</TabsTrigger>
<TabsTrigger value="grundbok" className="w-full justify-start">
<TabsTrigger value="grundbok" className="w-full justify-start text-[13px]">
Grundbok
</TabsTrigger>
<TabsTrigger value="kundreskontra" className="w-full justify-start">
<TabsTrigger value="kundreskontra" className="w-full justify-start text-[13px]">
Kundreskontra
</TabsTrigger>
<TabsTrigger value="supplier-ledger" className="w-full justify-start">
<TabsTrigger value="supplier-ledger" className="w-full justify-start text-[13px]">
Leverantörsreskontra
</TabsTrigger>
</TabsList>
</div>
{/* Avstämning (Reconciliation) */}
<div className="space-y-1">
<p className="text-[10px] font-semibold text-muted-foreground/70 uppercase tracking-[0.08em] px-1">
Avstämning
</p>
<TabsList className="flex flex-col h-auto w-full gap-0.5 p-1">
<TabsTrigger value="bank-reconciliation" className="w-full justify-start">
<div className="bg-border" />
{/* Avstämning */}
<div className="flex flex-col gap-3 px-5 py-4">
<span className="text-[11px] font-semibold text-muted-foreground/80 uppercase tracking-[0.1em]">Avstämning</span>
<TabsList className="flex flex-col h-auto bg-transparent p-0 gap-1 items-start">
<TabsTrigger value="bank-reconciliation" className="w-full justify-start text-[13px]">
Bankavstämning
</TabsTrigger>
</TabsList>
@@ -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<ArcimProvider>(['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<ArcimProvider>(
process.env.NODE_ENV === 'development'
? ['bjornlunden', 'briox']
: ['visma', 'bjornlunden', 'briox']
)
const PROVIDER_LOGOS: Record<ArcimProvider, string> = {
fortnox: '/logos/fortnox.svg',
@@ -142,53 +182,159 @@ const PROVIDER_LOGOS: Record<ArcimProvider, string> = {
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 (
<div className="space-y-4">
{/* Existing connections */}
{activeConsents.length > 0 && (
<Card>
<CardHeader>
<CardTitle>Aktiva anslutningar</CardTitle>
<CardDescription>
Du har redan anslutna leverantörer. Synka igen för att hämta ny data.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{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 (
<div
key={consent.id}
className="flex items-center gap-4 rounded-lg border border-border bg-card p-4"
>
<img
src={PROVIDER_LOGOS[consent.provider]}
alt={providerInfo?.name ?? consent.provider}
className="h-10 w-10 shrink-0 rounded-lg object-contain"
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<p className="font-medium">{providerInfo?.name ?? consent.provider}</p>
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-500/10 px-2 py-0.5 text-[10px] font-medium text-emerald-600 dark:text-emerald-400">
<CheckCircle className="h-3 w-3" />
Ansluten
</span>
</div>
<div className="mt-0.5 space-y-0.5">
{consent.companyName && (
<p className="text-xs text-muted-foreground">{consent.companyName}</p>
)}
{lastImport ? (
<p className="text-xs text-muted-foreground">
Senaste import: {new Date(lastImport.imported_at ?? lastImport.created_at).toLocaleDateString('sv-SE')}
{lastImport.transactions_count != null && `${lastImport.transactions_count} verifikationer`}
</p>
) : (
<p className="text-xs text-muted-foreground">
Ansluten {consent.createdAt ? new Date(consent.createdAt).toLocaleDateString('sv-SE') : ''}
</p>
)}
{(connectionStatus?.entityCounts.customers ?? 0) > 0 && (
<p className="text-xs text-muted-foreground">
{connectionStatus?.entityCounts.customers} kunder, {connectionStatus?.entityCounts.suppliers} leverantörer, {connectionStatus?.entityCounts.invoices} fakturor
</p>
)}
</div>
</div>
<div className="flex shrink-0 gap-2">
<Button
variant="outline"
size="sm"
onClick={() => onResync(consent.provider, consent.id)}
>
<RotateCcw className="mr-1.5 h-3.5 w-3.5" />
Synka igen
</Button>
<Button
variant="ghost"
size="sm"
className="text-muted-foreground hover:text-destructive"
onClick={() => onDisconnect(consent.id)}
>
<XCircle className="h-3.5 w-3.5" />
</Button>
</div>
</div>
)
})}
</CardContent>
</Card>
)}
{/* Provider selection */}
<Card>
<CardHeader>
<CardTitle>Välj ditt nuvarande bokföringssystem</CardTitle>
<CardTitle>{activeConsents.length > 0 ? 'Anslut ytterligare system' : 'Välj ditt nuvarande bokföringssystem'}</CardTitle>
<CardDescription>
Vi hämtar bokföringsdata via SIE och kunder, leverantörer och fakturor via API:et.
</CardDescription>
</CardHeader>
<CardContent>
<div className="grid gap-3 sm:grid-cols-2">
{ARCIM_PROVIDERS.map((provider) => {
const comingSoon = COMING_SOON_PROVIDERS.has(provider.id)
return (
<button
key={provider.id}
disabled={comingSoon}
className={`relative flex items-center gap-4 rounded-lg border p-4 text-left transition-all ${
comingSoon
? 'cursor-not-allowed border-border/50 opacity-60'
: 'border-border hover:border-primary/50 hover:bg-accent/50 active:scale-[0.98]'
}`}
onClick={() => !comingSoon && onSelect(provider.id)}
>
<img
src={PROVIDER_LOGOS[provider.id]}
alt={provider.name}
className="h-10 w-10 shrink-0 rounded-lg object-contain"
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<p className="font-medium">{provider.name}</p>
{comingSoon && (
<span className="inline-flex items-center rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium text-muted-foreground">
Kommer snart
</span>
)}
{isLoadingStatus ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
) : (
<div className="grid gap-3 sm:grid-cols-2">
{ARCIM_PROVIDERS.map((provider) => {
const comingSoon = COMING_SOON_PROVIDERS.has(provider.id)
const alreadyConnected = activeConsents.some(c => c.provider === provider.id)
return (
<button
key={provider.id}
disabled={comingSoon || alreadyConnected}
className={`relative flex items-center gap-4 rounded-lg border p-4 text-left transition-all ${
comingSoon || alreadyConnected
? 'cursor-not-allowed border-border/50 opacity-60'
: 'border-border hover:border-primary/50 hover:bg-accent/50 active:scale-[0.98]'
}`}
onClick={() => !comingSoon && !alreadyConnected && onSelect(provider.id)}
>
<img
src={PROVIDER_LOGOS[provider.id]}
alt={provider.name}
className="h-10 w-10 shrink-0 rounded-lg object-contain"
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<p className="font-medium">{provider.name}</p>
{comingSoon && (
<span className="inline-flex items-center rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium text-muted-foreground">
Kommer snart
</span>
)}
{alreadyConnected && (
<span className="inline-flex items-center rounded-full bg-emerald-500/10 px-2 py-0.5 text-[10px] font-medium text-emerald-600 dark:text-emerald-400">
Ansluten
</span>
)}
</div>
<p className="text-xs text-muted-foreground">
{alreadyConnected ? 'Använd "Synka igen" ovan' : provider.authType === 'oauth' ? 'Anslut via inloggning' : 'Anslut med API-nyckel'}
</p>
</div>
<p className="text-xs text-muted-foreground">
{provider.authType === 'oauth' ? 'Anslut via inloggning' : 'Anslut med API-nyckel'}
</p>
</div>
</button>
)
})}
</div>
</button>
)
})}
</div>
)}
</CardContent>
</Card>
</div>
@@ -285,14 +431,21 @@ function ConnectStep({
{authType === 'oauth' && authUrl && !isLoading && (
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
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.
</p>
<Button asChild className="min-h-11">
<a href={authUrl} target="_blank" rel="noopener noreferrer">
Logga in i {providerName}
<ExternalLink className="ml-2 h-4 w-4" />
</a>
<Button
className="min-h-11"
onClick={() => {
const w = 600
const h = 700
const left = window.screenX + (window.outerWidth - w) / 2
const top = window.screenY + (window.outerHeight - h) / 2
window.open(authUrl, 'arcim-oauth', `width=${w},height=${h},left=${left},top=${top}`)
}}
>
Logga in i {providerName}
<ExternalLink className="ml-2 h-4 w-4" />
</Button>
</div>
)}
@@ -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({
<OptionRow
icon={<Database className="h-4 w-4" />}
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 && (
<div className="ml-4 space-y-1.5">
{fileStatuses.map((fs) => (
<div key={fs.fiscalYear} className="flex items-center gap-2 text-xs">
{fs.alreadyImported ? (
<>
<CheckCircle className="h-3.5 w-3.5 text-emerald-500" />
<span className="text-muted-foreground">
Räkenskapsår {fs.fiscalYear} importerad {fs.importedAt ? new Date(fs.importedAt).toLocaleDateString('sv-SE') : ''}
</span>
</>
) : (
<>
<Calendar className="h-3.5 w-3.5 text-primary" />
<span className="font-medium">Räkenskapsår {fs.fiscalYear} ny data att importera</span>
</>
)}
</div>
))}
</div>
)}
{options.importSIEData && !allSieImported && (
<div className="flex items-center gap-3 rounded-lg border border-border p-3 ml-4">
<div className="text-muted-foreground">
<FileText className="h-4 w-4" />
@@ -679,17 +866,22 @@ function OptionRow({
description,
checked,
onChange,
disabled,
}: {
icon: React.ReactNode
label: string
description: string
checked: boolean
onChange: () => void
disabled?: boolean
}) {
return (
<div
className="flex cursor-pointer items-center gap-3 rounded-lg border border-border p-3 transition-colors hover:bg-accent/50"
onClick={onChange}
className={cn(
'flex items-center gap-3 rounded-lg border border-border p-3 transition-colors',
disabled ? 'opacity-60 cursor-not-allowed' : 'cursor-pointer hover:bg-accent/50'
)}
onClick={() => !disabled && onChange()}
>
<div className="text-muted-foreground">{icon}</div>
<div className="flex-1">
@@ -698,7 +890,8 @@ function OptionRow({
</div>
<Switch
checked={checked}
onCheckedChange={onChange}
onCheckedChange={() => !disabled && onChange()}
disabled={disabled}
onClick={(e) => e.stopPropagation()}
/>
</div>
@@ -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({
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2.5">
<StatusIcon status={overallIcon} />
{anySieFailed ? 'Migrering delvis genomförd' :
<StatusIcon status={nothingNew ? 'success' : overallIcon} />
{nothingNew ? 'Allt är uppdaterat' :
anySieFailed ? 'Migrering delvis genomförd' :
!allSieSucceeded ? 'Migrering klar med anmärkningar' :
'Migrering klar'}
</CardTitle>
<CardDescription className="text-sm">
{totalJournalEntries > 0 && (
{nothingNew ? (
'Det finns ingen ny data att importera från leverantören.'
) : totalJournalEntries > 0 ? (
<>
<span className="tabular-nums font-medium text-foreground">
{totalJournalEntries.toLocaleString('sv-SE')}
@@ -1023,7 +1229,7 @@ function ResultStep({
</span>
)}
</>
)}
) : null}
</CardDescription>
</CardHeader>
</Card>
@@ -1044,60 +1250,71 @@ function ResultStep({
)}
{/* ── API import results (company info, customers, etc.) ── */}
{results && (
<div className="space-y-2">
<h3 className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
<FileText className="h-4 w-4" />
Övriga data
</h3>
<div className="grid gap-2 sm:grid-cols-2">
{results.companyInfo && (
<EntityResultRow
icon={<Building2 className="h-4 w-4" />}
label="Företagsinformation"
status={results.companyInfo.imported ? 'success' : 'skipped'}
statusText={results.companyInfo.imported ? 'Importerad' : 'Hoppades över'}
/>
)}
{results.customers && (
<EntityResultRow
icon={<Users className="h-4 w-4" />}
label="Kunder"
status="success"
statusText={`${results.customers.imported} importerade`}
detail={results.customers.skipped > 0 ? `${results.customers.skipped} fanns redan` : undefined}
/>
)}
{results.suppliers && (
<EntityResultRow
icon={<Truck className="h-4 w-4" />}
label="Leverantörer"
status="success"
statusText={`${results.suppliers.imported} importerade`}
detail={results.suppliers.skipped > 0 ? `${results.suppliers.skipped} fanns redan` : undefined}
/>
)}
{results.salesInvoices && (
<EntityResultRow
icon={<FileText className="h-4 w-4" />}
label="Kundfakturor"
status="success"
statusText={`${results.salesInvoices.imported} importerade`}
detail={results.salesInvoices.skipped > 0 ? `${results.salesInvoices.skipped} hoppades över` : undefined}
/>
)}
{results.supplierInvoices && (
<EntityResultRow
icon={<FileText className="h-4 w-4" />}
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 (
<div className="space-y-2">
<h3 className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
<FileText className="h-4 w-4" />
Övriga data
</h3>
<div className="grid gap-2 sm:grid-cols-2">
{hasCompanyInfo && (
<EntityResultRow
icon={<Building2 className="h-4 w-4" />}
label="Företagsinformation"
status="success"
statusText="Importerad"
/>
)}
{hasCustomers && (
<EntityResultRow
icon={<Users className="h-4 w-4" />}
label="Kunder"
status="success"
statusText={`${results.customers!.imported} importerade`}
detail={results.customers!.skipped > 0 ? `${results.customers!.skipped} fanns redan` : undefined}
/>
)}
{hasSuppliers && (
<EntityResultRow
icon={<Truck className="h-4 w-4" />}
label="Leverantörer"
status="success"
statusText={`${results.suppliers!.imported} importerade`}
detail={results.suppliers!.skipped > 0 ? `${results.suppliers!.skipped} fanns redan` : undefined}
/>
)}
{hasSalesInvoices && (
<EntityResultRow
icon={<FileText className="h-4 w-4" />}
label="Kundfakturor"
status="success"
statusText={`${results.salesInvoices!.imported} importerade`}
detail={results.salesInvoices!.skipped > 0 ? `${results.salesInvoices!.skipped} hoppades över` : undefined}
/>
)}
{hasSupplierInvoices && (
<EntityResultRow
icon={<FileText className="h-4 w-4" />}
label="Leverantörsfakturor"
status="success"
statusText={`${results.supplierInvoices!.imported} importerade`}
detail={results.supplierInvoices!.skipped > 0 ? `${results.supplierInvoices!.skipped} hoppades över` : undefined}
/>
)}
</div>
</div>
</div>
)}
)
})()}
{/* ── Next steps ── */}
<Card className="bg-muted/50">
@@ -1193,8 +1410,12 @@ export default function ArcimMigrationWorkspace(_props: WorkspaceComponentProps)
const [step, setStep] = useState<WizardStep>('provider')
const [isLoading, setIsLoading] = useState(false)
const [isLoadingStatus, setIsLoadingStatus] = useState(true)
const [error, setError] = useState<string | null>(null)
// Connection status (existing connections + import history)
const [connectionStatus, setConnectionStatus] = useState<ConnectionStatus | null>(null)
// Connection state
const [selectedProvider, setSelectedProvider] = useState<ArcimProvider | null>(null)
const [consentId, setConsentId] = useState<string | null>(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' && (
<ProviderStep onSelect={handleSelectProvider} />
<ProviderStep
onSelect={handleSelectProvider}
onResync={handleResync}
onDisconnect={handleDisconnect}
connectionStatus={connectionStatus}
isLoadingStatus={isLoadingStatus}
/>
)}
{step === 'connect' && selectedProvider && (
@@ -1606,6 +1916,7 @@ export default function ArcimMigrationWorkspace(_props: WorkspaceComponentProps)
<OptionsStep
options={migrationOptions}
sieAvailable={preview?.sieAvailable ?? false}
sieData={sieData}
onChange={setMigrationOptions}
onStart={handleStartMigration}
onBack={() => preview?.sieAvailable ? setStep('mapping') : setStep('preview')}
-3
View File
@@ -59,9 +59,6 @@ export function TeamPanel() {
<Users className="h-4 w-4" />
{teamName || 'Team'}
</CardTitle>
<CardDescription>
Ditt team har en ägare. Bjud in medarbetare via enskilda företag.
</CardDescription>
</CardHeader>
<CardContent>
<div className="divide-y divide-border/40">
-2
View File
@@ -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"
/>
)
}
+392 -90
View File
@@ -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<string>('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 = `<!DOCTYPE html><html><body><script>
if (window.opener) {
window.opener.postMessage({ type: 'arcim-oauth-success', consentId: '${consentId}' }, '${appUrl}');
window.close();
} else {
window.location.href = '${appUrl}/import?migration=connected&consentId=${consentId}';
}
</script><p>Anslutningen lyckades. Du kan stänga denna flik.</p></body></html>`
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 = `<!DOCTYPE html><html><body><script>
if (window.opener) {
window.opener.postMessage({ type: 'arcim-oauth-error' }, '${appUrl}');
window.close();
} else {
window.location.href = '${appUrl}/import?migration=error';
}
</script><p>Något gick fel. Du kan stänga denna flik.</p></body></html>`
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<Record<string, unknown>>(
resolved.accessToken,
'/financialyears'
)
const years = (fyResponse['FinancialYears'] as Record<string, unknown>[] | 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 (20242026)
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 (20242026)
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<Record<string, unknown>>(
resolved.accessToken,
'/financialyears'
)
const years = (fyResponse['FinancialYears'] as Record<string, unknown>[] | 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 20242026' }, { 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 20242026' }, { 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<string, { number: string; name: string }>()
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<string, string>()
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)
@@ -16,7 +16,7 @@ import type {
CompanyInformationDto,
PostalAddress,
PartyDto,
} from '../types'
} from '@/lib/providers/dto'
// ── Helpers ─────────────────────────────────────────────────────────
@@ -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<MigrationResults> {
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<Migra
if (options.importCustomers !== false) {
emitProgress(options, { status: 'importing', currentStep: 'Importerar kunder...', progress: 20 })
try {
const customers = await fetchCustomers(consentId)
const customers = await fetchCustomersDirect(provider, accessToken, providerCompanyId)
let imported = 0
let skipped = 0
@@ -157,7 +165,7 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
if (options.importSuppliers !== false) {
emitProgress(options, { status: 'importing', currentStep: 'Importerar leverantörer...', progress: 40 })
try {
const suppliers = await fetchSuppliers(consentId)
const suppliers = await fetchSuppliersDirect(provider, accessToken, providerCompanyId)
let imported = 0
let skipped = 0
@@ -212,7 +220,7 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
if (options.importSalesInvoices !== false) {
emitProgress(options, { status: 'importing', currentStep: 'Importerar kundfakturor...', progress: 60 })
try {
const invoices = await fetchSalesInvoices(consentId)
const invoices = await fetchSalesInvoicesDirect(provider, accessToken, providerCompanyId)
const openInvoices = invoices.filter(i =>
i.status === 'sent' || i.status === 'overdue' || i.status === 'booked'
)
@@ -324,7 +332,7 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
if (options.importSupplierInvoices !== false) {
emitProgress(options, { status: 'importing', currentStep: 'Importerar leverantörsfakturor...', progress: 80 })
try {
const invoices = await fetchSupplierInvoices(consentId)
const invoices = await fetchSupplierInvoicesDirect(provider, accessToken, providerCompanyId)
const openInvoices = invoices.filter(i =>
i.status === 'sent' || i.status === 'overdue' || i.status === 'booked' || i.status === 'draft'
)
@@ -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<ConsentRecord> {
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<ConsentRecord[]> {
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<ConsentRecord> {
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<void> {
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<OtcResponse> {
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 }
}
@@ -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."
}
}
+26 -190
View File
@@ -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<T> {
data: T[]
page: number
pageSize: number
totalCount: number
hasMore: boolean
}
export interface TaxSubtotalDto {
taxableAmount: AmountType
taxAmount: AmountType
taxCategory?: string
percent?: number
}
export interface TaxTotalDto {
taxAmount: AmountType
taxSubtotals?: TaxSubtotalDto[]
}
export interface LegalMonetaryTotalDto {
lineExtensionAmount: AmountType
taxExclusiveAmount?: AmountType
taxInclusiveAmount?: AmountType
payableAmount: AmountType
}
export interface PaymentStatusDto {
paid: boolean
balance: AmountType
lastPaymentDate?: string
}
// ── Company Information ─────────────────────────────────────────────
export interface CompanyInformationDto {
companyName: string
organizationNumber?: string
legalEntity?: PartyLegalEntity
address?: PostalAddress
contact?: Contact
vatNumber?: string
fiscalYearStart?: string // MM-DD
baseCurrency?: string
}
// ── Customer ────────────────────────────────────────────────────────
export type ArcimCustomerType = 'company' | 'private'
export interface CustomerDto {
id: string
customerNumber: string
type?: ArcimCustomerType
party: PartyDto
active: boolean
vatNumber?: string
defaultPaymentTermsDays?: number
note?: string
}
// ── Supplier ────────────────────────────────────────────────────────
export interface SupplierDto {
id: string
supplierNumber: string
party: PartyDto
active: boolean
vatNumber?: string
bankAccount?: string
bankGiro?: string
plusGiro?: string
defaultPaymentTermsDays?: number
note?: string
}
// ── Sales Invoice ───────────────────────────────────────────────────
export type InvoiceStatusCode = 'draft' | 'sent' | 'booked' | 'paid' | 'overdue' | 'cancelled' | 'credited'
export interface SalesInvoiceLineDto {
id: string
description?: string
quantity?: number
unitCode?: string
unitPrice?: AmountType
lineExtensionAmount: AmountType
taxPercent?: number
taxAmount?: AmountType
accountNumber?: string
itemName?: string
}
export interface SalesInvoiceDto {
id: string
invoiceNumber: string
issueDate: string
dueDate?: string
deliveryDate?: string
invoiceTypeCode?: string
currencyCode: string
status: InvoiceStatusCode
supplier: PartyDto
customer: PartyDto
lines: SalesInvoiceLineDto[]
taxTotal?: TaxTotalDto
legalMonetaryTotal: LegalMonetaryTotalDto
paymentStatus: PaymentStatusDto
paymentTerms?: string
note?: string
}
// ── Supplier Invoice ────────────────────────────────────────────────
export interface SupplierInvoiceLineDto {
id: string
description?: string
quantity?: number
unitCode?: string
unitPrice?: AmountType
lineExtensionAmount: AmountType
taxPercent?: number
taxAmount?: AmountType
accountNumber?: string
itemName?: string
}
export interface SupplierInvoiceDto {
id: string
invoiceNumber: string
issueDate: string
dueDate?: string
deliveryDate?: string
invoiceTypeCode?: string
currencyCode: string
status: InvoiceStatusCode
supplier: PartyDto
buyer: PartyDto
lines: SupplierInvoiceLineDto[]
taxTotal?: TaxTotalDto
legalMonetaryTotal: LegalMonetaryTotalDto
paymentStatus: PaymentStatusDto
ocrNumber?: string
note?: string
}
export type { CustomerType as ArcimCustomerType } from '@/lib/providers/dto'
// ── Supported providers ─────────────────────────────────────────────
@@ -32,13 +32,13 @@ export const EXTENSION_DEFINITIONS: Record<string, ExtensionDefinition[]> = {
},
{
"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",
+107
View File
@@ -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<T> {
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<T>(accessToken: string, userKey: string, path: string): Promise<T> {
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<T>;
},
{
maxAttempts: 3,
initialDelayMs: 1000,
shouldRetry: isRetryableError,
},
);
}
async getPage<T>(
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<BLPaginatedResponse<T>>(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<T>(accessToken: string, userKey: string, path: string): Promise<T[]> {
const response = await this.get<T[] | BLPaginatedResponse<T>>(accessToken, userKey, path);
if (Array.isArray(response)) {
return response;
}
return Array.isArray(response.data) ? response.data : [];
}
async getDetail<T>(accessToken: string, userKey: string, path: string): Promise<T> {
return this.get<T>(accessToken, userKey, path);
}
}
+67
View File
@@ -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<Record<ResourceType, BjornLundenResourceConfig>> = {
[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,
},
};
+302
View File
@@ -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<string, unknown>): 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<string, unknown>): 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<string, unknown>): 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<string, unknown>): 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<string, unknown>): 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<string, unknown>): JournalDto {
const rawEntries = (raw['ledgerEntries'] as Record<string, unknown>[] | 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<string, unknown>): 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<string, unknown>): CompanyInformationDto {
const settings = raw['preferredSettings'] as Record<string, unknown> | 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,
};
}
+44
View File
@@ -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<TokenResponse> {
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<string, unknown>;
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<TokenResponse> {
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;
+149
View File
@@ -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<T> {
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<T>(accessToken: string, path: string): Promise<T> {
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<T>(
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<BokioPaginatedResponse<T>>(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<T>(
accessToken: string,
companyId: string,
relativePath: string,
): Promise<T[]> {
const path = `/companies/${companyId}${relativePath}`;
const response = await this.get<T[] | { items: T[] }>(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<T>(
accessToken: string,
companyId: string,
relativePath: string,
): Promise<T> {
const path = `/companies/${companyId}${relativePath}`;
return this.get<T>(accessToken, path);
}
async getCompany<T>(
accessToken: string,
companyId: string,
): Promise<T | null> {
try {
return await this.get<T>(accessToken, `/companies/${companyId}`);
} catch (err) {
if (err instanceof BokioApiError && err.statusCode === 404) {
return null;
}
throw err;
}
}
}
+51
View File
@@ -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<Record<ResourceType, BokioResourceConfig>> = {
[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,
},
};
+227
View File
@@ -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<string, unknown>): 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<string, unknown>): 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<string, unknown>): 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<string, unknown> | undefined;
const rawLines = (raw['lineItems'] as Record<string, unknown>[] | 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<string, unknown>): CustomerDto {
const name = (raw['name'] as string) ?? '';
const orgNumber = raw['orgNumber'] as string | undefined;
const address = raw['address'] as Record<string, unknown> | undefined;
const contacts = (raw['contactsDetails'] as Record<string, unknown>[] | 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<string, unknown>): JournalDto {
const rawItems = (raw['items'] as Record<string, unknown>[] | 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<string, unknown>): 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<string, unknown>): CompanyInformationDto {
const address = raw['address'] as Record<string, unknown> | 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,
};
}
+10
View File
@@ -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,
};
}
+159
View File
@@ -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<string, unknown> & {
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<T>(accessToken: string, path: string): Promise<T> {
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<T>;
},
{
maxAttempts: 3,
initialDelayMs: 1000,
shouldRetry: isRetryableError,
},
);
}
async getPage<T>(
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<BrioxListResponse>(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<T>(
accessToken: string,
path: string,
listKey: string,
options?: {
fromModifiedDate?: string;
pageSize?: number;
},
): Promise<T[]> {
const allItems: T[] = [];
let page = 1;
let totalPages = 1;
do {
const result = await this.getPage<T>(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<string> {
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;
}
}
+79
View File
@@ -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<Record<ResourceType, BrioxResourceConfig>> = {
[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,
},
};
+275
View File
@@ -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<string, unknown>): 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<string, unknown>): 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<string, unknown>): 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<string, unknown>[] | 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<string, unknown>): 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<string, unknown>[] | 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<string, unknown>): 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<string, unknown>): 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<string, unknown>): JournalDto {
// Briox detail API returns rows as "journal_rows" (list endpoint omits them)
const rows = (raw['journal_rows'] as Record<string, unknown>[] | undefined)
?? (raw['journalrows'] as Record<string, unknown>[] | 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<string, unknown>): 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<string, unknown>): CompanyInformationDto {
// /user/info returns { info: { company_name, accounts: [...] } }
const info = (raw['info'] as Record<string, unknown> | undefined) ?? raw;
const accounts = (info['accounts'] as Record<string, unknown>[] | undefined) ?? [];
const account = accounts[0] as Record<string, unknown> | undefined;
const addr = account?.['address'] as Record<string, unknown> | 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,
};
}
+68
View File
@@ -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<TokenResponse> {
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<TokenResponse> {
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);
}
+448
View File
@@ -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<T> {
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<string, unknown>;
}
// ============================================
// 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<string, unknown>;
}
// ============================================
// 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<string, unknown>;
}
// ============================================
// 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<string, unknown>;
}
// ============================================
// 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<string, unknown>;
}
// ============================================
// 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<string, unknown>;
}
// ============================================
// Company Information
// ============================================
export interface CompanyInformationDto {
companyName: string;
organizationNumber?: string;
legalEntity?: PartyLegalEntity;
address?: PostalAddress;
contact?: Contact;
vatNumber?: string;
fiscalYearStart?: string;
baseCurrency?: string;
_raw?: Record<string, unknown>;
}
// ============================================
// 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<string, unknown>;
}
// ============================================
// 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<string, unknown>;
}
// ============================================
// 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<string, unknown>;
}
// ============================================
// 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<string, unknown>;
}
export interface IncomeStatementDto {
fiscalYear: number;
periodStart: string;
periodEnd: string;
baseCurrency: string;
revenue: FinancialReportCategoryDto;
expenses: FinancialReportCategoryDto;
netIncome: AmountType;
_raw?: Record<string, unknown>;
}
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<string, unknown>;
}
// ============================================
// 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<string, unknown>;
}
+185
View File
@@ -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<T>(accessToken: string, path: string): Promise<T> {
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<T>;
},
{
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<string> {
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<T>(
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<Record<string, unknown>>(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<T>(
accessToken: string,
path: string,
listKey: string,
options?: { lastModified?: string; pageSize?: number },
): Promise<T[]> {
const allItems: T[] = [];
let page = 1;
let totalPages = 1;
do {
const result = await this.getPage<T>(accessToken, path, listKey, {
page,
pageSize: options?.pageSize,
lastModified: options?.lastModified,
});
allItems.push(...result.items);
totalPages = result.totalPages;
page++;
} while (page <= totalPages);
return allItems;
}
}
+92
View File
@@ -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<Record<ResourceType, FortnoxResourceConfig>> = {
[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,
},
};
+274
View File
@@ -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<string, unknown>): 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<string, unknown>): 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<string, unknown>): 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<string, unknown>[] | 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<string, unknown>,
),
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<string, unknown>): 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<string, unknown>[] | 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<string, unknown>): 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<string, unknown>): 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<string, unknown>): JournalDto {
const voucherRows = (raw['VoucherRows'] as Record<string, unknown>[] | 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<string, unknown>): 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<string, unknown>): 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<string, unknown>, 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,
};
}
+105
View File
@@ -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<TokenResponse> {
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<TokenResponse>;
}
export async function refreshFortnoxToken(
config: OAuthConfig,
refreshToken: string,
): Promise<TokenResponse> {
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<TokenResponse>;
}
export async function revokeFortnoxToken(
config: OAuthConfig,
refreshToken: string,
): Promise<boolean> {
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;
}
+44
View File
@@ -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';
}
+285
View File
@@ -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<T>(
accessToken: string,
companyId: string,
path: string,
): Promise<T[]> {
const allItems: T[] = [];
let page = 1;
let totalPages = 1;
do {
const result = await bokioClient.getPage<T>(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<T>(
accessToken: string,
userKey: string,
path: string,
): Promise<T[]> {
const allItems: T[] = [];
let page = 1;
let totalPages = 1;
do {
const result = await bjornLundenClient.getPage<T>(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<CompanyInformationDto | null> {
try {
if (provider === 'fortnox') {
const config = FORTNOX_RESOURCE_CONFIGS[ResourceType.CompanyInformation]!;
const response = await fortnoxClient.get<Record<string, unknown>>(accessToken, config.listEndpoint);
const data = response[config.detailKey];
return data ? config.mapper(data as Record<string, unknown>) as CompanyInformationDto : null;
}
if (provider === 'visma') {
const config = VISMA_RESOURCE_CONFIGS[ResourceType.CompanyInformation]!;
const response = await vismaClient.get<Record<string, unknown>>(accessToken, config.listEndpoint);
return config.mapper(response) as CompanyInformationDto;
}
if (provider === 'briox') {
const config = BRIOX_RESOURCE_CONFIGS[ResourceType.CompanyInformation]!;
const response = await brioxClient.get<Record<string, unknown>>(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<Record<string, unknown>>(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<Record<string, unknown>>(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<CustomerDto[]> {
if (provider === 'fortnox') {
const config = FORTNOX_RESOURCE_CONFIGS[ResourceType.Customers]!;
const items = await fortnoxClient.getPaginated<Record<string, unknown>>(
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<Record<string, unknown>>(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<Record<string, unknown>>(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<Record<string, unknown>>(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<Record<string, unknown>>(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<SupplierDto[]> {
if (provider === 'fortnox') {
const config = FORTNOX_RESOURCE_CONFIGS[ResourceType.Suppliers]!;
const items = await fortnoxClient.getPaginated<Record<string, unknown>>(
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<Record<string, unknown>>(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<Record<string, unknown>>(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<Record<string, unknown>>(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<Record<string, unknown>>(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<SalesInvoiceDto[]> {
if (provider === 'fortnox') {
const config = FORTNOX_RESOURCE_CONFIGS[ResourceType.SalesInvoices]!;
const items = await fortnoxClient.getPaginated<Record<string, unknown>>(
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<Record<string, unknown>>(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<Record<string, unknown>>(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<Record<string, unknown>>(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<Record<string, unknown>>(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<SupplierInvoiceDto[]> {
if (provider === 'fortnox') {
const config = FORTNOX_RESOURCE_CONFIGS[ResourceType.SupplierInvoices]!;
const items = await fortnoxClient.getPaginated<Record<string, unknown>>(
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<Record<string, unknown>>(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<Record<string, unknown>>(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<Record<string, unknown>>(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<Record<string, unknown>>(accessToken, providerCompanyId, config.listEndpoint);
return items.map((item) => config.mapper(item) as SupplierInvoiceDto);
}
return [];
}
+95
View File
@@ -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<void> {
if (this.upstashLimiter) {
return this.acquireDistributed();
}
return this.acquireLocal();
}
private async acquireDistributed(): Promise<void> {
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<void> {
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--;
}
}
}
+121
View File
@@ -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<string, unknown>;
accessToken: string;
providerCompanyId?: string;
}
export async function resolveConsent(companyId: string, consentId: string): Promise<ResolvedConsent> {
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 };
}
+48
View File
@@ -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<T>(
fn: () => Promise<T>,
options?: RetryOptions,
): Promise<T> {
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;
}
+58
View File
@@ -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<string, unknown>) => unknown;
singleton?: boolean;
}
export interface FortnoxResourceConfig extends ResourceConfig {
listKey: string;
detailKey: string;
supportsLastModified: boolean;
resolveDetailPath?: (resourceId: string, query?: Record<string, string>) => 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;
}
+140
View File
@@ -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<T> {
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<T>(accessToken: string, path: string): Promise<T> {
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<T>;
},
{
maxAttempts: 3,
initialDelayMs: 1000,
shouldRetry: isRetryableError,
},
);
}
async getPage<T>(
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<VismaPaginatedResponse<T> & { 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<T>(
accessToken: string,
path: string,
options?: {
modifiedSince?: string;
modifiedField?: string;
pageSize?: number;
},
): Promise<T[]> {
const allItems: T[] = [];
let page = 1;
let totalPages = 1;
do {
const result = await this.getPage<T>(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;
}
}
+74
View File
@@ -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<Record<ResourceType, VismaResourceConfig>> = {
[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,
},
};
+239
View File
@@ -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<string, unknown>): 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<string, unknown>): 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<string, unknown>): 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<string, unknown>[] | 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<string, unknown>): 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<string, unknown>[] | 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<string, unknown>): 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<string, unknown>): 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<string, unknown>): 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<string, unknown>): 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<string, unknown>): JournalDto {
const rows = (raw['Rows'] as Record<string, unknown>[] | 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,
};
}
+106
View File
@@ -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<TokenResponse> {
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<TokenResponse>;
}
export async function refreshVismaToken(
config: OAuthConfig,
refreshToken: string,
): Promise<TokenResponse> {
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<TokenResponse>;
}
export async function revokeVismaToken(
config: OAuthConfig,
refreshToken: string,
): Promise<boolean> {
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;
}
+41
View File
@@ -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",
+2
View File
@@ -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",
@@ -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()
)
));