fix(import): SIE bulk-delete on service client + provider/reporting/b… (#724)
* fix(import): SIE bulk-delete on service client + provider/reporting/banking fixes Rebuilt branch onto main as a single commit. - import: run SIE bulk-delete RPCs on the service client to escape the 8s statement_timeout; undo_sie_import now takes an explicit actor (p_user_id) so its owner/admin gate works when auth.uid() is NULL on the service client (migration 20260624120000) + pg-real regression test - providers: distinguish missing Fortnox license from expired connection; provider_consent_tokens PK regression test - reports: include unmapped BAS expense groups in the income statement - enable-banking: reconnect closed/expired bank sessions in place - bookkeeping: surface linked invoices as underlag on the verifikat view - scripts: track BL cleanup/diagnostic tooling; data files (*.csv) are git-ignored and consentId is now a required arg with no silent default Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(import): add Cache-Control header to journal entry references response --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
db8983ba9e
commit
43925bc2d3
@@ -68,6 +68,16 @@ function apiErrorMessage(data: unknown, fallback: string): string {
|
||||
return fallback
|
||||
}
|
||||
|
||||
/** Pull the structured error `code` from an envelope, if present. */
|
||||
function apiErrorCode(data: unknown): string | null {
|
||||
const err = (data as { error?: unknown } | null)?.error
|
||||
if (err && typeof err === 'object') {
|
||||
const code = (err as { code?: unknown }).code
|
||||
if (typeof code === 'string' && code) return code
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
interface SkipReasons {
|
||||
duplicate?: number
|
||||
inactive?: number
|
||||
@@ -632,12 +642,18 @@ function PreviewStep({
|
||||
preview,
|
||||
isLoading,
|
||||
error,
|
||||
authExpired,
|
||||
licenseMissing,
|
||||
onReconnect,
|
||||
onContinue,
|
||||
onBack,
|
||||
}: {
|
||||
preview: PreviewData | null
|
||||
isLoading: boolean
|
||||
error: string | null
|
||||
authExpired: boolean
|
||||
licenseMissing: boolean
|
||||
onReconnect: () => void
|
||||
onContinue: () => void
|
||||
onBack: () => void
|
||||
}) {
|
||||
@@ -663,13 +679,26 @@ function PreviewStep({
|
||||
<>
|
||||
<div className="flex gap-3 rounded-lg border border-destructive/20 bg-destructive/10 p-4">
|
||||
<AlertCircle className="mt-0.5 h-5 w-5 shrink-0 text-destructive" />
|
||||
<p className="text-sm text-muted-foreground">{error}</p>
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-muted-foreground">{error}</p>
|
||||
{authExpired && (
|
||||
<Button size="sm" className="min-h-9" onClick={onReconnect} disabled={isLoading}>
|
||||
<RotateCcw className="mr-2 h-4 w-4" />
|
||||
Återanslut {providerName}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<FallbackPrompt
|
||||
message="Du kan också importera din bokföringsdata manuellt via en SIE-fil."
|
||||
linkHref="/import?mode=sie"
|
||||
linkLabel="Ladda upp SIE-fil"
|
||||
/>
|
||||
{/* License-missing keeps the SIE fallback visible: re-auth loops
|
||||
until the customer re-orders the Fortnox Integration license,
|
||||
so a manual SIE import is the reliable escape hatch. */}
|
||||
{(!authExpired || licenseMissing) && (
|
||||
<FallbackPrompt
|
||||
message="Du kan också importera din bokföringsdata manuellt via en SIE-fil."
|
||||
linkHref="/import?mode=sie"
|
||||
linkLabel="Ladda upp SIE-fil"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1654,6 +1683,14 @@ export default function ArcimMigrationWorkspace(_props: WorkspaceComponentProps)
|
||||
|
||||
// Preview state
|
||||
const [preview, setPreview] = useState<PreviewData | null>(null)
|
||||
// Set when a preview/sync fails because the provider connection expired
|
||||
// (dead refresh token → PROVIDER_AUTH_EXPIRED). Drives the "Återanslut"
|
||||
// affordance so the user can re-authorize in place instead of disconnecting.
|
||||
const [authExpired, setAuthExpired] = useState(false)
|
||||
// Set when the failure is specifically a missing/inactive Fortnox integration
|
||||
// license (PROVIDER_LICENSE_MISSING). Re-auth alone can't fix it, so the SIE
|
||||
// fallback stays available alongside the "Återanslut" CTA.
|
||||
const [licenseMissing, setLicenseMissing] = useState(false)
|
||||
|
||||
// SIE data state (held between mapping and execution steps)
|
||||
const [sieData, setSieData] = useState<SIEData | null>(null)
|
||||
@@ -1707,17 +1744,30 @@ export default function ArcimMigrationWorkspace(_props: WorkspaceComponentProps)
|
||||
setStep('preview')
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
setAuthExpired(false)
|
||||
setLicenseMissing(false)
|
||||
setConsentId(cId)
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/extensions/ext/arcim-migration/preview?consentId=${cId}`)
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}))
|
||||
// A dead connection (expired/revoked refresh token) is recoverable in
|
||||
// place — flag it so the UI offers "Återanslut" instead of a dead end.
|
||||
// A missing Fortnox integration license shows the same CTA but keeps the
|
||||
// SIE fallback, because re-auth loops until the license is re-ordered.
|
||||
const code = apiErrorCode(data)
|
||||
if (code === 'PROVIDER_AUTH_EXPIRED' || code === 'PROVIDER_LICENSE_MISSING') {
|
||||
setAuthExpired(true)
|
||||
}
|
||||
if (code === 'PROVIDER_LICENSE_MISSING') {
|
||||
setLicenseMissing(true)
|
||||
}
|
||||
throw new Error(apiErrorMessage(data, `HTTP ${res.status}`))
|
||||
}
|
||||
|
||||
const data = await res.json()
|
||||
setPreview(data)
|
||||
setConsentId(cId)
|
||||
|
||||
// If SIE is not available, disable SIE import by default
|
||||
if (!data.sieAvailable) {
|
||||
@@ -1780,6 +1830,55 @@ export default function ArcimMigrationWorkspace(_props: WorkspaceComponentProps)
|
||||
await loadPreview(existingConsentId)
|
||||
}, [loadPreview])
|
||||
|
||||
// Re-authorize a dead connection in place. Re-runs provider auth against the
|
||||
// SAME consent so fresh tokens overwrite the expired pair — no disconnect.
|
||||
// OAuth providers open the login popup (the existing postMessage listener
|
||||
// reloads the preview on success); token providers drop to the credential
|
||||
// form. Triggered from the "Återanslut" CTA after a sync hits
|
||||
// PROVIDER_AUTH_EXPIRED.
|
||||
const handleReconnect = useCallback(async (provider: ArcimProvider, existingConsentId: string) => {
|
||||
setError(null)
|
||||
setAuthExpired(false)
|
||||
setLicenseMissing(false)
|
||||
setIsLoading(true)
|
||||
setSelectedProvider(provider)
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/extensions/ext/arcim-migration/connect', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ provider, reconnect: true }),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}))
|
||||
throw new Error(apiErrorMessage(data, `HTTP ${res.status}`))
|
||||
}
|
||||
|
||||
const data = await res.json()
|
||||
setConsentId(data.consentId ?? existingConsentId)
|
||||
setAuthType(data.authType)
|
||||
|
||||
if (data.authType === 'oauth' && data.authUrl) {
|
||||
// Open immediately — this runs inside the button's click handler, so
|
||||
// the popup is a trusted user gesture and won't be blocked.
|
||||
const w = 600
|
||||
const h = 700
|
||||
const left = window.screenX + (window.outerWidth - w) / 2
|
||||
const top = window.screenY + (window.outerHeight - h) / 2
|
||||
window.open(data.authUrl, 'arcim-oauth', `width=${w},height=${h},left=${left},top=${top}`)
|
||||
setAuthUrl(data.authUrl)
|
||||
} else if (data.authType === 'token') {
|
||||
// Re-enter credentials for token-based providers
|
||||
setStep('connect')
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Kunde inte återansluta')
|
||||
setAuthExpired(true)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Disconnect an existing consent
|
||||
const handleDisconnect = useCallback(async (consentIdToDelete: string) => {
|
||||
try {
|
||||
@@ -2163,6 +2262,11 @@ export default function ArcimMigrationWorkspace(_props: WorkspaceComponentProps)
|
||||
preview={preview}
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
authExpired={authExpired}
|
||||
licenseMissing={licenseMissing}
|
||||
onReconnect={() => {
|
||||
if (selectedProvider && consentId) handleReconnect(selectedProvider, consentId)
|
||||
}}
|
||||
onContinue={handlePreviewContinue}
|
||||
onBack={() => setStep('provider')}
|
||||
/>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useTranslations } from 'next-intl'
|
||||
import { Loader2, RefreshCw } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { ToastAction } from '@/components/ui/toast'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -15,24 +16,30 @@ import {
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
import { useCompany } from '@/contexts/CompanyContext'
|
||||
|
||||
interface ActiveConnection {
|
||||
interface BankConn {
|
||||
id: string
|
||||
bank_name: string
|
||||
status: string
|
||||
provider: string
|
||||
}
|
||||
|
||||
/**
|
||||
* On-demand "Sync now" button beside BankSyncStatusChip. Reuses the
|
||||
* per-connection sync endpoint that BankingSettingsPanel already calls;
|
||||
* if the user has multiple active connections, a dropdown lets them
|
||||
* pick which one to sync.
|
||||
* per-connection sync endpoint that BankingSettingsPanel already calls.
|
||||
*
|
||||
* Also handles dead PSD2 sessions: a connection whose consent has closed/expired
|
||||
* shows a "Förnya anslutning" action that re-authorizes in place (no disconnect
|
||||
* needed), and a sync that fails with a session-expiry surfaces the same
|
||||
* reconnect action right in the error toast. If the user has multiple
|
||||
* connections, a dropdown lets them pick which one to sync/reconnect.
|
||||
*/
|
||||
export default function BankSyncNowButton() {
|
||||
const t = useTranslations('transactions')
|
||||
const { toast } = useToast()
|
||||
const router = useRouter()
|
||||
const { company } = useCompany()
|
||||
const [connections, setConnections] = useState<ActiveConnection[] | null>(null)
|
||||
const [syncingId, setSyncingId] = useState<string | null>(null)
|
||||
const [connections, setConnections] = useState<BankConn[] | null>(null)
|
||||
const [busyId, setBusyId] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!company?.id) return
|
||||
@@ -40,11 +47,13 @@ export default function BankSyncNowButton() {
|
||||
const supabase = createClient()
|
||||
supabase
|
||||
.from('bank_connections')
|
||||
.select('id, bank_name')
|
||||
.select('id, bank_name, status, provider')
|
||||
// Include expired/error so the reconnect entry point survives a reload —
|
||||
// not just active connections that can sync.
|
||||
.in('status', ['active', 'expired', 'error'])
|
||||
.eq('company_id', company.id)
|
||||
.eq('status', 'active')
|
||||
.then(({ data }) => {
|
||||
if (!cancelled) setConnections(data ?? [])
|
||||
if (!cancelled) setConnections((data as BankConn[]) ?? [])
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
@@ -53,16 +62,63 @@ export default function BankSyncNowButton() {
|
||||
|
||||
if (!connections || connections.length === 0) return null
|
||||
|
||||
async function syncConnection(connectionId: string) {
|
||||
setSyncingId(connectionId)
|
||||
// Re-authorize an existing connection in place — posts the connection_id so
|
||||
// the server reuses the same row, then hands off to the bank's consent screen.
|
||||
async function reconnect(conn: BankConn) {
|
||||
setBusyId(conn.id)
|
||||
try {
|
||||
const country = conn.provider?.split('-').pop()?.toUpperCase() || 'SE'
|
||||
const res = await fetch('/api/extensions/ext/enable-banking/connect', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
connection_id: conn.id,
|
||||
aspsp_name: conn.bank_name,
|
||||
aspsp_country: country,
|
||||
}),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) throw new Error(data.error || 'Reconnect failed')
|
||||
window.location.href = data.authorization_url
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: t('bank_reconnect'),
|
||||
description: error instanceof Error ? error.message : 'Reconnect failed',
|
||||
variant: 'destructive',
|
||||
})
|
||||
setBusyId(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function syncConnection(conn: BankConn) {
|
||||
setBusyId(conn.id)
|
||||
try {
|
||||
const res = await fetch('/api/extensions/ext/enable-banking/sync', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ connection_id: connectionId }),
|
||||
body: JSON.stringify({ connection_id: conn.id }),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) {
|
||||
// A dead PSD2 session can't be fixed by retrying — surface a one-click
|
||||
// reconnect in the toast instead of a dead-end error.
|
||||
if (data?.reauth_required) {
|
||||
toast({
|
||||
title: t('bank_sync_session_expired'),
|
||||
description: t('bank_sync_session_expired_desc'),
|
||||
variant: 'destructive',
|
||||
action: (
|
||||
<ToastAction altText={t('bank_reconnect')} onClick={() => reconnect(conn)}>
|
||||
{t('bank_reconnect')}
|
||||
</ToastAction>
|
||||
),
|
||||
})
|
||||
// Reflect the now-expired status so the button flips to reconnect.
|
||||
setConnections((prev) =>
|
||||
(prev ?? []).map((c) => (c.id === conn.id ? { ...c, status: 'expired' } : c))
|
||||
)
|
||||
return
|
||||
}
|
||||
throw new Error(data.error || 'Sync failed')
|
||||
}
|
||||
toast({
|
||||
@@ -79,28 +135,36 @@ export default function BankSyncNowButton() {
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
setSyncingId(null)
|
||||
setBusyId((prev) => (prev === conn.id ? null : prev))
|
||||
}
|
||||
}
|
||||
|
||||
const isSyncing = syncingId !== null
|
||||
const label = isSyncing ? t('bank_sync_button_syncing') : t('bank_sync_button_now')
|
||||
// Active connections sync; expired/error connections reconnect.
|
||||
function runFor(conn: BankConn) {
|
||||
if (conn.status === 'active') return syncConnection(conn)
|
||||
return reconnect(conn)
|
||||
}
|
||||
|
||||
const isBusy = busyId !== null
|
||||
const syncLabel = isBusy ? t('bank_sync_button_syncing') : t('bank_sync_button_now')
|
||||
|
||||
if (connections.length === 1) {
|
||||
const conn = connections[0]
|
||||
const needsReconnect = conn.status !== 'active'
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 gap-1.5 px-2.5 text-xs"
|
||||
disabled={isSyncing}
|
||||
onClick={() => syncConnection(connections[0].id)}
|
||||
disabled={isBusy}
|
||||
onClick={() => runFor(conn)}
|
||||
>
|
||||
{isSyncing ? (
|
||||
{isBusy ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="h-3.5 w-3.5" />
|
||||
)}
|
||||
<span>{label}</span>
|
||||
<span>{needsReconnect ? t('bank_reconnect') : syncLabel}</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -112,24 +176,26 @@ export default function BankSyncNowButton() {
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 gap-1.5 px-2.5 text-xs"
|
||||
disabled={isSyncing}
|
||||
disabled={isBusy}
|
||||
>
|
||||
{isSyncing ? (
|
||||
{isBusy ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="h-3.5 w-3.5" />
|
||||
)}
|
||||
<span>{label}</span>
|
||||
<span>{syncLabel}</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
{connections.map((conn) => (
|
||||
<DropdownMenuItem
|
||||
key={conn.id}
|
||||
disabled={isSyncing}
|
||||
onSelect={() => syncConnection(conn.id)}
|
||||
disabled={isBusy}
|
||||
onSelect={() => runFor(conn)}
|
||||
>
|
||||
{conn.bank_name}
|
||||
{conn.status === 'active'
|
||||
? conn.bank_name
|
||||
: `${conn.bank_name} · ${t('bank_reconnect')}`}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
|
||||
Reference in New Issue
Block a user