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:
Mattsson
2026-06-14 23:40:26 +02:00
committed by GitHub
co-authored by Claude Opus 4.8
parent db8983ba9e
commit 43925bc2d3
37 changed files with 2456 additions and 1152 deletions
+91 -25
View File
@@ -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>