Staging (#181)
* 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:
@@ -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')}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 (2024–2026)
|
||||
const filteredFiles = sieResult.files.filter(f => ALLOWED_FISCAL_YEARS.has(f.fiscalYear))
|
||||
if (filteredFiles.length < sieResult.files.length) {
|
||||
const skippedYears = sieResult.files
|
||||
.filter(f => !ALLOWED_FISCAL_YEARS.has(f.fiscalYear))
|
||||
.map(f => f.fiscalYear)
|
||||
log.info(`Filtered out fiscal years: ${skippedYears.join(', ')} (only importing ${[...ALLOWED_FISCAL_YEARS].join(', ')})`)
|
||||
if (allowedYears.length > 0) {
|
||||
// Fetch SIE type 4 for the most recent allowed year to get stats
|
||||
const latestYear = allowedYears[allowedYears.length - 1]
|
||||
const sieContent = await fortnoxClient.getText(
|
||||
resolved.accessToken,
|
||||
`/sie/4?financialyear=${latestYear.id}`
|
||||
)
|
||||
if (sieContent) {
|
||||
const parsed = parseSIEFile(sieContent)
|
||||
sieAvailable = true
|
||||
sieStats = {
|
||||
accountCount: parsed.accounts.length,
|
||||
transactionCount: parsed.vouchers.length,
|
||||
fiscalYears: allowedYears.map(fy => new Date(fy.fromDate).getFullYear()),
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
log.info('SIE export failed:', err instanceof Error ? err.message : String(err))
|
||||
}
|
||||
|
||||
if (filteredFiles.length > 0) {
|
||||
sieAvailable = true
|
||||
const totalAccounts = Math.max(...filteredFiles.map(f => f.accountCount))
|
||||
const totalTransactions = filteredFiles.reduce((sum, f) => sum + f.transactionCount, 0)
|
||||
const fiscalYears = filteredFiles.map(f => f.fiscalYear).sort()
|
||||
sieStats = { accountCount: totalAccounts, transactionCount: totalTransactions, fiscalYears }
|
||||
log.info(`SIE stats: ${totalAccounts} accounts, ${totalTransactions} transactions, years: ${fiscalYears.join(', ')}`)
|
||||
} else {
|
||||
log.info('No SIE files within allowed fiscal years')
|
||||
}
|
||||
} catch (err) {
|
||||
log.info('SIE export failed:', err instanceof Error ? err.message : String(err))
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
@@ -363,6 +486,8 @@ export const arcimMigrationExtension: Extension = {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const companyId = ctx?.companyId ?? user.id
|
||||
|
||||
const url = new URL(request.url)
|
||||
const consentId = url.searchParams.get('consentId')
|
||||
|
||||
@@ -371,22 +496,77 @@ export const arcimMigrationExtension: Extension = {
|
||||
}
|
||||
|
||||
try {
|
||||
// Fetch SIE from gateway and filter to allowed fiscal years (2024–2026)
|
||||
const sieResult = await fetchSIEExport(consentId, 4)
|
||||
const filteredFiles = sieResult.files.filter(f => ALLOWED_FISCAL_YEARS.has(f.fiscalYear))
|
||||
if (filteredFiles.length === 0) {
|
||||
// Resolve consent
|
||||
const resolved = await resolveConsent(companyId, consentId)
|
||||
const provider = resolved.consent.provider as ProviderName
|
||||
|
||||
if (provider !== 'fortnox') {
|
||||
return NextResponse.json(
|
||||
{ error: `SIE export is currently only supported for Fortnox. Provider: ${provider}` },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Fetch financial years from Fortnox
|
||||
const fyResponse = await fortnoxClient.get<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 2024–2026' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Fetch SIE type 4 for each allowed year
|
||||
const sieFiles: { fiscalYear: number; rawContent: string }[] = []
|
||||
for (const fy of allowedYears) {
|
||||
try {
|
||||
const sieContent = await fortnoxClient.getText(
|
||||
resolved.accessToken,
|
||||
`/sie/4?financialyear=${fy.id}`
|
||||
)
|
||||
if (sieContent) {
|
||||
sieFiles.push({
|
||||
fiscalYear: new Date(fy.fromDate).getFullYear(),
|
||||
rawContent: sieContent,
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
log.info(`Failed to fetch SIE for year ${fy.id}:`, err instanceof Error ? err.message : String(err))
|
||||
}
|
||||
}
|
||||
|
||||
if (sieFiles.length === 0) {
|
||||
return NextResponse.json({ error: 'No SIE data available for fiscal years 2024–2026' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Parse most recent file for preview/validation
|
||||
const sieFile = filteredFiles[filteredFiles.length - 1]
|
||||
const sieFile = sieFiles[sieFiles.length - 1]
|
||||
const parsed = parseSIEFile(sieFile.rawContent)
|
||||
const validation = validateSIEFile(parsed)
|
||||
|
||||
if (!validation.valid) {
|
||||
return NextResponse.json({
|
||||
error: 'validation',
|
||||
message: 'SIE file validation failed',
|
||||
validation,
|
||||
}, { status: 400 })
|
||||
}
|
||||
|
||||
// Collect ALL unique accounts across ALL fiscal year files
|
||||
// so mappings cover every account that will be imported
|
||||
const allAccountsMap = new Map<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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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 [];
|
||||
}
|
||||
@@ -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--;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Generated
+41
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
));
|
||||
Reference in New Issue
Block a user