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
parent db8983ba9e
commit 43925bc2d3
37 changed files with 2456 additions and 1152 deletions
+5 -2
View File
@@ -87,7 +87,10 @@ supabase/.temp/
# out of the box without running the generator.
supabase/.branches/
/scripts
# Local-only SIE test fixtures — may contain real/scrubbed company data, never commit
tests/fixtures/sie/
# Diagnostic/cleanup tooling under /scripts is tracked, but the DATA those
# scripts read or emit (ledger dumps, reconciliation exports) is real customer
# räkenskapsinformation — never commit it. Keep the .ts/.sql tooling, ignore the data.
scripts/*.csv
+64 -18
View File
@@ -8,7 +8,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { AccountNumber } from '@/components/ui/account-number'
import { Textarea } from '@/components/ui/textarea'
import { Loader2, ArrowLeft, Paperclip, AlertTriangle, Lock, MessageSquare, Pencil, Check, X, Copy, ChevronDown, CalendarClock } from 'lucide-react'
import { Loader2, ArrowLeft, Paperclip, AlertTriangle, Lock, MessageSquare, Pencil, Check, X, Copy, ChevronDown, CalendarClock, FileText, Link2 } from 'lucide-react'
import {
DropdownMenu,
DropdownMenuTrigger,
@@ -27,6 +27,7 @@ import { ConfirmationDialog } from '@/components/ui/confirmation-dialog'
import { useToast } from '@/components/ui/use-toast'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import type { JournalEntry, JournalEntryLine } from '@/types'
import type { UnderlagReference } from '@/lib/core/bookkeeping/journal-entry-references'
export default function JournalEntryDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = use(params)
@@ -46,6 +47,7 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i
const [isCommitting, setIsCommitting] = useState(false)
const [isLastInSeries, setIsLastInSeries] = useState(false)
const [attachmentCount, setAttachmentCount] = useState(0)
const [references, setReferences] = useState<UnderlagReference[]>([])
const [editingNotes, setEditingNotes] = useState(false)
const [notesValue, setNotesValue] = useState('')
const [savingNotes, setSavingNotes] = useState(false)
@@ -54,16 +56,27 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i
setIsLoading(true)
setError(null)
try {
const res = await fetch(`/api/bookkeeping/journal-entries/${id}/chain`)
if (!res.ok) {
const { error: msg } = await res.json()
const [chainRes, refsRes] = await Promise.all([
fetch(`/api/bookkeeping/journal-entries/${id}/chain`),
fetch(`/api/bookkeeping/journal-entries/${id}/references`),
])
if (!chainRes.ok) {
const { error: msg } = await chainRes.json()
setError(msg || t('error_load_failed'))
return
}
const { data } = await res.json()
const { data } = await chainRes.json()
setEntry(data.entry)
setChain(data.chain)
setIsLastInSeries(data.is_last_in_series ?? false)
// Underlag references (linked invoices) — best-effort; the verifikat still
// renders if this fails, it just falls back to documents-only.
if (refsRes.ok) {
const { data: refData } = await refsRes.json()
setReferences(refData?.references ?? [])
} else {
setReferences([])
}
} catch {
setError(t('error_load_failed'))
} finally {
@@ -405,19 +418,27 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i
<CardTitle className="text-sm font-medium text-muted-foreground">{t('attachments_title')}</CardTitle>
</CardHeader>
<CardContent className="text-sm">
<div className="flex items-center gap-2">
{attachmentCount > 0 ? (
<>
<Paperclip className="h-4 w-4 text-muted-foreground" />
<span>{t('attachments_count', { count: attachmentCount })}</span>
</>
) : (
<>
<AlertTriangle className="h-4 w-4 text-warning-foreground" />
<span className="text-muted-foreground">{t('no_attachments')}</span>
</>
)}
</div>
{attachmentCount === 0 && references.length === 0 ? (
<div className="flex items-center gap-2">
<AlertTriangle className="h-4 w-4 text-warning-foreground" />
<span className="text-muted-foreground">{t('no_attachments')}</span>
</div>
) : (
<div className="space-y-1">
{attachmentCount > 0 && (
<div className="flex items-center gap-2">
<Paperclip className="h-4 w-4 text-muted-foreground" />
<span>{t('attachments_count', { count: attachmentCount })}</span>
</div>
)}
{references.length > 0 && (
<div className="flex items-center gap-2">
<Link2 className="h-4 w-4 text-muted-foreground" />
<span>{t('references_count', { count: references.length })}</span>
</div>
)}
</div>
)}
</CardContent>
</Card>
</div>
@@ -569,6 +590,31 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i
<CardTitle className="text-sm font-medium">{t('attachments_title')}</CardTitle>
</CardHeader>
<CardContent>
{references.length > 0 && (
<div className="mb-4 space-y-2">
<div>
<h4 className="text-sm font-medium">{t('references_title')}</h4>
<p className="text-xs text-muted-foreground">{t('references_subtitle')}</p>
</div>
<ul className="space-y-1">
{references.map((ref) => (
<li key={`${ref.type}-${ref.id}`}>
<Link
href={ref.type === 'invoice' ? `/invoices/${ref.id}` : `/supplier-invoices/${ref.id}`}
className="flex items-center gap-2 text-sm py-1.5 px-2 rounded bg-muted/50 hover:bg-secondary/60 transition-colors"
>
<FileText className="h-4 w-4 text-muted-foreground shrink-0" />
<span className="truncate">
{ref.type === 'invoice'
? t('reference_invoice', { number: ref.number })
: t('reference_supplier_invoice', { number: ref.number })}
</span>
</Link>
</li>
))}
</ul>
</div>
)}
<JournalEntryAttachments
journalEntryId={entry.id}
onCountChange={setAttachmentCount}
@@ -0,0 +1,30 @@
import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { getJournalEntryUnderlagReferences } from '@/lib/core/bookkeeping/journal-entry-references'
/**
* GET /api/bookkeeping/journal-entries/[id]/references
*
* Resolves the verifikation's followable underlag references — the linked
* customer / supplier invoices that identify the affärshändelse. Lets the
* verifikat view make the verifieringskedja traceable from the verifikat side,
* not only from the invoice side (BFL 5 kap 7§ — hänvisning till underlag;
* BFNAR 2013:2). Read-only.
*
* An id that doesn't belong to the active company resolves to no references
* (every underlying query is company-scoped), so this neither leaks nor 404s.
*
* Marked private, no-store: the payload carries invoice numbers (financial
* data), so no shared proxy / CDN may cache it across users or companies.
*/
export const GET = withRouteContext<{ params: Promise<{ id: string }> }>(
'journal_entry.references',
async (_request, { supabase, companyId }, { params }) => {
const { id } = await params
const references = await getJournalEntryUnderlagReferences(supabase, companyId, id)
return NextResponse.json(
{ data: { references } },
{ headers: { 'Cache-Control': 'private, no-store' } },
)
},
)
@@ -34,7 +34,7 @@ function makeRequest(params: Record<string, string>) {
function mockChain(result: { data?: unknown; error?: unknown }) {
const chain: Record<string, unknown> = {}
for (const m of ['select', 'eq', 'single', 'update', 'order', 'limit']) {
for (const m of ['select', 'eq', 'in', 'single', 'update', 'order', 'limit']) {
chain[m] = vi.fn().mockReturnValue(chain)
}
chain.single = vi.fn().mockResolvedValue({ data: result.data ?? null, error: result.error ?? null })
@@ -49,12 +49,14 @@ export async function GET(request: Request) {
try {
const supabase = await createServiceClient()
// Fetch connection details for logging before updating
// Fetch connection details for logging before updating. Match by
// oauth_state across pending/expired/error so an in-place reconnect
// (which stays 'expired' during the round-trip) is also handled.
const { data: pendingConn } = await supabase
.from('bank_connections')
.select('id, user_id, bank_name')
.eq('oauth_state', state)
.eq('status', 'pending')
.in('status', ['pending', 'expired', 'error'])
.single()
if (pendingConn) {
@@ -66,9 +68,16 @@ export async function GET(request: Request) {
error_description: errorDescription,
})
// If the bank reports a session-expiry during authorization itself,
// mark the row 'expired' (not generic 'error') so the settings panel
// surfaces the reconnect button rather than a dead-end error state.
const isSessionExpiry = /session.?expired|expired.?session|closed.?session|session.?closed|invalid.?session|session.?not.?found/i.test(
`${error} ${errorDescription ?? ''}`
)
await supabase
.from('bank_connections')
.update({ status: 'error', error_message: errorMessage, oauth_state: null })
.update({ status: isSessionExpiry ? 'expired' : 'error', error_message: errorMessage, oauth_state: null })
.eq('id', pendingConn.id)
// Include bank name and error code in redirect so the UI can offer PSU type retry
@@ -102,12 +111,16 @@ export async function GET(request: Request) {
const supabase = await createServiceClient()
try {
// Look up pending connection by oauth_state (CSRF-safe)
// Look up the connection awaiting this callback by oauth_state (CSRF-safe).
// oauth_state is a single-use random token cleared after use, so it uniquely
// identifies the row regardless of status. Accept 'expired'/'error' too: an
// in-place reconnect keeps the row in 'expired' during the round-trip (so
// the nightly stale-'pending' cleanup can't delete an established row).
const { data: pendingConnection, error: findError } = await supabase
.from('bank_connections')
.select('id, user_id, company_id')
.eq('oauth_state', state)
.eq('status', 'pending')
.in('status', ['pending', 'expired', 'error'])
.single()
if (findError || !pendingConnection) {
@@ -278,7 +291,7 @@ export async function GET(request: Request) {
.from('bank_connections')
.update({ status: 'error', error_message: error instanceof Error ? error.message : 'Connection failed', oauth_state: null })
.eq('oauth_state', state)
.eq('status', 'pending')
.in('status', ['pending', 'expired', 'error'])
} catch (cleanupError) {
console.error('[enable-banking] Callback cleanup failed', {
cleanupError: cleanupError instanceof Error ? cleanupError.message : String(cleanupError),
@@ -2,7 +2,7 @@ import { createClient, type SupabaseClient } from '@supabase/supabase-js'
import { NextResponse } from 'next/server'
import { syncAccountTransactions } from '@/extensions/general/enable-banking/lib/sync'
import { runReconciliation } from '@/lib/reconciliation/bank-reconciliation'
import { isConsentExpiringSoon, getDaysUntilExpiry } from '@/extensions/general/enable-banking/lib/api-client'
import { isConsentExpiringSoon, getDaysUntilExpiry, SessionExpiredError } from '@/extensions/general/enable-banking/lib/api-client'
import { getEmailService } from '@/lib/email/service'
import {
generateConsentExpiryEmailHtml,
@@ -266,10 +266,19 @@ export const GET = withCronContext('cron.bank_sync', async (_request, ctx) => {
lastSyncedAt: connection.last_synced_at,
})
// Persist error status on sync failure
// A dead PSD2 session (closed/expired/invalid consent) is a re-auth
// condition, not a transient failure — flip it to 'expired' (same state
// the consent-elapsed branch uses) so the UI offers a reconnect instead
// of a retry. Other errors stay 'error'.
const isSessionDead = error instanceof SessionExpiredError
const failureStatus = isSessionDead ? 'expired' : 'error'
const failureMessage = isSessionDead
? 'Bankanslutningen har löpt ut. Förnya anslutningen för att fortsätta synka.'
: message
await supabase
.from('bank_connections')
.update({ status: 'error', error_message: message })
.update({ status: failureStatus, error_message: failureMessage })
.eq('id', connection.id)
results.push({
@@ -279,7 +288,7 @@ export const GET = withCronContext('cron.bank_sync', async (_request, ctx) => {
imported: 0,
duplicates: 0,
errors: 1,
status: 'error',
status: failureStatus,
})
}
}
+2 -2
View File
@@ -16,10 +16,10 @@ export const DELETE = withRouteContext(
'sie_import.undo',
async (_request, ctx, { params }: { params: Promise<{ id: string }> }) => {
const { id } = await params
const { supabase, companyId, log, requestId } = ctx
const { supabase, companyId, user, log, requestId } = ctx
const opLog = log.child({ sieImportId: id })
const result = await undoSIEImport(supabase, companyId!, id)
const result = await undoSIEImport(supabase, companyId!, id, user.id)
if (!result.success) {
return errorResponseFromCode('SIE_UNDO_FAILED', opLog, {
@@ -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')}
/>
+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>
+79 -30
View File
@@ -55,6 +55,40 @@ function translateOAuthError(error: string, description: string | null): string
return description ? `${error}: ${description}` : error
}
/**
* Build a provider OAuth authorization URL bound to an EXISTING consent id.
* Used by both first-time connect and reconnect (token revival): the callback
* runs exchangeAuthToken(consentId, …) which upserts the fresh tokens keyed by
* consent_id, so re-running OAuth against the same consent overwrites a dead
* refresh-token pair in place — no disconnect/recreate needed.
*/
async function buildArcimOAuthUrl(consentId: string, provider: ArcimProvider): Promise<string> {
const otc = await generateOtc(consentId)
// Prefer a provider-specific redirect override (e.g. VISMA_REDIRECT_URI) when
// set — lets dev environments route through a single registered URI rather
// than registering every ngrok URL on the OAuth client. Falls back to
// NEXT_PUBLIC_APP_URL + the canonical callback path.
const appUrl = process.env.NEXT_PUBLIC_APP_URL || ''
const providerRedirectEnv =
provider === 'visma'
? process.env.VISMA_REDIRECT_URI
: provider === 'fortnox'
? process.env.FORTNOX_REDIRECT_URI
: undefined
const callbackUrl =
providerRedirectEnv && providerRedirectEnv.trim().length > 0
? providerRedirectEnv
: `${appUrl}/api/extensions/ext/arcim-migration/callback`
// Encode consentId + provider in state so the callback rebinds to this consent
const statePayload = JSON.stringify({ otc: otc.code, consentId, provider })
const stateEncoded = Buffer.from(statePayload).toString('base64url')
const { url } = await getAuthUrl(provider, stateEncoded, callbackUrl)
return url
}
/**
* Provider Migration extension
*
@@ -158,10 +192,11 @@ export const arcimMigrationExtension: Extension = {
const companyId = ctx?.companyId ?? user.id
const { provider, companyName, orgNumber } = await request.json() as {
const { provider, companyName, orgNumber, reconnect } = await request.json() as {
provider: ArcimProvider
companyName?: string
orgNumber?: string
reconnect?: boolean
}
if (!provider) {
@@ -180,8 +215,43 @@ export const arcimMigrationExtension: Extension = {
try {
const { createServiceClient: createSvc } = await import('@/lib/supabase/server')
// Reuse existing accepted consent if one exists for this provider
const existingConsents = await listConsents(companyId)
// Reconnect: an existing connection's stored tokens are dead (refresh
// failed → PROVIDER_AUTH_EXPIRED). Re-run auth against the SAME consent
// so fresh tokens overwrite the dead pair in place — no disconnect, no
// duplicate consent, import history preserved. Bypasses the
// alreadyConnected short-circuit below (which would otherwise skip the
// auth that's the whole point here).
if (reconnect) {
const stale = existingConsents.find(
c => c.provider === provider && (c.status === 0 || c.status === 1),
)
if (stale) {
if (ctx?.settings) {
await ctx.settings.set('consent_id', stale.id)
await ctx.settings.set('provider', provider)
}
if (providerInfo.authType === 'oauth') {
const authUrl = await buildArcimOAuthUrl(stale.id, provider)
return NextResponse.json({
consentId: stale.id,
authType: 'oauth',
authUrl,
reconnect: true,
})
}
// Token-based providers re-authorize by re-entering credentials
return NextResponse.json({
consentId: stale.id,
authType: 'token',
reconnect: true,
})
}
// No existing consent to revive — fall through to a normal connect.
}
// Reuse existing accepted consent if one exists for this provider
const accepted = existingConsents.find(c => c.provider === provider && c.status === 1)
if (accepted) {
@@ -205,7 +275,11 @@ export const arcimMigrationExtension: Extension = {
for (const p of pending) {
const { data: tokens } = await svc
.from('provider_consent_tokens')
.select('id')
// consent_id is the PK — there is no `id` column. Selecting `id`
// errors silently (only `data` is read), so `tokens` was always
// null and the reuse branch below never fired, deleting valid
// status-0 consents as "abandoned".
.select('consent_id')
.eq('consent_id', p.id)
.limit(1)
if (tokens && tokens.length > 0) {
@@ -242,37 +316,12 @@ export const arcimMigrationExtension: Extension = {
}
if (providerInfo.authType === 'oauth') {
// Generate OTC for OAuth flow
const otc = await generateOtc(consent.id)
// Build the OAuth callback URL. Prefer a provider-specific override
// (e.g. VISMA_REDIRECT_URI) when set — this lets dev environments
// route through a single registered URI (production) rather than
// requiring every ngrok URL to be registered on the OAuth client.
// Falls back to NEXT_PUBLIC_APP_URL + the canonical callback path.
const appUrl = process.env.NEXT_PUBLIC_APP_URL || ''
const providerRedirectEnv =
provider === 'visma'
? process.env.VISMA_REDIRECT_URI
: provider === 'fortnox'
? process.env.FORTNOX_REDIRECT_URI
: undefined
const callbackUrl =
providerRedirectEnv && providerRedirectEnv.trim().length > 0
? providerRedirectEnv
: `${appUrl}/api/extensions/ext/arcim-migration/callback`
// Encode consentId + provider in state
const statePayload = JSON.stringify({ otc: otc.code, consentId: consent.id, provider })
const stateEncoded = Buffer.from(statePayload).toString('base64url')
const { url } = await getAuthUrl(provider, stateEncoded, callbackUrl)
const authUrl = await buildArcimOAuthUrl(consent.id, provider)
return NextResponse.json({
consentId: consent.id,
authType: 'oauth',
authUrl: url,
otcCode: otc.code,
authUrl,
})
} else {
// Token-based providers: consent is ready for direct use
@@ -0,0 +1,262 @@
import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest'
import type { ExtensionContext } from '@/lib/extensions/types'
import type { StoredAccount } from '../types'
// Mock the JWT signer so api-client can build a request header without real
// ENABLE_BANKING credentials (part 2 stubs fetch directly).
vi.mock('../lib/jwt', () => ({
getAuthorizationHeader: () => 'Bearer test-token',
}))
// Mock the sync orchestrator so the /sync handler test can force a dead-session
// failure without hitting the network.
vi.mock('../lib/sync', () => ({
syncAccountTransactions: vi.fn(),
}))
import {
isSessionExpiredResponse,
SessionExpiredError,
getAllTransactionsWithRaw,
} from '../lib/api-client'
import { enableBankingExtension } from '../index'
import { syncAccountTransactions } from '../lib/sync'
const CLOSED_SESSION_BODY = JSON.stringify({
code: 401,
message: 'Session is closed',
error: 'CLOSED_SESSION',
detail: null,
})
describe('isSessionExpiredResponse', () => {
it('matches the CLOSED_SESSION 401 from the screenshot', () => {
expect(isSessionExpiredResponse(401, CLOSED_SESSION_BODY)).toBe(true)
})
it('matches the lowercase session_expired variant', () => {
expect(isSessionExpiredResponse(401, '{"error":"session_expired"}')).toBe(true)
})
it.each([
'{"error":"EXPIRED_SESSION"}',
'{"error":"INVALID_SESSION"}',
'{"error":"SESSION_NOT_FOUND"}',
'{"error":"WRONG_SESSION_STATUS"}',
'{"message":"Session is closed"}',
])('matches session-dead body %s', (body) => {
expect(isSessionExpiredResponse(401, body)).toBe(true)
// 403 is also a valid session-rejection status from some ASPSPs.
expect(isSessionExpiredResponse(403, body)).toBe(true)
})
it('does NOT match a bare 401 Unauthorized (app-credential problem, not a dead session)', () => {
expect(isSessionExpiredResponse(401, '{"error":"Unauthorized"}')).toBe(false)
})
it('does NOT match a non-401/403 status even with a session code in the body', () => {
expect(isSessionExpiredResponse(500, CLOSED_SESSION_BODY)).toBe(false)
expect(isSessionExpiredResponse(400, '{"error":"ASPSP_ERROR"}')).toBe(false)
})
})
describe('getAllTransactionsWithRaw — dead session', () => {
afterEach(() => {
vi.unstubAllGlobals()
})
it('throws SessionExpiredError on a CLOSED_SESSION 401', async () => {
const fetchMock = vi.fn(async () => ({
ok: false,
status: 401,
statusText: 'Unauthorized',
text: async () => CLOSED_SESSION_BODY,
}))
vi.stubGlobal('fetch', fetchMock)
await expect(
getAllTransactionsWithRaw('acc-1', '2026-01-01', '2026-06-01')
).rejects.toBeInstanceOf(SessionExpiredError)
})
})
const syncRoute = enableBankingExtension.apiRoutes?.find(
r => r.method === 'POST' && r.path === '/sync'
)
if (!syncRoute) {
throw new Error('POST /sync route not registered on enable-banking extension')
}
function makeContext(connection: Record<string, unknown>, updateSpy: Mock, insertSpy?: Mock): ExtensionContext {
// One universal chainable per from() call. Each table only ever terminates on
// single() (bank_connections lookup) OR maybeSingle() (sie_imports /
// company_members), so a single shared resolver is unambiguous. update()/
// insert() record their payloads and return the chain so trailing
// .eq()/.select() resolve.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const chain: any = {}
chain.select = vi.fn(() => chain)
chain.eq = vi.fn(() => chain)
chain.gte = vi.fn(() => chain)
chain.limit = vi.fn(() => chain)
chain.order = vi.fn(() => chain)
chain.insert = vi.fn((payload: unknown) => {
insertSpy?.(payload)
return chain
})
chain.single = vi.fn().mockResolvedValue({ data: connection, error: null })
chain.maybeSingle = vi.fn().mockResolvedValue({ data: null, error: null })
chain.update = vi.fn((payload: unknown) => {
updateSpy(payload)
return chain
})
const supabase = {
auth: {
getUser: vi.fn().mockResolvedValue({ data: { user: { id: 'user-1' } }, error: null }),
},
from: vi.fn(() => chain),
}
return {
userId: 'user-1',
companyId: 'company-1',
extensionId: 'enable-banking',
requestId: 'req_test',
// eslint-disable-next-line @typescript-eslint/no-explicit-any
supabase: supabase as any,
emit: vi.fn().mockResolvedValue(undefined),
settings: { get: vi.fn(), set: vi.fn(), getAll: vi.fn() } as never,
storage: {} as never,
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() } as never,
services: {} as never,
}
}
function makeRequest(): Request {
return new Request('http://localhost/api/extensions/ext/enable-banking/sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ connection_id: 'conn-1' }),
})
}
describe('POST /sync (enable-banking) — dead session reconnect', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('flips the connection to expired and returns a reauth-required 409', async () => {
;(syncAccountTransactions as unknown as Mock).mockRejectedValue(
new SessionExpiredError(401, CLOSED_SESSION_BODY)
)
const updateSpy = vi.fn()
const ctx = makeContext(
{
id: 'conn-1',
company_id: 'company-1',
status: 'active',
bank_name: 'Nordea',
accounts_data: [{ uid: 'acc-1', currency: 'SEK', enabled: true }] as StoredAccount[],
},
updateSpy
)
const res = await syncRoute.handler(makeRequest(), ctx)
expect(res.status).toBe(409)
const body = await res.json()
expect(body.reauth_required).toBe(true)
expect(body.code).toBe('SESSION_EXPIRED')
expect(body.connection_id).toBe('conn-1')
// The connection must be marked 'expired' so the UI surfaces the reconnect
// affordance instead of looping on the dead session.
expect(updateSpy).toHaveBeenCalledWith(
expect.objectContaining({ status: 'expired' })
)
})
})
const connectRoute = enableBankingExtension.apiRoutes?.find(
r => r.method === 'POST' && r.path === '/connect'
)
if (!connectRoute) {
throw new Error('POST /connect route not registered on enable-banking extension')
}
describe('POST /connect (enable-banking) — reconnect in place', () => {
beforeEach(() => {
vi.clearAllMocks()
})
afterEach(() => {
vi.unstubAllGlobals()
})
it('reuses the existing row (UPDATE, no INSERT) and keeps it out of the stale-pending sweep', async () => {
// startAuthorization() POSTs to /auth — stub it (jwt is already mocked).
vi.stubGlobal(
'fetch',
vi.fn(async () => ({
ok: true,
status: 200,
statusText: 'OK',
json: async () => ({ url: 'https://bank.example/auth', authorization_id: 'auth-123' }),
text: async () => '',
}))
)
const updateSpy = vi.fn()
const insertSpy = vi.fn()
const ctx = makeContext(
{
id: 'conn-1',
company_id: 'company-1',
bank_name: 'Nordea',
provider: 'nordea-se',
session_id: null, // null → skip the best-effort revoke call
status: 'expired',
},
updateSpy,
insertSpy
)
const req = new Request('http://localhost/api/extensions/ext/enable-banking/connect', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ connection_id: 'conn-1', aspsp_name: 'Nordea', aspsp_country: 'SE' }),
})
const res = await connectRoute.handler(req, ctx)
expect(res.status).toBe(200)
const body = await res.json()
expect(body.connection_id).toBe('conn-1')
expect(body.authorization_url).toBe('https://bank.example/auth')
// In-place: a fresh authorization on the SAME row, never a new INSERT.
expect(insertSpy).not.toHaveBeenCalled()
// The CSRF state is staged on the row FIRST — before startAuthorization, so
// before authorization_id even exists — guaranteeing the callback can always
// find the row by oauth_state and the bank session can never be orphaned.
// It stays 'expired' (not 'pending') so the cron's stale-pending cleanup
// can't delete an established connection mid-reconnect.
const firstUpdate = updateSpy.mock.calls[0][0]
expect(firstUpdate).toMatchObject({
oauth_state: expect.any(String),
status: 'expired',
session_id: null,
error_message: null,
})
expect(firstUpdate).not.toHaveProperty('authorization_id')
// The bank's authorization_id is recorded in a follow-up write (audit only;
// the callback never reads it, so a failure here can't break the reconnect).
expect(updateSpy.mock.calls[1][0]).toEqual({ authorization_id: 'auth-123' })
})
})
@@ -23,7 +23,7 @@ interface BankConnectionStatusProps {
connection: BankConnection
onSync: (connectionId: string) => void
onDisconnect: (connectionId: string) => void
onReconnect?: (bank: { name: string; country: string }) => void
onReconnect?: (connection: BankConnection) => void
onManageAccounts?: (connectionId: string) => void
isSyncing?: boolean
}
@@ -132,21 +132,18 @@ export function BankConnectionStatus({
</div>
</div>
<div className="flex items-center gap-2">
{isConnectionExpired && onReconnect && (
{(isConnectionExpired || isConnectionError) && onReconnect && (
<Button
variant="outline"
size="sm"
onClick={() => onReconnect({
name: connection.bank_name,
country: (connection.provider as string)?.split('-').pop()?.toUpperCase() || 'SE',
})}
onClick={() => onReconnect(connection)}
>
Förnya anslutning
</Button>
)}
{isConnectionError && (
<Button
variant="outline"
variant="ghost"
size="sm"
onClick={() => onSync(connection.id)}
disabled={isSyncing}
@@ -165,6 +165,52 @@ export default function BankingSettingsPanel() {
}
}
// Re-authorize an existing connection in place — no disconnect required.
// Posts to /connect with the existing connection_id so the server reuses the
// same row (revoking the dead session, issuing fresh authorization), then
// hands off to the bank's consent screen. The OAuth callback drives the row
// back through account selection to active.
async function handleReconnect(connection: BankConnection) {
if (connectingRef.current) return
connectingRef.current = true
setIsConnecting(true)
setConnectingBankName(connection.bank_name)
try {
const country = (connection.provider as string)?.split('-').pop()?.toUpperCase() || 'SE'
const response = await fetch('/api/extensions/ext/enable-banking/connect', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
connection_id: connection.id,
aspsp_name: connection.bank_name,
aspsp_country: country,
}),
})
const data = await response.json()
if (!response.ok) {
throw new Error(data.error)
}
window.location.href = data.authorization_url
} catch (error) {
console.error('[enable-banking] Reconnect flow failed', {
message: error instanceof Error ? error.message : String(error),
connectionId: connection.id,
})
toast({
title: 'Fel',
description: error instanceof Error ? error.message : 'Kunde inte förnya anslutningen',
variant: 'destructive',
})
connectingRef.current = false
setIsConnecting(false)
setConnectingBankName(null)
}
}
async function handleSyncTransactions(connectionId: string) {
setSyncingConnectionId(connectionId)
@@ -214,6 +260,9 @@ export default function BankingSettingsPanel() {
variant: 'destructive',
})
setShowCsvFallback(true)
// Refresh so a now-expired connection (e.g. closed PSD2 session) moves
// into "Åtgärd krävs" and surfaces the "Förnya anslutning" button.
fetchConnections()
}
setSyncingConnectionId(null)
@@ -382,7 +431,7 @@ export default function BankingSettingsPanel() {
connection={connection}
onSync={handleSyncTransactions}
onDisconnect={handleDisconnectBank}
onReconnect={handleConnectBank}
onReconnect={handleReconnect}
onManageAccounts={() => setPickerConnectionId(connection.id)}
isSyncing={syncingConnectionId === connection.id}
/>
+198 -41
View File
@@ -5,6 +5,7 @@ import {
getASPSPs,
deleteSession,
isSandboxMode,
SessionExpiredError,
type ASPSP,
} from './lib/api-client'
import { syncAccountTransactions } from './lib/sync'
@@ -102,9 +103,14 @@ export const enableBankingExtension: Extension = {
}
const companyId = ctx.companyId
const { aspsp_name, aspsp_country, psu_type: explicitPsuType } = await request.json()
const { aspsp_name, aspsp_country, psu_type: explicitPsuType, connection_id: reconnectId } = await request.json()
if (!aspsp_name || !aspsp_country) {
// Reconnect mode: re-authorize an EXISTING connection in place (no
// disconnect required). The aspsp identity falls back to the stored row
// when the client omits it, so a closed/expired session can be renewed
// with one click. A fresh connect still needs the bank name + country.
const isReconnect = !!reconnectId
if (!isReconnect && (!aspsp_name || !aspsp_country)) {
return NextResponse.json(
{ error: 'aspsp_name and aspsp_country are required' },
{ status: 400 }
@@ -112,6 +118,43 @@ export const enableBankingExtension: Extension = {
}
try {
// For reconnect, load the existing connection up front (company-scoped)
// so we can revoke its dead session and reuse its bank identity.
let existing:
| { id: string; bank_name: string; provider: string; session_id: string | null }
| null = null
if (isReconnect) {
const { data, error: findErr } = await supabase
.from('bank_connections')
.select('id, bank_name, provider, session_id')
.eq('id', reconnectId)
.eq('company_id', companyId)
.single()
if (findErr || !data) {
return NextResponse.json({ error: 'Connection not found' }, { status: 404 })
}
existing = data
}
// Resolve the aspsp identity. For a reconnect the bank is already
// known, so derive it authoritatively from the stored row and IGNORE
// any client-supplied aspsp_name/aspsp_country — the client
// (BankSyncNowButton) derives the country by string-splitting the
// provider slug, and trusting that back is needless attack surface
// (compliance: ASVS V8.2.1/V4.5). A fresh connect has no stored row,
// so it uses the client values (already required+validated above).
// The provider slug ends with the country code, e.g. "nordea-se".
const resolvedAspspName = isReconnect ? existing?.bank_name : aspsp_name
const resolvedAspspCountry = isReconnect
? existing?.provider?.split('-').pop()?.toUpperCase() || 'SE'
: aspsp_country
if (!resolvedAspspName) {
return NextResponse.json(
{ error: 'aspsp_name and aspsp_country are required' },
{ status: 400 }
)
}
// Detect PSU type: explicit override > company entity_type > default 'business'
let psuType: 'personal' | 'business' = 'business'
if (explicitPsuType === 'personal' || explicitPsuType === 'business') {
@@ -129,49 +172,53 @@ export const enableBankingExtension: Extension = {
log.info('[enable-banking] Starting bank connection', {
user_id: user.id,
bank: aspsp_name,
country: aspsp_country,
bank: resolvedAspspName,
country: resolvedAspspCountry,
psu_type: psuType,
reconnect: isReconnect,
})
// Reject if there's already a recent pending connection for this user+bank
// to prevent double-click race conditions that confuse the bank's consent flow
const { data: recentPending } = await supabase
.from('bank_connections')
.select('id, created_at')
.eq('company_id', companyId)
.eq('bank_name', aspsp_name)
.eq('status', 'pending')
.order('created_at', { ascending: false })
.limit(1)
.maybeSingle()
// to prevent double-click race conditions that confuse the bank's consent
// flow. Skipped for reconnect — that deliberately re-authorizes a known row.
if (!isReconnect) {
const { data: recentPending } = await supabase
.from('bank_connections')
.select('id, created_at')
.eq('company_id', companyId)
.eq('bank_name', resolvedAspspName)
.eq('status', 'pending')
.order('created_at', { ascending: false })
.limit(1)
.maybeSingle()
if (recentPending) {
const pendingAge = Date.now() - new Date(recentPending.created_at).getTime()
const STALE_THRESHOLD_MS = 30 * 1000 // 30 seconds — long enough to cover the redirect handoff, short enough that an abandoned attempt doesn't block the user
if (recentPending) {
const pendingAge = Date.now() - new Date(recentPending.created_at).getTime()
const STALE_THRESHOLD_MS = 30 * 1000 // 30 seconds — long enough to cover the redirect handoff, short enough that an abandoned attempt doesn't block the user
if (pendingAge < STALE_THRESHOLD_MS) {
log.info('[enable-banking] Rejecting duplicate connect — recent pending exists', {
existing_id: recentPending.id,
if (pendingAge < STALE_THRESHOLD_MS) {
log.info('[enable-banking] Rejecting duplicate connect — recent pending exists', {
existing_id: recentPending.id,
age_ms: pendingAge,
})
return NextResponse.json(
{ error: 'En anslutning pågår redan. Vänta och försök igen.' },
{ status: 409 }
)
}
// Clean up stale pending connections (older than threshold)
log.info('[enable-banking] Cleaning up stale pending connections', {
stale_id: recentPending.id,
age_ms: pendingAge,
})
return NextResponse.json(
{ error: 'En anslutning pågår redan. Vänta och försök igen.' },
{ status: 409 }
)
await supabase
.from('bank_connections')
.update({ status: 'error', error_message: 'Superseded by new connection attempt', oauth_state: null })
.eq('company_id', companyId)
.eq('bank_name', resolvedAspspName)
.eq('status', 'pending')
}
// Clean up stale pending connections (older than threshold)
log.info('[enable-banking] Cleaning up stale pending connections', {
stale_id: recentPending.id,
age_ms: pendingAge,
})
await supabase
.from('bank_connections')
.update({ status: 'error', error_message: 'Superseded by new connection attempt', oauth_state: null })
.eq('company_id', companyId)
.eq('bank_name', aspsp_name)
.eq('status', 'pending')
}
const redirectUrl = `${process.env.NEXT_PUBLIC_APP_URL}/api/extensions/enable-banking/callback`
@@ -179,9 +226,94 @@ export const enableBankingExtension: Extension = {
// Generate cryptographic state token for CSRF protection
const oauthState = crypto.randomUUID()
if (isReconnect && existing) {
// Persist the CSRF state to the existing row BEFORE asking the bank
// to start an authorization. The OAuth callback locates this row only
// by oauth_state, so writing it first guarantees that once the bank
// holds a session bound to this state a matching row already exists.
// If startAuthorization ran first and this UPDATE then failed, the
// bank session would be orphaned with no row to complete it.
//
// Reuse the SAME row: the callback drives it back to
// pending_selection → active, so existing transactions and the
// cash_accounts mirror stay linked. Deliberately keep status
// 'expired' (NOT 'pending') during the round-trip: this row's
// created_at is old and the cron deletes stale 'pending' rows after
// 1h — a reconnect must not be eligible for that. Staying 'expired'
// also keeps it visible in "Åtgärd krävs" so an abandoned reconnect
// is recoverable. The callback's oauth_state lookup accepts 'expired'.
const { error: stateError } = await supabase
.from('bank_connections')
.update({
oauth_state: oauthState,
status: 'expired',
session_id: null,
error_message: null,
})
.eq('id', existing.id)
.eq('company_id', companyId)
if (stateError) {
log.error('[enable-banking] Database error staging reconnect state', {
errorMessage: stateError.message,
errorCode: stateError.code,
connection_id: existing.id,
user_id: user.id,
})
throw new Error(`Failed to update connection: ${stateError.message}`)
}
// Best-effort revoke the dead consent at Enable Banking. A
// closed/expired session is often already gone, so a failure here is
// expected and non-fatal — the new authorization supersedes it.
// Logged at WARN so a systematic revoke failure is visible to
// monitoring (compliance: ASVS V16 / ISO 27001 A.8.15).
if (existing.session_id) {
try {
await deleteSession(existing.session_id)
} catch (revokeError) {
log.warn('[enable-banking] Old session revoke skipped (likely already expired)', {
message: revokeError instanceof Error ? revokeError.message : String(revokeError),
connection_id: existing.id,
})
}
}
const { url, authorization_id } = await startAuthorization(
resolvedAspspName,
resolvedAspspCountry,
redirectUrl,
oauthState,
psuType
)
// Record the bank's authorization_id for audit/traceability. The
// callback matches on oauth_state alone (already persisted above), so
// a failure here cannot orphan the flow — log and continue.
const { error: authIdError } = await supabase
.from('bank_connections')
.update({ authorization_id })
.eq('id', existing.id)
.eq('company_id', companyId)
if (authIdError) {
log.warn('[enable-banking] Could not persist authorization_id on reconnect (non-fatal)', {
errorMessage: authIdError.message,
connection_id: existing.id,
})
}
return NextResponse.json({
connection_id: existing.id,
authorization_url: url,
})
}
// Fresh connect: create the bank authorization, then persist the new
// row carrying its oauth_state so the callback can find it.
const { url, authorization_id } = await startAuthorization(
aspsp_name,
aspsp_country,
resolvedAspspName,
resolvedAspspCountry,
redirectUrl,
oauthState,
psuType
@@ -192,8 +324,8 @@ export const enableBankingExtension: Extension = {
.insert({
company_id: companyId,
user_id: user.id,
provider: `${aspsp_name.toLowerCase().replace(/\s+/g, '-')}-${aspsp_country.toLowerCase()}`,
bank_name: aspsp_name,
provider: `${resolvedAspspName.toLowerCase().replace(/\s+/g, '-')}-${resolvedAspspCountry.toLowerCase()}`,
bank_name: resolvedAspspName,
authorization_id,
oauth_state: oauthState,
status: 'pending',
@@ -207,7 +339,7 @@ export const enableBankingExtension: Extension = {
errorCode: error.code,
errorDetails: error.details,
user_id: user.id,
bank: aspsp_name,
bank: resolvedAspspName,
})
throw new Error(`Failed to store connection: ${error.message}`)
}
@@ -420,6 +552,31 @@ export const enableBankingExtension: Extension = {
connectionStatus: connection.status,
bankName: connection.bank_name,
})
// A dead PSD2 session (closed/expired/invalid consent) can't be fixed
// by retrying — the user must re-authorize. Flip the connection to
// 'expired' so the UI surfaces the reconnect affordance, and tell the
// client re-auth is required (reauth_required) so it can offer a
// one-click "Förnya anslutning" instead of a dead-end error. No
// disconnect needed: /connect reconnects this same connection in place.
if (error instanceof SessionExpiredError) {
const reauthMessage = 'Bankanslutningen har löpt ut. Förnya anslutningen för att fortsätta synka.'
await supabase
.from('bank_connections')
.update({ status: 'expired', error_message: reauthMessage })
.eq('id', connection.id)
.eq('company_id', companyId)
return NextResponse.json(
{
error: reauthMessage,
code: 'SESSION_EXPIRED',
reauth_required: true,
connection_id: connection.id,
},
{ status: 409 }
)
}
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Sync failed' },
{ status: 500 }
@@ -181,6 +181,58 @@ class TransactionsFetchError extends Error {
}
}
/**
* Normalized signatures (uppercase, non-alphanumerics stripped) of the
* responses Enable Banking — or the upstream ASPSP via Enable Banking's
* envelope — returns when the PSD2 session can no longer be used: the consent
* was closed, expired, or invalidated bank-side. Spelling and casing vary by
* bank (CLOSED_SESSION, EXPIRED_SESSION, SESSION_EXPIRED / session_expired,
* INVALID_SESSION, SESSION_NOT_FOUND, WRONG_SESSION_STATUS, and the plain
* "Session is closed" message), so we match the whole family. A dead session
* is unrecoverable by retrying — the user must re-authorize.
*/
const SESSION_DEAD_NEEDLES = [
'CLOSEDSESSION', // CLOSED_SESSION
'SESSIONCLOSED', // "session closed"
'SESSIONISCLOSED', // "Session is closed"
'SESSIONEXPIRED', // SESSION_EXPIRED / session_expired
'EXPIREDSESSION', // EXPIRED_SESSION
'INVALIDSESSION', // INVALID_SESSION
'SESSIONNOTFOUND', // SESSION_NOT_FOUND
'WRONGSESSIONSTATUS', // WRONG_SESSION_STATUS
] as const
/**
* Whether a failed transactions response signals a dead PSD2 session (vs. a
* transient error or a config-level auth failure). Only 401/403 with a
* session-expiry signal in the body counts — a bare 401 "Unauthorized" is an
* app-credential problem, not a closed consent, and must NOT be misread as
* "reconnect the bank". The match is deterministic: normalize the body and
* test for any known session-dead needle.
*/
export function isSessionExpiredResponse(status: number, body: string): boolean {
if (status !== 401 && status !== 403) return false
const normalized = body.toUpperCase().replace(/[^A-Z0-9]/g, '')
return SESSION_DEAD_NEEDLES.some(needle => normalized.includes(needle))
}
/**
* Thrown when a transactions fetch fails because the PSD2 session is dead
* (closed/expired/invalid). Distinct from TransactionsFetchError so the sync
* handler can flip the connection to 'expired' and prompt re-authorization
* instead of surfacing a raw error the user can't act on. Carries the status
* and raw body for logging. See isSessionExpiredResponse for the codes covered.
*/
export class SessionExpiredError extends Error {
constructor(
readonly status: number,
readonly body: string
) {
super(`Bank session expired (${status}): ${body}`)
this.name = 'SessionExpiredError'
}
}
// API Helper
async function authenticatedFetch(
@@ -521,6 +573,9 @@ export async function getAccountTransactions(
strategy,
hasContinuationKey: !!continuationKey,
})
if (isSessionExpiredResponse(response.status, body)) {
throw new SessionExpiredError(response.status, body)
}
throw new TransactionsFetchError(response.status, body)
}
@@ -771,6 +826,9 @@ export async function getAllTransactionsWithRaw(
page,
hasContinuationKey: !!continuationKey,
})
if (isSessionExpiredResponse(response.status, body)) {
throw new SessionExpiredError(response.status, body)
}
throw new Error(`Failed to get transactions (${response.status}): ${body}`)
}
@@ -0,0 +1,94 @@
import { describe, it, expect } from 'vitest'
import type { SupabaseClient } from '@supabase/supabase-js'
import { createQueuedMockSupabase } from '@/tests/helpers'
import { getJournalEntryUnderlagReferences } from '../journal-entry-references'
/**
* The resolver issues its queries in a fixed `.from()` order, and the queued
* mock consumes one enqueued result per `.from()` call:
* 1. invoices (direct journal_entry_id link)
* 2. invoice_payments (payment rows → invoice_id)
* 3. invoices (by id — only when step 2 found new ids)
* 4. supplier_invoices (registration_journal_entry_id)
* 5. supplier_invoices (payment_journal_entry_id)
* 6. supplier_invoice_payments (payment rows → supplier_invoice_id)
* 7. supplier_invoices (by id — only when step 6 found new ids)
*/
describe('getJournalEntryUnderlagReferences', () => {
const run = (results: { data: unknown }[]) => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany(results)
return getJournalEntryUnderlagReferences(
supabase as unknown as SupabaseClient,
'company-1',
'je-1',
)
}
it('surfaces a customer invoice linked only via a cash-method payment row', async () => {
// The reported gap: debit 1930 / credit 3001, invoice linked through
// invoice_payments, no document attached and no direct invoice link.
const refs = await run([
{ data: [] }, // 1. invoices direct — none
{ data: [{ invoice_id: 'inv-x' }] }, // 2. invoice_payments
{ data: [{ id: 'inv-x', invoice_number: '003' }] }, // 3. invoices by id
{ data: [] }, // 4. supplier registration
{ data: [] }, // 5. supplier payment
{ data: [] }, // 6. supplier_invoice_payments
])
expect(refs).toEqual([{ type: 'invoice', id: 'inv-x', number: '003' }])
})
it('surfaces a supplier invoice linked via its registration booking', async () => {
const refs = await run([
{ data: [] }, // 1. invoices direct
{ data: [] }, // 2. invoice_payments (empty → step 3 skipped)
{ data: [{ id: 'si-1', supplier_invoice_number: 'LF-001' }] }, // 4. registration
{ data: [] }, // 5. supplier payment
{ data: [] }, // 6. supplier_invoice_payments
])
expect(refs).toEqual([{ type: 'supplier_invoice', id: 'si-1', number: 'LF-001' }])
})
it('returns nothing when no invoice is linked (warning legitimately stays)', async () => {
const refs = await run([
{ data: [] }, // 1. invoices direct
{ data: [] }, // 2. invoice_payments
{ data: [] }, // 4. supplier registration
{ data: [] }, // 5. supplier payment
{ data: [] }, // 6. supplier_invoice_payments
])
expect(refs).toEqual([])
})
it('deduplicates an invoice reachable via both the direct link and a payment row', async () => {
const refs = await run([
{ data: [{ id: 'inv-x', invoice_number: '003' }] }, // 1. invoices direct
{ data: [{ invoice_id: 'inv-x' }] }, // 2. invoice_payments (already known → step 3 skipped)
{ data: [] }, // 4. supplier registration
{ data: [] }, // 5. supplier payment
{ data: [] }, // 6. supplier_invoice_payments
])
expect(refs).toEqual([{ type: 'invoice', id: 'inv-x', number: '003' }])
})
it('returns both a customer and a supplier invoice, customer first', async () => {
const refs = await run([
{ data: [{ id: 'inv-a', invoice_number: 'A1' }] }, // 1. invoices direct
{ data: [] }, // 2. invoice_payments (empty → step 3 skipped)
{ data: [] }, // 4. supplier registration
{ data: [] }, // 5. supplier payment
{ data: [{ supplier_invoice_id: 'si-2' }] }, // 6. supplier_invoice_payments
{ data: [{ id: 'si-2', supplier_invoice_number: 'LF-2' }] }, // 7. supplier by id
])
expect(refs).toEqual([
{ type: 'invoice', id: 'inv-a', number: 'A1' },
{ type: 'supplier_invoice', id: 'si-2', number: 'LF-2' },
])
})
})
@@ -0,0 +1,143 @@
import type { SupabaseClient } from '@supabase/supabase-js'
/**
* A followable reference from a verifikation back to its underlag — the customer
* or supplier invoice that identifies what the affärshändelse avser and who the
* motpart is.
*
* Surfacing these makes the verifieringskedja traceable from the verifikat side,
* not only from the invoice side (BFL 5 kap 7§ — hänvisning till underlag;
* BFNAR 2013:2 — the verification chain must be followable in both directions).
*
* Bank transactions are deliberately excluded: a bank line is the trace of the
* affärshändelse, not its underlag. Counting it as underlag would wrongly silence
* the "saknar underlag" warning for expenses that still genuinely need a kvitto.
*/
export type UnderlagReferenceType = 'invoice' | 'supplier_invoice'
export interface UnderlagReference {
type: UnderlagReferenceType
id: string
/** invoice_number / supplier_invoice_number — the UI builds the label from this. */
number: string
}
interface InvoiceRow {
id: string
invoice_number: string
}
interface SupplierInvoiceRow {
id: string
supplier_invoice_number: string
}
/**
* Resolve every customer/supplier invoice linked to a verifikation, across all
* the deterministic FK paths the engine uses to book one:
* - invoices.journal_entry_id (faktureringsmetod registration / direct)
* - invoice_payments.journal_entry_id (kontantmetod inbetalning / delbetalning)
* - supplier_invoices.registration_journal_entry_id / payment_journal_entry_id
* - supplier_invoice_payments.journal_entry_id (delbetalning)
*
* Every query is company-scoped (defense in depth alongside RLS). Results are
* deduplicated by id, so an invoice reachable via several paths appears once.
*/
export async function getJournalEntryUnderlagReferences(
supabase: SupabaseClient,
companyId: string,
journalEntryId: string,
): Promise<UnderlagReference[]> {
// --- Customer invoices ---------------------------------------------------
const invoices = new Map<string, string>()
// Direct link (faktureringsmetod registration, or invoices.journal_entry_id).
const { data: directInvoices } = await supabase
.from('invoices')
.select('id, invoice_number')
.eq('company_id', companyId)
.eq('journal_entry_id', journalEntryId)
for (const inv of (directInvoices ?? []) as InvoiceRow[]) {
invoices.set(inv.id, inv.invoice_number)
}
// Payment rows (kontantmetod inbetalning, partial payments) → invoice_payments.
const { data: paymentRows } = await supabase
.from('invoice_payments')
.select('invoice_id')
.eq('journal_entry_id', journalEntryId)
const paymentInvoiceIds = new Set<string>()
for (const row of (paymentRows ?? []) as { invoice_id: string | null }[]) {
if (row.invoice_id && !invoices.has(row.invoice_id)) paymentInvoiceIds.add(row.invoice_id)
}
if (paymentInvoiceIds.size > 0) {
const { data: paidInvoices } = await supabase
.from('invoices')
.select('id, invoice_number')
.eq('company_id', companyId)
.in('id', Array.from(paymentInvoiceIds))
for (const inv of (paidInvoices ?? []) as InvoiceRow[]) {
invoices.set(inv.id, inv.invoice_number)
}
}
// --- Supplier invoices ---------------------------------------------------
const supplierInvoices = new Map<string, string>()
// Registration booking (accrual) on the invoice itself.
const { data: registrationLinks } = await supabase
.from('supplier_invoices')
.select('id, supplier_invoice_number')
.eq('company_id', companyId)
.eq('registration_journal_entry_id', journalEntryId)
for (const si of (registrationLinks ?? []) as SupplierInvoiceRow[]) {
supplierInvoices.set(si.id, si.supplier_invoice_number)
}
// Payment booking on the invoice itself.
const { data: paymentLinks } = await supabase
.from('supplier_invoices')
.select('id, supplier_invoice_number')
.eq('company_id', companyId)
.eq('payment_journal_entry_id', journalEntryId)
for (const si of (paymentLinks ?? []) as SupplierInvoiceRow[]) {
supplierInvoices.set(si.id, si.supplier_invoice_number)
}
// Partial-payment rows → supplier_invoice_payments.
const { data: supplierPaymentRows } = await supabase
.from('supplier_invoice_payments')
.select('supplier_invoice_id')
.eq('journal_entry_id', journalEntryId)
const supplierPaymentIds = new Set<string>()
for (const row of (supplierPaymentRows ?? []) as { supplier_invoice_id: string | null }[]) {
if (row.supplier_invoice_id && !supplierInvoices.has(row.supplier_invoice_id)) {
supplierPaymentIds.add(row.supplier_invoice_id)
}
}
if (supplierPaymentIds.size > 0) {
const { data: paidSupplierInvoices } = await supabase
.from('supplier_invoices')
.select('id, supplier_invoice_number')
.eq('company_id', companyId)
.in('id', Array.from(supplierPaymentIds))
for (const si of (paidSupplierInvoices ?? []) as SupplierInvoiceRow[]) {
supplierInvoices.set(si.id, si.supplier_invoice_number)
}
}
// --- Assemble ------------------------------------------------------------
const references: UnderlagReference[] = []
for (const [id, number] of invoices) references.push({ type: 'invoice', id, number })
for (const [id, number] of supplierInvoices) references.push({ type: 'supplier_invoice', id, number })
return references
}
+7
View File
@@ -1861,6 +1861,13 @@ const PROVIDER: Record<string, StructuredErrorEntry> = {
message_sv: 'Anslutningen till leverantören har gått ut. Återanslut för att fortsätta.',
message_en: 'Provider authentication expired or refresh failed.',
},
PROVIDER_LICENSE_MISSING: {
httpStatus: 403,
message_sv:
'Fortnox nekade anslutningen eftersom integrationslicensen inte är aktiv. Aktivera tilläggstjänsten "Fortnox Integration" i ditt Fortnox-konto (Inställningar → Tilläggstjänster) och återanslut sedan. Du kan även importera via SIE-fil under tiden.',
message_en:
'Fortnox refused the connection because the integration license is not active. Activate the "Fortnox Integration" add-on in your Fortnox account, then reconnect. You can also import via SIE file in the meantime.',
},
PROVIDER_RATE_LIMITED: {
httpStatus: 429,
message_sv:
@@ -0,0 +1,156 @@
import { randomUUID } from 'node:crypto'
import { describe, expect, it } from 'vitest'
import { getPool, withUserContext } from '@/tests/pg/setup'
import {
seedCompany,
insertAuthUser,
insertCompanyMember,
insertDraftJournalEntry,
insertBalancedLines,
} from '@/tests/pg/fixtures'
// Migration 20260624120000_undo_sie_import_explicit_actor.sql makes
// undo_sie_import accept the authorising user as p_user_id and resolve the
// owner/admin gate against COALESCE(p_user_id, auth.uid()).
//
// Why: the RPC now runs on the service-role client (to escape the 8s
// statement_timeout on large imports). That client is cookie-less, so inside
// the RPC auth.uid() is NULL — before this fix the role lookup matched nothing
// and the function ALWAYS raised "Only company owners and admins can undo SIE
// imports", breaking undo entirely on hosted.
//
// These tests call the function over the raw pool (no JWT context), which is
// exactly the auth.uid()-is-NULL situation the service client creates.
async function insertCompletedImport(params: {
companyId: string
userId: string
fiscalPeriodId: string
}): Promise<string> {
const id = randomUUID()
await getPool().query(
`INSERT INTO public.sie_imports
(id, user_id, company_id, filename, file_hash, sie_type,
fiscal_year_start, fiscal_year_end, accounts_count, transactions_count,
status, fiscal_period_id, imported_at)
VALUES ($1, $2, $3, 'undo-actor-test.se', $4, 4,
'2026-01-01', '2026-12-31', 0, 1,
'completed', $5, now())`,
[id, params.userId, params.companyId, `hash-${id}`, params.fiscalPeriodId],
)
return id
}
// Seed one posted source_type='import' verifikat so undo has something to
// delete. Insert as draft + balanced lines, then commit the draft→posted
// transition (the balance trigger requires balanced lines on that step).
async function insertPostedImportEntry(params: {
companyId: string
userId: string
fiscalPeriodId: string
}): Promise<string> {
const jeId = await insertDraftJournalEntry({
userId: params.userId,
companyId: params.companyId,
fiscalPeriodId: params.fiscalPeriodId,
sourceType: 'import',
status: 'draft',
voucherNumber: 1,
})
await insertBalancedLines(jeId, 1000)
await getPool().query(
`UPDATE public.journal_entries SET status = 'posted' WHERE id = $1`,
[jeId],
)
return jeId
}
async function callUndo(companyId: string, importId: string, actor: string | null) {
return getPool().query<{ deleted: number }>(
`SELECT public.undo_sie_import($1::uuid, $2::uuid, $3::uuid) AS deleted`,
[companyId, importId, actor],
)
}
describe('undo_sie_import: explicit actor (service-client path)', () => {
it('succeeds with an owner p_user_id even when auth.uid() is NULL', async () => {
const { companyId, userId, fiscalPeriodId } = await seedCompany()
const importId = await insertCompletedImport({ companyId, userId, fiscalPeriodId })
const jeId = await insertPostedImportEntry({ companyId, userId, fiscalPeriodId })
const res = await callUndo(companyId, importId, userId)
expect(res.rows[0].deleted).toBe(1)
const { rows: jeRows } = await getPool().query(
`SELECT 1 FROM public.journal_entries WHERE id = $1`,
[jeId],
)
expect(jeRows).toHaveLength(0)
const { rows: impRows } = await getPool().query<{ status: string }>(
`SELECT status FROM public.sie_imports WHERE id = $1`,
[importId],
)
expect(impRows[0].status).toBe('undone')
})
it('raises when no authorising identity is supplied (auth.uid() NULL, p_user_id NULL)', async () => {
const { companyId, userId, fiscalPeriodId } = await seedCompany()
const importId = await insertCompletedImport({ companyId, userId, fiscalPeriodId })
await expect(callUndo(companyId, importId, null)).rejects.toThrow(
/owners and admins/i,
)
// The gate fired before any mutation — the import is untouched.
const { rows } = await getPool().query<{ status: string }>(
`SELECT status FROM public.sie_imports WHERE id = $1`,
[importId],
)
expect(rows[0].status).toBe('completed')
})
it('raises when p_user_id is not an owner/admin of the company', async () => {
const { companyId, userId, fiscalPeriodId } = await seedCompany()
const importId = await insertCompletedImport({ companyId, userId, fiscalPeriodId })
// A 'member' of the same company is still not allowed to undo.
const memberId = await insertAuthUser()
await insertCompanyMember({ companyId, userId: memberId, role: 'member' })
await expect(callUndo(companyId, importId, memberId)).rejects.toThrow(
/owners and admins/i,
)
// And a complete stranger (no membership) is rejected too.
await expect(callUndo(companyId, importId, randomUUID())).rejects.toThrow(
/owners and admins/i,
)
})
it('still resolves the actor from auth.uid() when p_user_id is omitted (backward compat)', async () => {
const { companyId, userId, fiscalPeriodId } = await seedCompany()
const importId = await insertCompletedImport({ companyId, userId, fiscalPeriodId })
// Seed a posted import verifikat so undo has something to delete. Without it
// the returned count is 0 regardless of behaviour, so the assertion would
// pass even if the function deleted nothing — making the count meaningless.
await insertPostedImportEntry({ companyId, userId, fiscalPeriodId })
// 2-arg shape: p_user_id defaults to NULL, so the gate falls back to
// auth.uid(). withUserContext sets the JWT sub to the owner and runs in a
// transaction; assert inside it (the helper rolls back on return).
const deleted = await withUserContext(userId, async (client) => {
const res = await client.query<{ deleted: number }>(
`SELECT public.undo_sie_import($1::uuid, $2::uuid) AS deleted`,
[companyId, importId],
)
const imp = await client.query<{ status: string }>(
`SELECT status FROM public.sie_imports WHERE id = $1`,
[importId],
)
expect(imp.rows[0].status).toBe('undone')
return res.rows[0].deleted
})
expect(deleted).toBe(1)
})
})
+40 -4
View File
@@ -157,6 +157,29 @@ export async function checkDuplicatePeriodImport(
return data as SIEImport | null
}
/**
* Client for the bulk hard-delete RPCs (replace_sie_import / undo_sie_import).
*
* The authenticated role carries statement_timeout=8s on hosted Supabase,
* and deleting a large import (thousands of journal_entries, each firing
* write_audit_log with a JSONB old_state snapshot, plus cascading lines)
* does not finish inside that budget — the RPC dies with "canceling
* statement due to statement timeout" and rolls back. The service role has
* no statement_timeout, so the RPC runs on it instead.
*
* Safe escalation: callers validate company ownership against the
* RLS-scoped session client BEFORE the RPC, and the RPC itself (SECURITY
* DEFINER) re-filters every statement on p_company_id.
*
* Falls back to the caller's client when the service key is absent
* (unit tests, misconfigured self-hosted) — same behavior as before.
*/
async function rpcClientForBulkDelete(fallback: SupabaseClient): Promise<SupabaseClient> {
if (!process.env.SUPABASE_SERVICE_ROLE_KEY) return fallback
const { createServiceClient } = await import('@/lib/supabase/server')
return createServiceClient()
}
/**
* Replace a completed SIE import so the user can re-import corrected data
* for the same fiscal period.
@@ -210,8 +233,10 @@ export async function replaceSIEImport(
}
}
// 3. Atomically delete entries and mark import as replaced via DB RPC
const { data: deletedCount, error: rpcError } = await supabase.rpc('replace_sie_import', {
// 3. Atomically delete entries and mark import as replaced via DB RPC.
// Runs on the service client — see rpcClientForBulkDelete.
const rpcClient = await rpcClientForBulkDelete(supabase)
const { data: deletedCount, error: rpcError } = await rpcClient.rpc('replace_sie_import', {
p_company_id: companyId,
p_import_id: importId,
})
@@ -231,11 +256,17 @@ export async function replaceSIEImport(
* Pre-flight checks mirror replaceSIEImport so the user gets a Swedish
* error message before the RPC raises. The RPC itself is idempotent on
* status — calling twice surfaces the "not in completed status" error.
*
* `userId` is the authorising user. It is passed to the RPC as p_user_id
* because the RPC may run on the service client (see rpcClientForBulkDelete),
* where auth.uid() is NULL — without it the RPC's owner/admin gate can never
* match and always raises. The RPC enforces owner/admin against this id.
*/
export async function undoSIEImport(
supabase: SupabaseClient,
companyId: string,
importId: string
importId: string,
userId: string
): Promise<{ success: boolean; deletedEntries: number; error?: string }> {
const { data: importRecord } = await supabase
.from('sie_imports')
@@ -265,9 +296,14 @@ export async function undoSIEImport(
}
}
const { data: deletedCount, error: rpcError } = await supabase.rpc('undo_sie_import', {
// Runs on the service client — see rpcClientForBulkDelete. Pass the
// authorising user explicitly: on the service client auth.uid() is NULL,
// so the RPC's owner/admin gate resolves against p_user_id instead.
const rpcClient = await rpcClientForBulkDelete(supabase)
const { data: deletedCount, error: rpcError } = await rpcClient.rpc('undo_sie_import', {
p_company_id: companyId,
p_import_id: importId,
p_user_id: userId,
})
if (rpcError) {
+3 -2
View File
@@ -2550,6 +2550,7 @@ async function commitImportSie(
async function commitUndoSieImport(
supabase: SupabaseClient,
userId: string,
companyId: string,
params: Record<string, unknown>,
): Promise<ExecutorResult> {
@@ -2559,7 +2560,7 @@ async function commitUndoSieImport(
return { error: 'import_id is required', status: 400 }
}
const result = await undoSIEImport(supabase, companyId, importId)
const result = await undoSIEImport(supabase, companyId, importId, userId)
if (!result.success) {
return { error: result.error ?? 'SIE undo failed', status: 400 }
}
@@ -3459,7 +3460,7 @@ async function commitPendingOperationInner(
result = await commitImportSie(supabase, userId, companyId, pendingOp.params)
break
case 'undo_sie_import':
result = await commitUndoSieImport(supabase, companyId, pendingOp.params)
result = await commitUndoSieImport(supabase, userId, companyId, pendingOp.params)
break
case 'create_voucher':
result = await commitCreateVoucher(supabase, userId, companyId, pendingOp.params, opts)
@@ -0,0 +1,63 @@
import { describe, it, expect } from 'vitest'
import { randomUUID } from 'node:crypto'
import { getPool } from '@/tests/pg/setup'
import { seedCompany } from '@/tests/pg/fixtures'
// Regression guard for a destructive bug: resolveConsent()'s optimistic
// concurrency guard ran `UPDATE provider_consent_tokens … .select('id')`, but
// this table's PRIMARY KEY is `consent_id` and it has NO `id` column. Postgres
// rejected the whole statement ("column provider_consent_tokens.id does not
// exist"), which surfaced as updateError AFTER the provider had already rotated
// the refresh token — permanently breaking the consent.
//
// Unit mocks can't catch this (they replay queued data regardless of the
// selected columns), so we assert the real query shapes against real Postgres.
async function seedConsentWithToken(): Promise<{ consentId: string; expiresAt: string }> {
const { companyId } = await seedCompany()
const consentId = randomUUID()
const expiresAt = '2020-01-01T00:00:00.000Z'
await getPool().query(
`INSERT INTO provider_consents (id, company_id, name, status, provider)
VALUES ($1, $2, $3, 1, 'fortnox')`,
[consentId, companyId, `pg-real-${consentId}`],
)
await getPool().query(
`INSERT INTO provider_consent_tokens
(consent_id, provider, access_token, refresh_token, token_expires_at)
VALUES ($1, 'fortnox', 'old-access', 'old-refresh', $2)`,
[consentId, expiresAt],
)
return { consentId, expiresAt }
}
describe('provider_consent_tokens guarded update (pg-real)', () => {
it('the rotation UPDATE … RETURNING consent_id is valid and matches the row', async () => {
const { consentId, expiresAt } = await seedConsentWithToken()
// This mirrors resolveConsent()'s guarded update exactly. `consent_id` is
// the PK; selecting it must succeed and return the matched row.
const { rows } = await getPool().query(
`UPDATE provider_consent_tokens
SET access_token = $1, refresh_token = $2, token_expires_at = $3
WHERE consent_id = $4 AND token_expires_at = $5
RETURNING consent_id`,
['new-access', 'new-refresh', '2030-01-01T00:00:00.000Z', consentId, expiresAt],
)
expect(rows).toHaveLength(1)
expect(rows[0].consent_id).toBe(consentId)
})
it('there is no `id` column to select (proves why the old query broke)', async () => {
const { consentId } = await seedConsentWithToken()
await expect(
getPool().query(
`SELECT id FROM provider_consent_tokens WHERE consent_id = $1`,
[consentId],
),
).rejects.toThrow(/column .*id.* does not exist/i)
})
})
@@ -9,9 +9,15 @@ vi.mock('@/lib/providers/briox/oauth', () => ({
refreshBrioxToken: vi.fn(),
}));
vi.mock('@/lib/providers/fortnox/oauth', () => ({
refreshFortnoxToken: vi.fn(),
}));
import { createServiceClient } from '@/lib/supabase/server';
import { refreshBrioxToken } from '@/lib/providers/briox/oauth';
import { refreshFortnoxToken } from '@/lib/providers/fortnox/oauth';
import { resolveConsent } from '../resolve-consent';
import { ProviderCallError } from '../with-provider-call';
const consentRow = { id: 'c1', company_id: 'co1', provider: 'briox', status: 1 };
@@ -52,7 +58,7 @@ describe('resolveConsent — Briox token refresh concurrency', () => {
it('persists the rotated pair when the guarded update wins the race', async () => {
mock.enqueue({ data: [consentRow] }); // consent lookup
mock.enqueue({ data: [expiredTokens] }); // expired token row
mock.enqueue({ data: [{ id: 't1' }] }); // guarded update matched 1 row
mock.enqueue({ data: [{ consent_id: 'c1' }] }); // guarded update matched 1 row (PK is consent_id, not id)
const result = await resolveConsent('co1', 'c1');
@@ -98,4 +104,63 @@ describe('resolveConsent — Briox token refresh concurrency', () => {
message: expect.stringContaining('re-enter the credentials'),
});
});
it('rethrows a dead refresh token as PROVIDER_AUTH_EXPIRED so callers prompt reconnect', async () => {
mock.enqueue({ data: [consentRow] }); // consent lookup
mock.enqueue({ data: [expiredTokens] }); // expired token row
// Mirrors Fortnox's `400 invalid_grant`: the raw helper throws a plain
// Error whose status lives only in the message string. resolveConsent must
// still classify it as an expired connection, not let it fall through to a
// generic 500 at the route.
vi.mocked(refreshBrioxToken).mockRejectedValueOnce(
new Error('Briox token refresh failed: 400 {"error":"invalid_grant"}'),
);
const err = await resolveConsent('co1', 'c1').catch((e) => e);
expect(err).toBeInstanceOf(ProviderCallError);
expect(err.code).toBe('PROVIDER_AUTH_EXPIRED');
expect(err.provider).toBe('briox');
});
it('maps Fortnox error_missing_license to PROVIDER_LICENSE_MISSING (not a revivable reconnect)', async () => {
const fortnoxConsent = { id: 'c2', company_id: 'co1', provider: 'fortnox', status: 1 };
mock.enqueue({ data: [fortnoxConsent] }); // consent lookup
mock.enqueue({ data: [expiredTokens] }); // expired token row
// Fortnox answers the token endpoint with error_missing_license when the
// customer's integration license has lapsed. Re-auth can't revive it — the
// license must be re-ordered first — so it gets its own code rather than the
// generic "reconnect" PROVIDER_AUTH_EXPIRED.
vi.mocked(refreshFortnoxToken).mockRejectedValueOnce(
new Error(
'Fortnox token refresh failed: 401 {"error":"error_missing_license","error_description":"The client credentials are invalid"}',
),
);
const err = await resolveConsent('co1', 'c2').catch((e) => e);
expect(err).toBeInstanceOf(ProviderCallError);
expect(err.code).toBe('PROVIDER_LICENSE_MISSING');
expect(err.provider).toBe('fortnox');
});
it('keeps a Fortnox invalid_grant as PROVIDER_AUTH_EXPIRED (revivable by reconnect)', async () => {
const fortnoxConsent = { id: 'c2', company_id: 'co1', provider: 'fortnox', status: 1 };
mock.enqueue({ data: [fortnoxConsent] }); // consent lookup
mock.enqueue({ data: [expiredTokens] }); // expired token row
// A plain expired/revoked grant IS revivable by reconnecting — it must not
// be mis-mapped to the license code.
vi.mocked(refreshFortnoxToken).mockRejectedValueOnce(
new Error('Fortnox token refresh failed: 400 {"error":"invalid_grant"}'),
);
const err = await resolveConsent('co1', 'c2').catch((e) => e);
expect(err).toBeInstanceOf(ProviderCallError);
expect(err.code).toBe('PROVIDER_AUTH_EXPIRED');
expect(err.provider).toBe('fortnox');
});
});
+49 -10
View File
@@ -5,6 +5,7 @@ import { refreshFortnoxToken } from './fortnox/oauth';
import { refreshVismaToken } from './visma/oauth';
import { refreshBrioxToken } from './briox/oauth';
import { refreshBjornLundenToken } from './bjornlunden/oauth';
import { ProviderCallError, isMissingLicenseError } from './with-provider-call';
import { createLogger } from '@/lib/logger';
const log = createLogger('providers/resolve-consent');
@@ -98,15 +99,48 @@ export async function resolveConsent(companyId: string, consentId: string): Prom
let refreshed: TokenResponse;
if (consent.provider === 'fortnox') {
refreshed = await refreshFortnoxToken(getOAuthConfig('fortnox'), tokens.refresh_token as string);
} else if (consent.provider === 'briox') {
// Briox /tokenrefresh wants the (expired) access token alongside the
// refresh token; no app-level config involved. Both tokens rotate —
// the new refresh_token is persisted below.
refreshed = await refreshBrioxToken(tokens.refresh_token as string, tokens.access_token as string);
} else {
refreshed = await refreshVismaToken(getOAuthConfig(consent.provider as string), tokens.refresh_token as string);
// A failed refresh is categorically an expired/revoked connection: the
// providers rotate refresh tokens and a dead one (e.g. Fortnox `400
// invalid_grant`) can never be replayed — retrying is pointless. Surface it
// as PROVIDER_AUTH_EXPIRED so callers (preview/sie-data/migrate) report
// "reconnect" (401) instead of a generic 500 that invites a useless retry.
// The raw helpers throw plain Errors with the status only in the message
// string, so classifyProviderError can't see it downstream — we map here,
// at the boundary that knows this is a refresh.
try {
if (consent.provider === 'fortnox') {
refreshed = await refreshFortnoxToken(getOAuthConfig('fortnox'), tokens.refresh_token as string);
} else if (consent.provider === 'briox') {
// Briox /tokenrefresh wants the (expired) access token alongside the
// refresh token; no app-level config involved. Both tokens rotate —
// the new refresh_token is persisted below.
refreshed = await refreshBrioxToken(tokens.refresh_token as string, tokens.access_token as string);
} else {
refreshed = await refreshVismaToken(getOAuthConfig(consent.provider as string), tokens.refresh_token as string);
}
} catch (err) {
const reason = err instanceof Error ? err.message : String(err);
// A missing/inactive integration license (Fortnox `error_missing_license`)
// is NOT a revivable token: re-authorizing loops until the customer
// re-orders the license. Surface it as its own code so the caller shows
// "activate the license, then reconnect" instead of a bare reconnect.
const code = isMissingLicenseError(reason)
? 'PROVIDER_LICENSE_MISSING'
: 'PROVIDER_AUTH_EXPIRED';
log.error(
`Failed to refresh ${consent.provider} token for consent ${consentId}` +
(code === 'PROVIDER_LICENSE_MISSING'
? 'the integration license is missing/inactive'
: 'the connection must be re-authorized'),
{ reason },
);
throw new ProviderCallError(
code,
consent.provider as string,
code === 'PROVIDER_LICENSE_MISSING'
? `${consent.provider} integration license missing/inactive; the customer must re-order it before reconnecting`
: `Token refresh failed for ${consent.provider}; the connection must be re-authorized`,
);
}
const newExpiresAt = new Date(Date.now() + refreshed.expires_in * 1000).toISOString();
@@ -126,7 +160,12 @@ export async function resolveConsent(companyId: string, consentId: string): Prom
})
.eq('consent_id', consentId)
.eq('token_expires_at', tokens.token_expires_at as string)
.select('id');
// consent_id is the table's PRIMARY KEY — there is no `id` column.
// Selecting `id` here makes Postgres reject the whole statement
// ("column provider_consent_tokens.id does not exist"), which surfaces as
// updateError and is misreported as "rotated tokens could not be saved"
// AFTER the provider already rotated — permanently breaking the consent.
.select('consent_id');
if (updateError) {
// The provider has ALREADY rotated the tokens but we failed to persist
+26
View File
@@ -13,6 +13,7 @@ import { createLogger, type Logger } from '@/lib/logger'
export type ProviderCallErrorCode =
| 'PROVIDER_AUTH_EXPIRED'
| 'PROVIDER_LICENSE_MISSING'
| 'PROVIDER_RATE_LIMITED'
| 'PROVIDER_UNREACHABLE'
| 'PROVIDER_UPSTREAM_ERROR'
@@ -206,3 +207,28 @@ export function classifyProviderError(error: unknown): ProviderCallErrorCode | n
return null
}
/**
* True when a provider token/OAuth failure means the integration license is
* missing or inactive NOT an ordinary expired/revoked grant.
*
* Fortnox answers its token endpoint with `error_missing_license` when the
* customer's Fortnox account no longer carries the integration license. The
* stored refresh token cannot be revived by re-authorizing: re-auth loops until
* the customer re-orders the "Fortnox Integration" add-on. Distinguishing this
* from a plain dead token lets callers say "activate the license, then
* reconnect" instead of a bare "reconnect" that just fails again.
*
* Matches on the raw provider message string because the underlying refresh
* helpers bake the body into the Error message; deliberately does NOT match
* `invalid_grant` (that IS a revivable reconnect PROVIDER_AUTH_EXPIRED).
*/
export function isMissingLicenseError(message: string): boolean {
const haystack = message.toLowerCase()
return (
haystack.includes('error_missing_license') ||
haystack.includes('missing_license') ||
haystack.includes('missing license') ||
haystack.includes('not have enough licenses')
)
}
@@ -6,6 +6,7 @@ vi.mock('../trial-balance', () => ({
import { generateIncomeStatement } from '../income-statement'
import { generateTrialBalance } from '../trial-balance'
import { roundOre } from '@/lib/money'
import type { TrialBalanceRow } from '@/types'
const mockTrialBalance = vi.mocked(generateTrialBalance)
@@ -289,4 +290,71 @@ describe('generateIncomeStatement', () => {
expect(report.financial_sections).toEqual([])
expect(report.total_revenue).toBe(40000)
})
it('includes energikostnader (group 53, e.g. 5310) in expenses and net_result — regression', async () => {
// Regression: group '53' was missing from the expense label map, so 53xx
// accounts (energy costs like 5310 El för drift) were silently dropped from
// total_expenses and net_result. The Resultatrapport (which sums all class
// 38 rows directly) stayed correct, which is how the discrepancy surfaced.
mockTrialBalance.mockResolvedValue({
rows: [
makeRow({ account_number: '3001', account_name: 'Revenue', account_class: 3, closing_credit: 100000, closing_debit: 0 }),
makeRow({ account_number: '5310', account_name: 'El för drift', account_class: 5, closing_debit: 18000, closing_credit: 0 }),
],
totalDebit: 18000,
totalCredit: 100000,
isBalanced: false,
})
const report = await generateIncomeStatement(supabase, 'company-1', 'period-1')
expect(report.total_expenses).toBe(18000) // was 0 before the fix
expect(report.net_result).toBe(82000) // was 100000 before the fix
const expenseAccounts = report.expense_sections.flatMap((s) => s.rows.map((r) => r.account_number))
expect(expenseAccounts).toContain('5310')
})
it('routes accounts from every unmapped group (48, 53, 67) into a catch-all, never dropping them', async () => {
mockTrialBalance.mockResolvedValue({
rows: [
makeRow({ account_number: '3001', account_name: 'Revenue', account_class: 3, closing_credit: 100000, closing_debit: 0 }),
makeRow({ account_number: '4810', account_name: 'Energi råvara', account_class: 4, closing_debit: 1000, closing_credit: 0 }),
makeRow({ account_number: '5310', account_name: 'El för drift', account_class: 5, closing_debit: 2000, closing_credit: 0 }),
makeRow({ account_number: '6710', account_name: 'Lämnade bidrag', account_class: 6, closing_debit: 3000, closing_credit: 0 }),
],
totalDebit: 6000,
totalCredit: 100000,
isBalanced: false,
})
const report = await generateIncomeStatement(supabase, 'company-1', 'period-1')
// All three expense accounts must be counted, regardless of label coverage.
expect(report.total_expenses).toBe(6000)
expect(report.net_result).toBe(94000)
const expenseAccounts = report.expense_sections.flatMap((s) => s.rows.map((r) => r.account_number))
expect(expenseAccounts).toEqual(expect.arrayContaining(['4810', '5310', '6710']))
})
it('total_expenses equals the signed sum of every class 47 row (no silent drops)', async () => {
// Structural invariant guarding the whole class of "missing group label"
// bug: the sum of expense-section subtotals must equal Σ(debit - credit)
// over all class 47 rows, mixing mapped (50, 70) and unmapped (48, 53, 67)
// groups.
const rows = [
makeRow({ account_number: '5010', account_name: 'Lokalhyra', account_class: 5, closing_debit: 8000, closing_credit: 0 }),
makeRow({ account_number: '5310', account_name: 'El för drift', account_class: 5, closing_debit: 2500, closing_credit: 0 }),
makeRow({ account_number: '4810', account_name: 'Energi', account_class: 4, closing_debit: 1500, closing_credit: 0 }),
makeRow({ account_number: '6710', account_name: 'Bidrag', account_class: 6, closing_debit: 500, closing_credit: 0 }),
makeRow({ account_number: '7010', account_name: 'Löner', account_class: 7, closing_debit: 40000, closing_credit: 0 }),
]
mockTrialBalance.mockResolvedValue({ rows, totalDebit: 52500, totalCredit: 0, isBalanced: false })
const report = await generateIncomeStatement(supabase, 'company-1', 'period-1')
const expectedTotal = rows.reduce((sum, r) => sum + (r.closing_debit - r.closing_credit), 0)
const sectionSum = report.expense_sections.reduce((sum, s) => sum + s.subtotal, 0)
expect(report.total_expenses).toBe(expectedTotal) // 52500
expect(roundOre(sectionSum)).toBe(expectedTotal)
})
})
+43 -24
View File
@@ -47,7 +47,8 @@ export async function generateIncomeStatement(
'38': 'Aktiverat arbete',
'39': 'Övriga rörelseintäkter',
},
'credit' // Revenue has credit normal balance
'credit', // Revenue has credit normal balance
'Övriga intäkter',
)
// Expense sections (class 4-7)
@@ -62,10 +63,12 @@ export async function generateIncomeStatement(
'45': 'Inköp utlandet',
'46': 'Underentreprenader och legoarbeten',
'47': 'Erhållna rabatter',
'48': 'Andra produktionskostnader',
'49': 'Lagerförändringar',
'50': 'Lokalkostnader',
'51': 'Fastighetskostnader',
'52': 'Hyra av tillgångar',
'53': 'Energikostnader',
'54': 'Förbrukningsinventarier',
'55': 'Reparation och underhåll',
'56': 'Transportkostnader',
@@ -78,6 +81,7 @@ export async function generateIncomeStatement(
'63': 'Försäkringar och riskkostnader',
'64': 'Förvaltningskostnader',
'65': 'Övriga externa tjänster',
'67': 'Särskilt för ideella föreningar och stiftelser',
'68': 'Inhyrd personal',
'69': 'Övriga kostnader',
'70': 'Löner kollektivanställda',
@@ -90,7 +94,8 @@ export async function generateIncomeStatement(
'78': 'Avskrivningar',
'79': 'Övriga rörelsekostnader',
},
'debit' // Expenses have debit normal balance
'debit', // Expenses have debit normal balance
'Övriga kostnader',
)
// Financial sections (class 8) — exclude 8999 "Årets resultat".
@@ -112,7 +117,8 @@ export async function generateIncomeStatement(
'88': 'Bokslutsdispositioner',
'89': 'Skatter och årets resultat',
},
'mixed'
'mixed',
'Övriga finansiella poster',
)
const totalRevenue = revenueSections.reduce((sum, s) => sum + s.subtotal, 0)
@@ -132,31 +138,29 @@ export async function generateIncomeStatement(
}
/**
* Build report sections from trial balance rows
* Build report sections from trial balance rows.
*
* Every row is assigned to exactly one section: either a known 2-digit group
* (from `groupLabels`) or the `fallbackTitle` catch-all for any group not in
* the map. The catch-all is what keeps the report complete without it, an
* account whose group code is missing from `groupLabels` (e.g. 53xx
* energikostnader, 48xx, 67xx) would be silently dropped from both the
* breakdown and the computed subtotal/total/net_result.
*/
function buildSections(
rows: TrialBalanceRow[],
groupLabels: Record<string, string>,
normalBalance: 'debit' | 'credit' | 'mixed'
normalBalance: 'debit' | 'credit' | 'mixed',
fallbackTitle: string
): IncomeStatementSection[] {
const sections: IncomeStatementSection[] = []
for (const [groupCode, title] of Object.entries(groupLabels)) {
const groupRows = rows.filter((r) => r.account_number.startsWith(groupCode))
if (groupRows.length === 0) continue
const makeSection = (title: string, groupRows: TrialBalanceRow[]): IncomeStatementSection => {
const sectionRows = groupRows.map((r) => {
let amount: number
if (normalBalance === 'credit') {
// Revenue: credit - debit (positive = revenue)
amount = r.closing_credit - r.closing_debit
} else if (normalBalance === 'debit') {
// Expense: debit - credit (positive = expense)
amount = r.closing_debit - r.closing_credit
} else {
// Mixed: net balance (financial items)
amount = r.closing_credit - r.closing_debit
}
// Expenses (debit) use debit - credit; revenue (credit) and financial
// (mixed) use credit - debit.
const amount =
normalBalance === 'debit'
? r.closing_debit - r.closing_credit
: r.closing_credit - r.closing_debit
return {
account_number: r.account_number,
@@ -167,12 +171,27 @@ function buildSections(
const subtotal = sectionRows.reduce((sum, r) => sum + r.amount, 0)
sections.push({
return {
title,
rows: sectionRows.filter((r) => Math.abs(r.amount) > 0.005),
subtotal: Math.round(subtotal * 100) / 100,
})
}
}
const sections: IncomeStatementSection[] = []
const matched = new Set<string>()
for (const [groupCode, title] of Object.entries(groupLabels)) {
const groupRows = rows.filter((r) => r.account_number.startsWith(groupCode))
if (groupRows.length === 0) continue
for (const r of groupRows) matched.add(r.account_number)
sections.push(makeSection(title, groupRows))
}
// Catch-all: any row whose 2-digit group is not in groupLabels. Guarantees no
// account is ever excluded from the subtotal/total/net_result.
const orphans = rows.filter((r) => !matched.has(r.account_number))
if (orphans.length > 0) sections.push(makeSection(fallbackTitle, orphans))
return sections
}
+8
View File
@@ -3150,6 +3150,11 @@
"attachments_title": "Documents",
"attachments_count": "{count} documents",
"no_attachments": "No documents attached",
"references_count": "{count, plural, one {# reference} other {# references}}",
"references_title": "Linked supporting documents",
"references_subtitle": "Reference to the invoice that identifies the transaction — part of the audit trail.",
"reference_invoice": "Customer invoice {number}",
"reference_supplier_invoice": "Supplier invoice {number}",
"currency_title": "Currency conversion",
"currency_rate": "Rate",
"currency_original_amount": "Original amount",
@@ -3556,6 +3561,9 @@
"bank_sync_new_since_last_visit_one": "1 new bank transaction since your last visit",
"bank_sync_new_since_last_visit_many": "{count} new bank transactions since your last visit",
"bank_sync_new_since_last_visit_dismiss": "Dismiss",
"bank_reconnect": "Reconnect",
"bank_sync_session_expired": "Bank connection expired",
"bank_sync_session_expired_desc": "Reconnect to keep syncing transactions.",
"import_psd2_active_warning_title": "PSD2 is active for {bankName}",
"import_psd2_active_warning_body": "Transactions sync automatically each night. File imports are only needed for older history or when PSD2 isn't working — otherwise duplicates may occur."
},
+8
View File
@@ -3150,6 +3150,11 @@
"attachments_title": "Underlag",
"attachments_count": "{count} dokument",
"no_attachments": "Inga underlag bifogade",
"references_count": "{count, plural, one {# hänvisning} other {# hänvisningar}}",
"references_title": "Kopplat underlag",
"references_subtitle": "Hänvisning till fakturan som identifierar affärshändelsen — del av verifieringskedjan.",
"reference_invoice": "Kundfaktura {number}",
"reference_supplier_invoice": "Leverantörsfaktura {number}",
"currency_title": "Valutaomräkning",
"currency_rate": "Kurs",
"currency_original_amount": "Ursprungsbelopp",
@@ -3556,6 +3561,9 @@
"bank_sync_new_since_last_visit_one": "1 ny banktransaktion sen ditt senaste besök",
"bank_sync_new_since_last_visit_many": "{count} nya banktransaktioner sen ditt senaste besök",
"bank_sync_new_since_last_visit_dismiss": "Stäng",
"bank_reconnect": "Förnya anslutning",
"bank_sync_session_expired": "Bankanslutningen har löpt ut",
"bank_sync_session_expired_desc": "Förnya anslutningen för att fortsätta synka transaktioner.",
"import_psd2_active_warning_title": "PSD2 är aktivt för {bankName}",
"import_psd2_active_warning_body": "Transaktioner synkas automatiskt varje natt. Filimport behövs bara för äldre historik eller om PSD2 inte fungerar — annars kan dubbletter uppstå."
},
+252
View File
@@ -0,0 +1,252 @@
/**
* One-off cleanup: balance every unbalanced voucher in the BL test company
* behind the given consent, via BL's
* PUT /journal/ledgerentry/{journalId}/{journalEntryId}/{journalEntryDate}.
*
* BL refuses DELETE on anything but the last voucher of a series, so instead:
* method A (preferred): add a counter ledger entry against 0099
* "Konvertering" with amount = -diff (probe: entityId 0 = new line)
* method B (fallback): update the voucher's first ledger entry so the
* voucher sums to zero
*
* Each method is probed on ONE voucher and verified with a GET before the
* mass run. Empty vouchers (0 lines) are left alone they pass validation.
*
* Run: npx tsx --env-file=.env --env-file=.env.local scripts/bl-balance-broken.ts <consentId>
*/
import { createClient } from '@supabase/supabase-js'
import { fetchBjornLundenToken } from '@/lib/providers/bjornlunden/oauth'
import { fetchProviderSieFiles } from '@/extensions/general/arcim-migration/lib/sie-fetcher'
import { parseSIEFile, validateSIEFile } from '@/lib/import/sie-parser'
const BL_BASE_URL = 'https://apigateway.blinfo.se/bla-api/v1/sp'
const DELAY_MS = 125 // ~8 req/s, under BL's 10 req/s limit
const consentId = process.argv[2]
if (!consentId) {
console.error('Usage: npx tsx --env-file=.env --env-file=.env.local scripts/bl-balance-broken.ts <consentId>')
console.error('Refusing to run without an explicit consentId — this script writes vouchers to a live BL company.')
process.exit(1)
}
interface BLLedgerEntry {
entityId: number
accountId: string
amount: number
costBearerId: string
costCenterId: string
date: string
id: number
line: number
projectId: string
quantity: number
text: string
accrual: boolean
}
interface BLJournalEntry {
entityId: number
journalId: string
journalEntryId: number
journalEntryDate: string
journalEntryText: string
ledgerEntries: BLLedgerEntry[]
}
const round2 = (n: number) => Math.round(n * 100) / 100
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))
function entrySum(e: BLJournalEntry): number {
return round2(e.ledgerEntries.reduce((s, l) => s + (l.amount ?? 0), 0))
}
function headers(accessToken: string, userKey: string): Record<string, string> {
return {
Authorization: `Bearer ${accessToken}`,
'User-Key': userKey,
'Content-Type': 'application/json',
Accept: 'application/json',
}
}
async function fetchAllEntries(accessToken: string, userKey: string): Promise<BLJournalEntry[]> {
const all: BLJournalEntry[] = []
let page = 1
let totalPages = 1
while (page <= totalPages) {
const params = new URLSearchParams({ page: String(page), rows: '500' })
const res = await fetch(`${BL_BASE_URL}/journal/entry/batch?${params}`, {
headers: headers(accessToken, userKey),
signal: AbortSignal.timeout(60_000),
})
if (!res.ok) throw new Error(`batch page ${page} failed: HTTP ${res.status}`)
const body = await res.json() as { pageRequested: number; totalPages: number; data: BLJournalEntry[] }
all.push(...(body.data ?? []))
totalPages = body.totalPages ?? 1
page++
await sleep(DELAY_MS)
}
return all
}
async function fetchOne(
accessToken: string,
userKey: string,
v: BLJournalEntry,
): Promise<BLJournalEntry> {
const res = await fetch(
`${BL_BASE_URL}/journal/entry/${encodeURIComponent(v.journalId)}/${v.journalEntryId}/${v.journalEntryDate}`,
{ headers: headers(accessToken, userKey), signal: AbortSignal.timeout(30_000) },
)
if (!res.ok) throw new Error(`GET single entry failed: HTTP ${res.status}`)
return res.json() as Promise<BLJournalEntry>
}
async function putLedgerEntry(
accessToken: string,
userKey: string,
v: BLJournalEntry,
body: Partial<BLLedgerEntry>,
): Promise<{ ok: boolean; status: number; body: string }> {
const res = await fetch(
`${BL_BASE_URL}/journal/ledgerentry/${encodeURIComponent(v.journalId)}/${v.journalEntryId}/${v.journalEntryDate}`,
{
method: 'PUT',
headers: headers(accessToken, userKey),
body: JSON.stringify(body),
signal: AbortSignal.timeout(30_000),
},
)
const text = await res.text().catch(() => '')
return { ok: res.ok, status: res.status, body: text.slice(0, 300) }
}
function addLineBody(v: BLJournalEntry, diff: number): Partial<BLLedgerEntry> {
const maxLine = v.ledgerEntries.reduce((m, l) => Math.max(m, l.line ?? 0), 0)
return {
entityId: 0,
accountId: '0099',
amount: round2(-diff),
costBearerId: '',
costCenterId: '',
date: v.journalEntryDate,
line: maxLine + 1,
projectId: '',
quantity: 0,
text: 'Balansering vid migrering (test)',
accrual: false,
}
}
function adjustFirstLineBody(v: BLJournalEntry, diff: number): Partial<BLLedgerEntry> {
const first = v.ledgerEntries[0]!
return { ...first, amount: round2(first.amount - diff) }
}
async function main() {
// Either a User-Key GUID passed directly as the 2nd arg, or resolved from
// the consent in the 1st arg (consents churn on every wizard reconnect).
let userKey = process.argv[3]
if (!userKey) {
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!,
)
const { data: tokens, error } = await supabase
.from('provider_consent_tokens')
.select('provider_company_id')
.eq('consent_id', consentId)
.limit(1)
if (error || !tokens?.length) throw new Error(`no tokens for consent: ${error?.message ?? 'not found'}`)
userKey = tokens[0]!.provider_company_id as string
}
const token = await fetchBjornLundenToken(
process.env.BJORN_LUNDEN_CLIENT_ID!,
process.env.BJORN_LUNDEN_CLIENT_SECRET!,
)
const at = token.access_token
console.log('Fetching all journal entries via batch API...')
const entries = await fetchAllEntries(at, userKey)
const unbalanced = entries.filter((e) => e.ledgerEntries.length > 0 && Math.abs(entrySum(e)) > 0.01)
const empty = entries.filter((e) => e.ledgerEntries.length === 0).length
console.log(`${entries.length} vouchers total: ${unbalanced.length} unbalanced (will fix), ${empty} empty (left alone)`)
if (unbalanced.length === 0) {
console.log('Nothing to do.')
return
}
// ── Probe method A (add 0099 line) on one voucher ────────────────
const probe = unbalanced[0]!
const probeDiff = entrySum(probe)
console.log(`Probe: ${probe.journalId}${probe.journalEntryId} (${probe.journalEntryDate}, diff ${probeDiff})`)
let method: 'add' | 'adjust' | null = null
const addResult = await putLedgerEntry(at, userKey, probe, addLineBody(probe, probeDiff))
if (addResult.ok) {
const after = await fetchOne(at, userKey, probe)
if (Math.abs(entrySum(after)) <= 0.01) {
method = 'add'
console.log('Method A (add 0099 counter-line) works — verified balanced via GET.')
} else {
console.log(`Method A responded OK but voucher still sums to ${entrySum(after)} — trying method B.`)
}
} else {
console.log(`Method A refused (HTTP ${addResult.status}): ${addResult.body} — trying method B.`)
}
if (!method) {
const fresh = await fetchOne(at, userKey, probe)
const freshDiff = entrySum(fresh)
if (Math.abs(freshDiff) > 0.01) {
const adjResult = await putLedgerEntry(at, userKey, fresh, adjustFirstLineBody(fresh, freshDiff))
if (!adjResult.ok) {
console.error(`ABORT: method B also refused (HTTP ${adjResult.status}): ${adjResult.body}`)
process.exit(1)
}
const after = await fetchOne(at, userKey, fresh)
if (Math.abs(entrySum(after)) > 0.01) {
console.error(`ABORT: method B responded OK but voucher still sums to ${entrySum(after)}`)
process.exit(1)
}
method = 'adjust'
console.log('Method B (adjust first line) works — verified balanced via GET.')
}
}
// ── Mass run ─────────────────────────────────────────────────────
const failures: { voucher: string; status: number; body: string }[] = []
let done = 1
for (const v of unbalanced.slice(1)) {
await sleep(DELAY_MS)
const diff = entrySum(v)
const body = method === 'add' ? addLineBody(v, diff) : adjustFirstLineBody(v, diff)
const result = await putLedgerEntry(at, userKey, v, body)
if (!result.ok) {
failures.push({ voucher: `${v.journalId}${v.journalEntryId} (${v.journalEntryDate})`, status: result.status, body: result.body })
}
done++
if (done % 100 === 0) console.log(` ${done}/${unbalanced.length} (${failures.length} failed)`)
}
console.log(`\nDone: ${done - failures.length}/${unbalanced.length} balanced via method ${method}, ${failures.length} failed`)
if (failures.length > 0) {
console.log('Failures (first 20):')
for (const f of failures.slice(0, 20)) console.log(` ${f.voucher} — HTTP ${f.status} ${f.body}`)
}
console.log('\nRe-fetching SIE export to validate...')
const after = await fetchProviderSieFiles('bjornlunden', at, userKey)
for (const f of after.files) {
const parsed = parseSIEFile(f.rawContent)
const validation = validateSIEFile(parsed)
console.log(`fiscal year ${f.fiscalYear}: ${parsed.vouchers.length} vouchers, valid=${validation.valid}`)
if (validation.errors.length) console.log('remaining errors:\n- ' + validation.errors.slice(0, 5).join('\n- '))
}
}
main().catch((e) => {
console.error(e)
process.exit(1)
})
+145
View File
@@ -0,0 +1,145 @@
/**
* One-off cleanup: delete every broken voucher (empty or unbalanced) in the
* BL test company behind the given consent, via BL's
* DELETE /journal/entry/{journalId}/{journalEntryId}/{journalEntryDate}.
*
* Probes the first voucher and aborts if BL refuses the delete, then runs the
* full list at ~8 req/s and re-validates the SIE export at the end.
*
* Run: npx tsx --env-file=.env --env-file=.env.local scripts/bl-delete-broken.ts <consentId>
*/
import { createClient } from '@supabase/supabase-js'
import { fetchBjornLundenToken } from '@/lib/providers/bjornlunden/oauth'
import { fetchProviderSieFiles } from '@/extensions/general/arcim-migration/lib/sie-fetcher'
import { parseSIEFile, validateSIEFile } from '@/lib/import/sie-parser'
const BL_BASE_URL = 'https://apigateway.blinfo.se/bla-api/v1/sp'
const DELAY_MS = 125 // ~8 req/s, under BL's 10 req/s limit
const consentId = process.argv[2]
if (!consentId) {
console.error('Usage: npx tsx --env-file=.env --env-file=.env.local scripts/bl-delete-broken.ts <consentId>')
console.error('Refusing to run without an explicit consentId — this script deletes vouchers from a live BL company.')
process.exit(1)
}
interface BrokenVoucher {
series: string
number: number
date: string // yyyy-MM-dd as written in the SIE file
lineCount: number
diff: number
}
function isoLocal(d: Date): string {
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${y}-${m}-${day}`
}
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))
async function deleteVoucher(
accessToken: string,
userKey: string,
v: BrokenVoucher,
): Promise<{ ok: boolean; status: number; body: string }> {
const url = `${BL_BASE_URL}/journal/entry/${encodeURIComponent(v.series)}/${v.number}/${v.date}`
const response = await fetch(url, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${accessToken}`,
'User-Key': userKey,
},
signal: AbortSignal.timeout(30_000),
})
const body = response.ok ? '' : await response.text().catch(() => '')
return { ok: response.ok, status: response.status, body }
}
async function main() {
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!,
)
const { data: tokens, error } = await supabase
.from('provider_consent_tokens')
.select('provider_company_id')
.eq('consent_id', consentId)
.limit(1)
if (error || !tokens?.length) throw new Error(`no tokens for consent: ${error?.message ?? 'not found'}`)
const userKey = tokens[0]!.provider_company_id as string
const token = await fetchBjornLundenToken(
process.env.BJORN_LUNDEN_CLIENT_ID!,
process.env.BJORN_LUNDEN_CLIENT_SECRET!,
)
console.log('Fetching fresh SIE export to compute the broken-voucher list...')
const { files } = await fetchProviderSieFiles('bjornlunden', token.access_token, userKey)
const broken: BrokenVoucher[] = []
for (const f of files) {
const parsed = parseSIEFile(f.rawContent)
for (const v of parsed.vouchers) {
const diff = Math.round(v.lines.reduce((s, l) => s + l.amount, 0) * 100) / 100
if (Math.abs(diff) > 0.01 || v.lines.length === 0) {
broken.push({
series: v.series,
number: v.number,
date: isoLocal(v.date),
lineCount: v.lines.length,
diff,
})
}
}
}
console.log(`${broken.length} broken vouchers to delete`)
if (broken.length === 0) {
console.log('Nothing to do.')
return
}
// Probe with the first voucher — abort if BL refuses deletes
const probe = broken[0]!
console.log(`Probe delete: ${probe.series}${probe.number} (${probe.date})...`)
const probeResult = await deleteVoucher(token.access_token, userKey, probe)
if (!probeResult.ok) {
console.error(`ABORT: BL refused the probe delete (HTTP ${probeResult.status}): ${probeResult.body}`)
process.exit(1)
}
console.log('Probe OK — deleting the rest...')
const failures: { voucher: string; status: number; body: string }[] = []
let done = 1
for (const v of broken.slice(1)) {
await sleep(DELAY_MS)
const result = await deleteVoucher(token.access_token, userKey, v)
if (!result.ok) {
failures.push({ voucher: `${v.series}${v.number} (${v.date})`, status: result.status, body: result.body.slice(0, 200) })
}
done++
if (done % 100 === 0) console.log(` ${done}/${broken.length} (${failures.length} failed)`)
}
console.log(`\nDone: ${done - failures.length}/${broken.length} deleted, ${failures.length} failed`)
if (failures.length > 0) {
console.log('Failures (first 20):')
for (const f of failures.slice(0, 20)) console.log(` ${f.voucher} — HTTP ${f.status} ${f.body}`)
}
console.log('\nRe-fetching SIE export to validate...')
const after = await fetchProviderSieFiles('bjornlunden', token.access_token, userKey)
for (const f of after.files) {
const parsed = parseSIEFile(f.rawContent)
const validation = validateSIEFile(parsed)
console.log(`fiscal year ${f.fiscalYear}: ${parsed.vouchers.length} vouchers, valid=${validation.valid}`)
if (validation.errors.length) console.log('remaining errors:\n- ' + validation.errors.slice(0, 5).join('\n- '))
}
}
main().catch((e) => {
console.error(e)
process.exit(1)
})
+30
View File
@@ -0,0 +1,30 @@
import { createClient } from '@supabase/supabase-js'
async function main() {
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!,
)
const { data: consents, error } = await supabase
.from('provider_consents')
.select('id, provider, company_name, status, created_at')
.eq('provider', 'bjornlunden')
.order('created_at', { ascending: false })
.limit(5)
if (error) throw error
console.log(JSON.stringify(consents, null, 2))
for (const c of consents ?? []) {
const { data: tokens } = await supabase
.from('provider_consent_tokens')
.select('consent_id, provider_company_id, token_expires_at')
.eq('consent_id', c.id)
.limit(1)
console.log(`consent ${c.id}: tokens=${tokens?.length ? 'yes' : 'NO'} userKey=${tokens?.[0]?.provider_company_id?.slice(0, 8) ?? '-'}`)
}
}
main().catch((e) => {
console.error(e)
process.exit(1)
})
+102
View File
@@ -0,0 +1,102 @@
/**
* Temporary diagnostic: list every unbalanced voucher in the BL SIE export
* with its lines, write a CSV report, and print summary statistics.
* Run: npx tsx --env-file=.env --env-file=.env.local scripts/debug-bl-unbalanced.ts
*/
import { writeFileSync } from 'node:fs'
import { createClient } from '@supabase/supabase-js'
import { fetchBjornLundenToken } from '@/lib/providers/bjornlunden/oauth'
import { fetchProviderSieFiles } from '@/extensions/general/arcim-migration/lib/sie-fetcher'
import { parseSIEFile } from '@/lib/import/sie-parser'
const consentId = process.argv[2]
if (!consentId) {
console.error('Usage: npx tsx --env-file=.env --env-file=.env.local scripts/debug-bl-unbalanced.ts <consentId>')
console.error('Refusing to run without an explicit consentId — this script reads a live BL company and writes a report.')
process.exit(1)
}
function isoLocal(d: Date): string {
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${y}-${m}-${day}`
}
async function main() {
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!,
)
const { data: tokens, error } = await supabase
.from('provider_consent_tokens')
.select('provider_company_id')
.eq('consent_id', consentId)
.limit(1)
if (error || !tokens?.length) throw new Error(`no tokens for consent: ${error?.message ?? 'not found'}`)
const userKey = tokens[0]!.provider_company_id as string
const token = await fetchBjornLundenToken(
process.env.BJORN_LUNDEN_CLIENT_ID!,
process.env.BJORN_LUNDEN_CLIENT_SECRET!,
)
const { files } = await fetchProviderSieFiles('bjornlunden', token.access_token, userKey)
for (const f of files) {
const parsed = parseSIEFile(f.rawContent)
const bad = parsed.vouchers
.map((v) => ({
voucher: `${v.series}${v.number}`,
date: isoLocal(v.date),
description: v.description,
lineCount: v.lines.length,
diff: Math.round(v.lines.reduce((s, l) => s + l.amount, 0) * 100) / 100,
lines: v.lines.map((l) => `${l.account}:${l.amount}`).join(' '),
}))
.filter((v) => Math.abs(v.diff) > 0.01 || v.lineCount === 0)
const total = parsed.vouchers.length
const empty = bad.filter((v) => v.lineCount === 0).length
const single = bad.filter((v) => v.lineCount === 1).length
const multi = bad.filter((v) => v.lineCount > 1).length
console.log(`fiscal year ${f.fiscalYear}: ${total} vouchers total, ${bad.length} broken`)
console.log(` empty (0 lines): ${empty}`)
console.log(` one-sided (1 line): ${single}`)
console.log(` multi-line unbalanced: ${multi}`)
// Distribution of diffs to spot recurring patterns (e.g. 1.50 bank fees)
const byDiff = new Map<string, number>()
for (const v of bad.filter((b) => b.lineCount > 0)) {
const key = Math.abs(v.diff).toFixed(2)
byDiff.set(key, (byDiff.get(key) ?? 0) + 1)
}
const topDiffs = [...byDiff.entries()].sort((a, b) => b[1] - a[1]).slice(0, 10)
console.log(' most common |diff| amounts:')
for (const [amount, count] of topDiffs) console.log(` ${amount} kr × ${count}`)
// Per-series breakdown
const bySeries = new Map<string, number>()
for (const v of bad) {
const series = v.voucher.replace(/\d+$/, '')
bySeries.set(series, (bySeries.get(series) ?? 0) + 1)
}
console.log(' broken per series:', [...bySeries.entries()].map(([s, n]) => `${s}=${n}`).join(' '))
const csvEscape = (s: string) => `"${s.replace(/"/g, '""')}"`
const csv = [
'voucher;date;diff;line_count;description;lines',
...bad.map((v) =>
[v.voucher, v.date, v.diff.toFixed(2), v.lineCount, csvEscape(v.description), csvEscape(v.lines)].join(';'),
),
].join('\n')
const outPath = `scripts/bl-unbalanced-${f.fiscalYear}.csv`
writeFileSync(outPath, '' + csv, 'utf8')
console.log(` full list written to ${outPath}`)
}
}
main().catch((e) => {
console.error(e)
process.exit(1)
})
-289
View File
@@ -1,289 +0,0 @@
-- One-off BAS96 -> BAS2025 chart-of-accounts remap for Krister Sundling.
-- Run from the Supabase Studio SQL editor (Project Settings -> SQL Editor).
-- Equivalent of scripts/remap-krister-bas96-to-bas2025.ts but executed
-- entirely server-side, so it doesn't need the DB password.
--
-- Before running:
-- 1. Take a Supabase backup (Database -> Backups -> Create backup).
-- 2. Read this entire file. The identity check is at line ~50.
-- 3. Make sure no fiscal period is closed/locked (the script aborts if so).
--
-- Safety:
-- * Identity is hard-coded (ks@sundlingwarn.com + company name contains "sundling").
-- * The whole DO block is one transaction. Any RAISE EXCEPTION rolls back.
-- * Grand debit/credit invariant is checked at the end -- mismatch -> rollback.
-- * Only Krister's company_id is written to. Every UPDATE/INSERT/DELETE filters by it.
--
-- After running, watch the "Notices" panel below the editor for progress and the
-- final summary. If the DO block errors out, the whole transaction rolls back.
BEGIN;
DO $remap$
DECLARE
-- ────────────────────────────────────────────────────────────────
-- Hard-coded identity (no override). Aborts if either doesn't match.
-- ────────────────────────────────────────────────────────────────
v_expected_email constant text := 'ks@sundlingwarn.com';
v_expected_fragment constant text := 'cesu'; -- Krister's holding company: CeSu Invest AB
v_user_id uuid;
v_user_email text;
v_company_id uuid;
v_company_name text;
v_owner_count int;
-- counters
v_inserted_accounts int := 0;
v_updated_lines bigint := 0;
v_deleted_accounts int := 0;
v_locked_periods int;
v_old_id uuid;
v_target_id uuid;
v_line_count bigint;
-- invariants
v_debit_before numeric;
v_credit_before numeric;
v_debit_after numeric;
v_credit_after numeric;
m record; -- mapping iterator
BEGIN
PERFORM set_config('gnubok.allow_delete', 'true', true);
-- ────────────────────────────────────────────────────────────────
-- 1. Resolve user
-- ────────────────────────────────────────────────────────────────
SELECT id, email INTO v_user_id, v_user_email
FROM auth.users
WHERE LOWER(email) = LOWER(v_expected_email);
IF v_user_id IS NULL THEN
RAISE EXCEPTION 'No auth.users row for email %', v_expected_email;
END IF;
-- ────────────────────────────────────────────────────────────────
-- 2. Resolve company (owner/admin role)
-- ────────────────────────────────────────────────────────────────
SELECT COUNT(*) INTO v_owner_count
FROM public.company_members
WHERE user_id = v_user_id AND role IN ('owner', 'admin');
IF v_owner_count = 0 THEN
RAISE EXCEPTION 'User % owns/admins no companies', v_user_id;
ELSIF v_owner_count > 1 THEN
RAISE EXCEPTION
'User % owns/admins % companies -- this script supports exactly one. '
'Add a WHERE c.id = ''<uuid>'' filter below to pick one explicitly.',
v_user_id, v_owner_count;
END IF;
SELECT c.id, c.name INTO v_company_id, v_company_name
FROM public.companies c
JOIN public.company_members cm ON cm.company_id = c.id
WHERE cm.user_id = v_user_id AND cm.role IN ('owner', 'admin');
-- ────────────────────────────────────────────────────────────────
-- 3. Identity assertions (hard checks; no override)
-- ────────────────────────────────────────────────────────────────
IF LOWER(v_user_email) <> LOWER(v_expected_email) THEN
RAISE EXCEPTION 'Identity check FAILED: email % != expected %', v_user_email, v_expected_email;
END IF;
IF POSITION(LOWER(v_expected_fragment) IN LOWER(v_company_name)) = 0 THEN
RAISE EXCEPTION 'Identity check FAILED: company "%" does not contain "%"',
v_company_name, v_expected_fragment;
END IF;
RAISE NOTICE 'Resolved user : % (%)', v_user_email, v_user_id;
RAISE NOTICE 'Resolved company: % (%)', v_company_name, v_company_id;
-- ────────────────────────────────────────────────────────────────
-- 4. Period lock check (bypass GUC does NOT unlock periods)
-- ────────────────────────────────────────────────────────────────
SELECT COUNT(*) INTO v_locked_periods
FROM public.fiscal_periods
WHERE company_id = v_company_id AND (is_closed = true OR locked_at IS NOT NULL);
IF v_locked_periods > 0 THEN
RAISE EXCEPTION 'Refusing to run: % closed/locked fiscal periods exist for this company',
v_locked_periods;
END IF;
-- ────────────────────────────────────────────────────────────────
-- 5. Pre-flight grand totals (invariant)
-- ────────────────────────────────────────────────────────────────
SELECT COALESCE(SUM(l.debit_amount), 0), COALESCE(SUM(l.credit_amount), 0)
INTO v_debit_before, v_credit_before
FROM public.journal_entry_lines l
JOIN public.journal_entries je ON je.id = l.journal_entry_id
WHERE je.company_id = v_company_id;
RAISE NOTICE 'Pre-flight totals: debit=% credit=%', v_debit_before, v_credit_before;
-- ────────────────────────────────────────────────────────────────
-- 5b. Rename every source account to a __mig__ prefix so target lookups
-- can never collide with an empty source row (handles the 1360 swap:
-- old 1360 -> 1760 AND old 1630 -> new 1360 in the same run).
-- ────────────────────────────────────────────────────────────────
UPDATE public.chart_of_accounts
SET account_number = '__mig__' || account_number
WHERE company_id = v_company_id
AND account_number IN (
'1040','1050','1051','1052','1053','1055','1056','1060','1061',
'1210','1360','1623','1624','1625','1626','1627','1628','1629',
'1630','1631','1632','2210','2211','2330','2480','2510','2690',
'2864','2991','2992','2997','2999'
);
-- ────────────────────────────────────────────────────────────────
-- 6. Iterate mappings: INSERT target if missing, move lines, count.
-- ────────────────────────────────────────────────────────────────
FOR m IN
SELECT * FROM (VALUES
-- Bank och likvida medel
('1040', '1930', 'Företagskonto', 1, 'asset', 'debit', '19'),
('1050', '1940', 'Likviditetskonto', 1, 'asset', 'debit', '19'),
('1051', '1941', 'Valutakonto GBP', 1, 'asset', 'debit', '19'),
('1052', '1942', 'Valutakonto EUR', 1, 'asset', 'debit', '19'),
('1053', '1943', 'Fasträntekonto', 1, 'asset', 'debit', '19'),
('1055', '1944', 'Sparkonto SBAB', 1, 'asset', 'debit', '19'),
-- Värdepapper / placeringar
('1056', '1361', 'Depå Carnegie', 1, 'asset', 'debit', '13'),
('1060', '1385', 'Kapitalförsäkring (Avanza)', 1, 'asset', 'debit', '13'),
('1061', '1386', 'Kapitalförsäkring (Movestic)', 1, 'asset', 'debit', '13'),
('1210', '1510', 'Kundfordringar', 1, 'asset', 'debit', '15'),
('1360', '1760', 'Upplupna ränteintäkter', 1, 'asset', 'debit', '17'),
('1623', '1330', 'Andelar i intresseföretag', 1, 'asset', 'debit', '13'),
('1624', '1311', 'Andelar i dotterföretag — Divigen', 1, 'asset', 'debit', '13'),
('1625', '1350', 'Andelar i andra företag', 1, 'asset', 'debit', '13'),
('1626', '1351', 'Andelar i andra utländska företag', 1, 'asset', 'debit', '13'),
('1627', '1352', 'Andelar — Impilo', 1, 'asset', 'debit', '13'),
('1628', '1353', 'Andelar — Röko', 1, 'asset', 'debit', '13'),
('1629', '1354', 'Andelar — Altor V', 1, 'asset', 'debit', '13'),
('1630', '1360', 'Aktiefonder (HB Microcap)', 1, 'asset', 'debit', '13'),
('1631', '1355', 'Andelar — Altor VI', 1, 'asset', 'debit', '13'),
('1632', '1356', 'Andelar — Impilo Orphan', 1, 'asset', 'debit', '13'),
-- Skatt och moms (2210 + 2211 merged into 1630 Skattekonto)
('2210', '1630', 'Skattekonto', 1, 'asset', 'debit', '16'),
('2211', '1630', 'Skattekonto', 1, 'asset', 'debit', '16'),
('2330', '2941', 'Upplupna lagstadgade soc. avgifter', 2, 'liability', 'credit', '29'),
('2480', '2650', 'Redovisningskonto för moms', 2, 'liability', 'credit', '26'),
('2510', '2710', 'Personalens källskatt', 2, 'liability', 'credit', '27'),
-- Övriga skulder och reserver
('2690', '2890', 'Övriga kortfristiga skulder', 2, 'liability', 'credit', '28'),
('2864', '2126', 'Periodiseringsfond avsatt vid taxering 2026', 2, 'equity', 'credit', '21'),
-- Eget kapital
('2991', '2081', 'Aktiekapital', 2, 'equity', 'credit', '20'),
('2992', '2086', 'Reservfond', 2, 'equity', 'credit', '20'),
('2997', '2091', 'Balanserat resultat', 2, 'equity', 'credit', '20'),
('2999', '2099', 'Årets resultat', 2, 'equity', 'credit', '20')
) AS t(old_number, new_number, new_name, account_class, account_type, normal_balance, account_group)
LOOP
-- Find existing old account (now under the __mig__ prefix)
SELECT id INTO v_old_id
FROM public.chart_of_accounts
WHERE company_id = v_company_id AND account_number = '__mig__' || m.old_number;
IF v_old_id IS NULL THEN
RAISE NOTICE ' skip %: old account not in chart (already migrated?)', m.old_number;
CONTINUE;
END IF;
-- Find existing target account, or INSERT it
SELECT id INTO v_target_id
FROM public.chart_of_accounts
WHERE company_id = v_company_id AND account_number = m.new_number;
IF v_target_id IS NULL THEN
INSERT INTO public.chart_of_accounts
(user_id, company_id, account_number, account_name, account_class,
account_group, account_type, normal_balance, plan_type, is_active, is_system_account)
VALUES
(v_user_id, v_company_id, m.new_number, m.new_name, m.account_class,
m.account_group, m.account_type, m.normal_balance, 'full_bas', true, false)
RETURNING id INTO v_target_id;
v_inserted_accounts := v_inserted_accounts + 1;
RAISE NOTICE ' insert account % %', m.new_number, m.new_name;
END IF;
-- Idempotent no-op
IF v_target_id = v_old_id THEN
CONTINUE;
END IF;
-- Move lines old -> target (scoped by parent journal_entries.company_id)
WITH moved AS (
UPDATE public.journal_entry_lines l
SET account_id = v_target_id, account_number = m.new_number
FROM public.journal_entries je
WHERE l.journal_entry_id = je.id
AND je.company_id = v_company_id
AND l.account_id = v_old_id
RETURNING l.id
)
SELECT COUNT(*) INTO v_line_count FROM moved;
v_updated_lines := v_updated_lines + v_line_count;
RAISE NOTICE ' remap % -> % (% lines moved)', m.old_number, m.new_number, v_line_count;
END LOOP;
-- ────────────────────────────────────────────────────────────────
-- 7. (skipped) account_balances was dropped in migration
-- 20240101000027_drop_unused_module_tables.sql -- no cache to clean.
-- ────────────────────────────────────────────────────────────────
-- ────────────────────────────────────────────────────────────────
-- 8. Delete the __mig__-prefixed source rows now that they have no lines.
-- Safety: refuses to delete if any line still references one.
-- ────────────────────────────────────────────────────────────────
FOR m IN
SELECT id, account_number
FROM public.chart_of_accounts
WHERE company_id = v_company_id
AND LEFT(account_number, 7) = '__mig__'
LOOP
SELECT COUNT(*) INTO v_line_count
FROM public.journal_entry_lines l
JOIN public.journal_entries je ON je.id = l.journal_entry_id
WHERE l.account_id = m.id AND je.company_id = v_company_id;
IF v_line_count <> 0 THEN
RAISE EXCEPTION 'Refusing to delete migrate-source account % (%) -- % lines still reference it',
m.account_number, m.id, v_line_count;
END IF;
DELETE FROM public.chart_of_accounts WHERE id = m.id AND company_id = v_company_id;
v_deleted_accounts := v_deleted_accounts + 1;
END LOOP;
-- ────────────────────────────────────────────────────────────────
-- 9. Post-flight invariant check
-- ────────────────────────────────────────────────────────────────
SELECT COALESCE(SUM(l.debit_amount), 0), COALESCE(SUM(l.credit_amount), 0)
INTO v_debit_after, v_credit_after
FROM public.journal_entry_lines l
JOIN public.journal_entries je ON je.id = l.journal_entry_id
WHERE je.company_id = v_company_id;
IF v_debit_before <> v_debit_after OR v_credit_before <> v_credit_after THEN
RAISE EXCEPTION
'INVARIANT BROKEN: grand debit/credit totals diverged. '
'Before D=% C=%, After D=% C=%. Rolling back.',
v_debit_before, v_credit_before, v_debit_after, v_credit_after;
END IF;
RAISE NOTICE '─────────────────────────────────────────';
RAISE NOTICE 'Done.';
RAISE NOTICE ' Inserted accounts : %', v_inserted_accounts;
RAISE NOTICE ' Updated lines : %', v_updated_lines;
RAISE NOTICE ' Deleted accounts : %', v_deleted_accounts;
RAISE NOTICE ' Grand totals OK : debit=% credit=%', v_debit_after, v_credit_after;
RAISE NOTICE '─────────────────────────────────────────';
END
$remap$;
-- Change the next line to ROLLBACK for a dry run, COMMIT to apply.
COMMIT;
-575
View File
@@ -1,575 +0,0 @@
#!/usr/bin/env npx tsx
/**
* One-off BAS96 -> BAS2025 chart-of-accounts remap for Krister Sundling.
*
* Context: Krister imported a SIE from SPCS into gnubok. Balances are correct
* but account numbers use BAS96, which gnubok's reports interpret against
* BAS2025 -- so classification in BR/RR is wrong. Krister only has IB data
* and is travelling, giving us a clean window to fix the chart before he
* enters real vouchers.
*
* Strategy (UUID-based, no UPDATE on chart_of_accounts.account_number):
* 1. Resolve the company from auth.users by email + company_members.
* 2. Hard-check the resolved company's name contains EXPECTED_COMPANY_NAME_FRAGMENT.
* 3. Snapshot chart_of_accounts; build (oldId -> targetId) plan, inserting
* target rows where missing.
* 4. In one transaction with SET LOCAL gnubok.allow_delete='true':
* - INSERT new chart_of_accounts rows for target numbers that don't
* exist yet.
* - UPDATE journal_entry_lines.account_id/account_number from old UUIDs
* to target UUIDs.
* - DELETE old chart_of_accounts rows that no longer have lines.
* 5. Pre/post per-account-class debit/credit totals must match.
*
* Why pg directly: the immutability bypass GUC is transaction-local
* (current_setting('gnubok.allow_delete', true)). supabase-js issues
* each call on its own pooled connection, so the flag wouldn't persist.
*
* Usage:
* DATABASE_URL=postgresql://... npx tsx scripts/remap-krister-bas96-to-bas2025.ts # dry run
* DATABASE_URL=postgresql://... npx tsx scripts/remap-krister-bas96-to-bas2025.ts --commit # apply
* Flags: --email <addr> overrides the default, --company-id <uuid> picks
* one when the user owns multiple companies.
*/
import { config } from 'dotenv'
config({ path: '.env.local' })
import { Pool, type PoolClient } from 'pg'
import readline from 'node:readline/promises'
import { stdin as input, stdout as output } from 'node:process'
// ──────────────────────────────────────────────────────────────────
// Hard-coded identity. The script aborts if these don't match.
// No --force, no override.
// ──────────────────────────────────────────────────────────────────
const EXPECTED_EMAIL = 'ks@sundlingwarn.com'
const EXPECTED_COMPANY_NAME_FRAGMENT = 'cesu' // Krister's holding company: CeSu Invest AB
const CONFIRM_PHRASE = 'remap krister'
// ──────────────────────────────────────────────────────────────────
// Mapping (BAS96 -> BAS2025), agreed with Krister 2026-05-15.
// Order does not matter -- mapping is keyed by old account UUID.
// ──────────────────────────────────────────────────────────────────
type AccountType = 'asset' | 'equity' | 'liability' | 'revenue' | 'expense'
type NormalBalance = 'debit' | 'credit'
interface Mapping {
oldNumber: string
newNumber: string
newName: string
accountClass: number
accountType: AccountType
normalBalance: NormalBalance
accountGroup: string | null
}
const MAPPINGS: ReadonlyArray<Mapping> = [
// Bank och likvida medel
{ oldNumber: '1040', newNumber: '1930', newName: 'Företagskonto', accountClass: 1, accountType: 'asset', normalBalance: 'debit', accountGroup: '19' },
{ oldNumber: '1050', newNumber: '1940', newName: 'Likviditetskonto', accountClass: 1, accountType: 'asset', normalBalance: 'debit', accountGroup: '19' },
{ oldNumber: '1051', newNumber: '1941', newName: 'Valutakonto GBP', accountClass: 1, accountType: 'asset', normalBalance: 'debit', accountGroup: '19' },
{ oldNumber: '1052', newNumber: '1942', newName: 'Valutakonto EUR', accountClass: 1, accountType: 'asset', normalBalance: 'debit', accountGroup: '19' },
{ oldNumber: '1053', newNumber: '1943', newName: 'Fasträntekonto', accountClass: 1, accountType: 'asset', normalBalance: 'debit', accountGroup: '19' },
{ oldNumber: '1055', newNumber: '1944', newName: 'Sparkonto SBAB', accountClass: 1, accountType: 'asset', normalBalance: 'debit', accountGroup: '19' },
// Värdepapper och placeringar
{ oldNumber: '1056', newNumber: '1361', newName: 'Depå Carnegie', accountClass: 1, accountType: 'asset', normalBalance: 'debit', accountGroup: '13' },
{ oldNumber: '1060', newNumber: '1385', newName: 'Kapitalförsäkring (Avanza)', accountClass: 1, accountType: 'asset', normalBalance: 'debit', accountGroup: '13' },
{ oldNumber: '1061', newNumber: '1386', newName: 'Kapitalförsäkring (Movestic)', accountClass: 1, accountType: 'asset', normalBalance: 'debit', accountGroup: '13' },
{ oldNumber: '1210', newNumber: '1510', newName: 'Kundfordringar', accountClass: 1, accountType: 'asset', normalBalance: 'debit', accountGroup: '15' },
{ oldNumber: '1360', newNumber: '1760', newName: 'Upplupna ränteintäkter', accountClass: 1, accountType: 'asset', normalBalance: 'debit', accountGroup: '17' },
{ oldNumber: '1623', newNumber: '1330', newName: 'Andelar i intresseföretag', accountClass: 1, accountType: 'asset', normalBalance: 'debit', accountGroup: '13' },
{ oldNumber: '1624', newNumber: '1311', newName: 'Andelar i dotterföretag — Divigen', accountClass: 1, accountType: 'asset', normalBalance: 'debit', accountGroup: '13' },
{ oldNumber: '1625', newNumber: '1350', newName: 'Andelar i andra företag', accountClass: 1, accountType: 'asset', normalBalance: 'debit', accountGroup: '13' },
{ oldNumber: '1626', newNumber: '1351', newName: 'Andelar i andra utländska företag', accountClass: 1, accountType: 'asset', normalBalance: 'debit', accountGroup: '13' },
{ oldNumber: '1627', newNumber: '1352', newName: 'Andelar — Impilo', accountClass: 1, accountType: 'asset', normalBalance: 'debit', accountGroup: '13' },
{ oldNumber: '1628', newNumber: '1353', newName: 'Andelar — Röko', accountClass: 1, accountType: 'asset', normalBalance: 'debit', accountGroup: '13' },
{ oldNumber: '1629', newNumber: '1354', newName: 'Andelar — Altor V', accountClass: 1, accountType: 'asset', normalBalance: 'debit', accountGroup: '13' },
{ oldNumber: '1630', newNumber: '1360', newName: 'Aktiefonder (HB Microcap)', accountClass: 1, accountType: 'asset', normalBalance: 'debit', accountGroup: '13' },
{ oldNumber: '1631', newNumber: '1355', newName: 'Andelar — Altor VI', accountClass: 1, accountType: 'asset', normalBalance: 'debit', accountGroup: '13' },
{ oldNumber: '1632', newNumber: '1356', newName: 'Andelar — Impilo Orphan', accountClass: 1, accountType: 'asset', normalBalance: 'debit', accountGroup: '13' },
// Skatt och moms (merge: 2210 + 2211 -> 1630 Skattekonto)
{ oldNumber: '2210', newNumber: '1630', newName: 'Skattekonto', accountClass: 1, accountType: 'asset', normalBalance: 'debit', accountGroup: '16' },
{ oldNumber: '2211', newNumber: '1630', newName: 'Skattekonto', accountClass: 1, accountType: 'asset', normalBalance: 'debit', accountGroup: '16' },
{ oldNumber: '2330', newNumber: '2941', newName: 'Upplupna lagstadgade soc. avgifter', accountClass: 2, accountType: 'liability', normalBalance: 'credit', accountGroup: '29' },
{ oldNumber: '2480', newNumber: '2650', newName: 'Redovisningskonto för moms', accountClass: 2, accountType: 'liability', normalBalance: 'credit', accountGroup: '26' },
{ oldNumber: '2510', newNumber: '2710', newName: 'Personalens källskatt', accountClass: 2, accountType: 'liability', normalBalance: 'credit', accountGroup: '27' },
// Övriga skulder och reserver
{ oldNumber: '2690', newNumber: '2890', newName: 'Övriga kortfristiga skulder', accountClass: 2, accountType: 'liability', normalBalance: 'credit', accountGroup: '28' },
{ oldNumber: '2864', newNumber: '2126', newName: 'Periodiseringsfond avsatt vid taxering 2026', accountClass: 2, accountType: 'equity', normalBalance: 'credit', accountGroup: '21' },
// Eget kapital
{ oldNumber: '2991', newNumber: '2081', newName: 'Aktiekapital', accountClass: 2, accountType: 'equity', normalBalance: 'credit', accountGroup: '20' },
{ oldNumber: '2992', newNumber: '2086', newName: 'Reservfond', accountClass: 2, accountType: 'equity', normalBalance: 'credit', accountGroup: '20' },
{ oldNumber: '2997', newNumber: '2091', newName: 'Balanserat resultat', accountClass: 2, accountType: 'equity', normalBalance: 'credit', accountGroup: '20' },
{ oldNumber: '2999', newNumber: '2099', newName: 'Årets resultat', accountClass: 2, accountType: 'equity', normalBalance: 'credit', accountGroup: '20' },
]
// ──────────────────────────────────────────────────────────────────
// Args
// ──────────────────────────────────────────────────────────────────
function arg(name: string): string | undefined {
const i = process.argv.indexOf(`--${name}`)
return i >= 0 ? process.argv[i + 1] : undefined
}
const COMMIT = process.argv.includes('--commit')
const EMAIL_OVERRIDE = arg('email')
const COMPANY_ID_OVERRIDE = arg('company-id')
const databaseUrl = process.env.DATABASE_URL
if (!databaseUrl) {
console.error(
'Missing DATABASE_URL. Set it to the Supabase Postgres connection string ' +
'(Project Settings -> Database -> Connection string -> URI, with the service password).',
)
process.exit(1)
}
const targetEmail = EMAIL_OVERRIDE ?? EXPECTED_EMAIL
// ──────────────────────────────────────────────────────────────────
// Identity resolution
// ──────────────────────────────────────────────────────────────────
interface UserRow { id: string; email: string }
interface CompanyRow { id: string; name: string; entity_type: string | null }
async function resolveUser(client: PoolClient): Promise<UserRow> {
const res = await client.query<UserRow>(
`SELECT id, email FROM auth.users WHERE LOWER(email) = LOWER($1) LIMIT 2`,
[targetEmail],
)
if (res.rows.length === 0) throw new Error(`No auth.users row for email ${targetEmail}`)
if (res.rows.length > 1) throw new Error(`Multiple auth.users rows for email ${targetEmail} -- aborting`)
return res.rows[0]
}
async function resolveCompany(client: PoolClient, userId: string): Promise<CompanyRow> {
const res = await client.query<CompanyRow>(
`SELECT c.id, c.name, c.entity_type
FROM public.companies c
JOIN public.company_members cm ON cm.company_id = c.id
WHERE cm.user_id = $1 AND cm.role IN ('owner', 'admin')
ORDER BY c.created_at ASC`,
[userId],
)
if (res.rows.length === 0) {
throw new Error(`User ${userId} owns/admins no companies`)
}
if (COMPANY_ID_OVERRIDE) {
const pick = res.rows.find(r => r.id === COMPANY_ID_OVERRIDE)
if (!pick) {
throw new Error(
`--company-id ${COMPANY_ID_OVERRIDE} is not among this user's owned companies: ` +
res.rows.map(r => `${r.id} (${r.name})`).join(', '),
)
}
return pick
}
if (res.rows.length > 1) {
const list = res.rows.map(r => ` ${r.id} ${r.name}`).join('\n')
throw new Error(
`User ${userId} owns/admins multiple companies -- pick one with --company-id <uuid>:\n${list}`,
)
}
return res.rows[0]
}
function assertIdentity(user: UserRow, company: CompanyRow): void {
if (user.email.toLowerCase() !== EXPECTED_EMAIL.toLowerCase()) {
throw new Error(
`Identity check FAILED: resolved user email ${user.email} != expected ${EXPECTED_EMAIL}`,
)
}
if (!company.name.toLowerCase().includes(EXPECTED_COMPANY_NAME_FRAGMENT.toLowerCase())) {
throw new Error(
`Identity check FAILED: resolved company "${company.name}" does not contain "${EXPECTED_COMPANY_NAME_FRAGMENT}"`,
)
}
}
// ──────────────────────────────────────────────────────────────────
// Plan construction
// ──────────────────────────────────────────────────────────────────
interface AccountSnapshotRow {
id: string
account_number: string
account_name: string
account_class: number
account_type: AccountType
normal_balance: NormalBalance
}
interface PlanItem {
mapping: Mapping
oldId: string
targetId: string | null // null means INSERT new row
targetExisted: boolean // true if a row with newNumber already existed
lineCountEstimate: number // # of journal_entry_lines that will be moved
}
async function snapshotAccounts(client: PoolClient, companyId: string): Promise<Map<string, AccountSnapshotRow>> {
const res = await client.query<AccountSnapshotRow>(
`SELECT id, account_number, account_name, account_class, account_type, normal_balance
FROM public.chart_of_accounts
WHERE company_id = $1`,
[companyId],
)
const map = new Map<string, AccountSnapshotRow>()
for (const r of res.rows) map.set(r.account_number, r)
return map
}
async function countLines(client: PoolClient, accountId: string, companyId: string): Promise<number> {
// Scope by company_id via parent journal_entries to defend against any
// accidental cross-tenant account_id reuse (should be impossible since
// UUIDs are unique, but a free defense-in-depth check).
const res = await client.query<{ n: string }>(
`SELECT COUNT(*)::text AS n
FROM public.journal_entry_lines l
JOIN public.journal_entries je ON je.id = l.journal_entry_id
WHERE l.account_id = $1 AND je.company_id = $2`,
[accountId, companyId],
)
return Number(res.rows[0]?.n ?? '0')
}
async function buildPlan(client: PoolClient, companyId: string): Promise<PlanItem[]> {
const snapshot = await snapshotAccounts(client, companyId)
const items: PlanItem[] = []
for (const m of MAPPINGS) {
const src = snapshot.get(m.oldNumber)
if (!src) continue // already migrated or never existed
const tgt = snapshot.get(m.newNumber)
const lineCount = await countLines(client, src.id, companyId)
items.push({
mapping: m,
oldId: src.id,
targetId: tgt?.id ?? null,
targetExisted: !!tgt,
lineCountEstimate: lineCount,
})
}
return items
}
// ──────────────────────────────────────────────────────────────────
// Pre/post invariants. The remap reclassifies accounts (that's the whole
// point), so per-class sums shift -- the merge 2210+2211 -> 1630 moves
// money from class 2 to class 1. The invariant that MUST hold is the
// company-wide debit/credit sum: the script never touches debit_amount
// or credit_amount on any line, so those sums must be byte-identical
// before/after. Per-class breakdown is informational.
// ──────────────────────────────────────────────────────────────────
interface GrandTotal { total_debit: string; total_credit: string }
interface ClassTotal { account_class: number; account_number: string | null; total_debit: string; total_credit: string }
async function grandTotals(client: PoolClient, companyId: string): Promise<GrandTotal> {
const res = await client.query<GrandTotal>(
`SELECT COALESCE(SUM(l.debit_amount), 0)::text AS total_debit,
COALESCE(SUM(l.credit_amount), 0)::text AS total_credit
FROM public.journal_entry_lines l
JOIN public.journal_entries je ON je.id = l.journal_entry_id
WHERE je.company_id = $1`,
[companyId],
)
return res.rows[0]
}
async function classTotals(client: PoolClient, companyId: string): Promise<ClassTotal[]> {
const res = await client.query<ClassTotal>(
`SELECT coa.account_class,
NULL::text AS account_number,
COALESCE(SUM(l.debit_amount), 0)::text AS total_debit,
COALESCE(SUM(l.credit_amount), 0)::text AS total_credit
FROM public.journal_entry_lines l
JOIN public.journal_entries je ON je.id = l.journal_entry_id
JOIN public.chart_of_accounts coa ON coa.id = l.account_id
WHERE je.company_id = $1
GROUP BY coa.account_class
ORDER BY coa.account_class`,
[companyId],
)
return res.rows
}
function grandTotalsEqual(a: GrandTotal, b: GrandTotal): boolean {
return a.total_debit === b.total_debit && a.total_credit === b.total_credit
}
// ──────────────────────────────────────────────────────────────────
// Period lock pre-flight
// ──────────────────────────────────────────────────────────────────
async function assertNoLockedPeriods(client: PoolClient, companyId: string): Promise<void> {
const res = await client.query<{ name: string; is_closed: boolean; locked_at: string | null }>(
`SELECT name, is_closed, locked_at::text
FROM public.fiscal_periods
WHERE company_id = $1 AND (is_closed = true OR locked_at IS NOT NULL)`,
[companyId],
)
if (res.rows.length > 0) {
const list = res.rows.map(r => ` ${r.name} (closed=${r.is_closed}, locked_at=${r.locked_at ?? '—'})`).join('\n')
throw new Error(
`Refusing to run: ${res.rows.length} fiscal_periods are closed/locked. ` +
`gnubok.allow_delete does not bypass period locks. Unlock first, or escalate:\n${list}`,
)
}
}
// ──────────────────────────────────────────────────────────────────
// Plan execution (inside one transaction)
// ──────────────────────────────────────────────────────────────────
interface ExecResult {
inserted: number
updatedLines: number
deletedAccounts: number
newTargetIds: Map<string, string> // newNumber -> id
}
async function executePlan(
client: PoolClient,
companyId: string,
ownerUserId: string,
plan: PlanItem[],
): Promise<ExecResult> {
await client.query("SELECT set_config('gnubok.allow_delete', 'true', true)")
const result: ExecResult = { inserted: 0, updatedLines: 0, deletedAccounts: 0, newTargetIds: new Map() }
// Phase 1: INSERT all missing target accounts, dedup by newNumber.
const newNumbersNeeded = new Map<string, Mapping>()
for (const p of plan) {
if (!p.targetExisted && !newNumbersNeeded.has(p.mapping.newNumber)) {
newNumbersNeeded.set(p.mapping.newNumber, p.mapping)
}
}
for (const [, m] of newNumbersNeeded) {
const ins = await client.query<{ id: string }>(
`INSERT INTO public.chart_of_accounts (
user_id, company_id, account_number, account_name, account_class,
account_group, account_type, normal_balance, plan_type, is_active, is_system_account
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'full_bas', true, false)
RETURNING id`,
[ownerUserId, companyId, m.newNumber, m.newName, m.accountClass, m.accountGroup, m.accountType, m.normalBalance],
)
result.newTargetIds.set(m.newNumber, ins.rows[0].id)
result.inserted++
}
// Phase 2: resolve every plan item's final targetId.
for (const p of plan) {
if (!p.targetId) {
const inserted = result.newTargetIds.get(p.mapping.newNumber)
if (!inserted) throw new Error(`Internal: no inserted id for new account ${p.mapping.newNumber}`)
p.targetId = inserted
}
}
// Phase 3: move journal lines from old account_id -> targetId.
for (const p of plan) {
if (!p.targetId) throw new Error('Internal: missing targetId')
if (p.targetId === p.oldId) continue // idempotent no-op
// Defense-in-depth: confirm old account still belongs to this company.
const own = await client.query<{ n: string }>(
`SELECT COUNT(*)::text AS n FROM public.chart_of_accounts WHERE id = $1 AND company_id = $2`,
[p.oldId, companyId],
)
if (Number(own.rows[0].n) !== 1) {
throw new Error(
`Pre-write check failed: old account ${p.oldId} (${p.mapping.oldNumber}) not owned by company ${companyId}`,
)
}
const upd = await client.query<{ id: string }>(
`UPDATE public.journal_entry_lines AS l
SET account_id = $1, account_number = $2
FROM public.journal_entries AS je
WHERE l.journal_entry_id = je.id
AND je.company_id = $3
AND l.account_id = $4
RETURNING l.id`,
[p.targetId, p.mapping.newNumber, companyId, p.oldId],
)
result.updatedLines += upd.rowCount ?? 0
}
// Phase 4: account_balances was dropped in migration
// 20240101000027_drop_unused_module_tables.sql -- no cache to clean.
// Phase 5: delete old accounts that no longer have any lines.
const oldIds = Array.from(new Set(plan.map(p => p.oldId)))
for (const oldId of oldIds) {
const remaining = await client.query<{ n: string }>(
`SELECT COUNT(*)::text AS n
FROM public.journal_entry_lines l
JOIN public.journal_entries je ON je.id = l.journal_entry_id
WHERE l.account_id = $1 AND je.company_id = $2`,
[oldId, companyId],
)
if (Number(remaining.rows[0].n) !== 0) {
throw new Error(`Refusing to delete account ${oldId}: ${remaining.rows[0].n} lines still reference it`)
}
const del = await client.query(
`DELETE FROM public.chart_of_accounts WHERE id = $1 AND company_id = $2`,
[oldId, companyId],
)
result.deletedAccounts += del.rowCount ?? 0
}
return result
}
// ──────────────────────────────────────────────────────────────────
// Pretty-print plan
// ──────────────────────────────────────────────────────────────────
function printPlan(plan: PlanItem[]): void {
console.log('\nRemap plan:')
const renames = plan.filter(p => p.mapping.oldNumber !== p.mapping.newNumber)
const merges = new Map<string, PlanItem[]>()
for (const p of plan) {
const k = p.mapping.newNumber
if (!merges.has(k)) merges.set(k, [])
merges.get(k)!.push(p)
}
const lineWidth = 6
for (const p of renames) {
const tag = p.targetExisted ? 'merge into existing' : 'rename'
console.log(
` ${p.mapping.oldNumber.padEnd(lineWidth)} ` +
`-> ${p.mapping.newNumber.padEnd(lineWidth)} ` +
`${p.mapping.newName.padEnd(48)} ` +
`(${p.lineCountEstimate} lines, ${tag})`,
)
}
const mergeTargets = [...merges.entries()].filter(([, items]) => items.length > 1)
if (mergeTargets.length > 0) {
console.log('\nMerges (multiple old -> one new):')
for (const [newNumber, items] of mergeTargets) {
console.log(` ${items.map(i => i.mapping.oldNumber).join(' + ')} -> ${newNumber}`)
}
}
const newAccounts = new Set(plan.filter(p => !p.targetExisted).map(p => p.mapping.newNumber))
if (newAccounts.size > 0) {
console.log(`\nNew chart_of_accounts rows to insert: ${newAccounts.size}`)
for (const n of [...newAccounts].sort()) {
const m = plan.find(p => p.mapping.newNumber === n)!.mapping
console.log(` ${n} ${m.newName}`)
}
}
}
// ──────────────────────────────────────────────────────────────────
// Main
// ──────────────────────────────────────────────────────────────────
async function main() {
const pool = new Pool({ connectionString: databaseUrl, max: 2 })
const client = await pool.connect()
try {
console.log('─────────────────────────────────────────────────────────')
console.log('BAS96 -> BAS2025 remap (one-off, Krister Sundling)')
console.log('─────────────────────────────────────────────────────────')
console.log('Mode :', COMMIT ? 'COMMIT (writes)' : 'DRY RUN (no writes)')
console.log('Email:', targetEmail)
const user = await resolveUser(client)
const company = await resolveCompany(client, user.id)
assertIdentity(user, company)
console.log('User :', `${user.email} (${user.id})`)
console.log('Co. :', `${company.name} (${company.id}, ${company.entity_type ?? '?'})`)
await assertNoLockedPeriods(client, company.id)
const plan = await buildPlan(client, company.id)
if (plan.length === 0) {
console.log('\nNothing to remap -- no BAS96 source accounts found. (Already migrated?)')
return
}
printPlan(plan)
const totalLines = plan.reduce((n, p) => n + p.lineCountEstimate, 0)
console.log(`\nTotal journal_entry_lines that will move: ${totalLines}`)
const grandBefore = await grandTotals(client, company.id)
console.log(`\nPre-flight grand totals (must be unchanged by remap):`)
console.log(` total_debit=${grandBefore.total_debit} total_credit=${grandBefore.total_credit}`)
console.log(`\nPre-flight per-class totals (these WILL shift as accounts are reclassified):`)
const classBefore = await classTotals(client, company.id)
for (const r of classBefore) {
console.log(` class ${r.account_class}: debit=${r.total_debit} credit=${r.total_credit}`)
}
if (!COMMIT) {
console.log('\n[dry-run] No changes made. Re-run with --commit to apply.')
return
}
const rl = readline.createInterface({ input, output })
const phrase = await rl.question(
`\nAbout to apply the remap above for ${company.name} (${company.id}).\n` +
`Type '${CONFIRM_PHRASE}' to proceed: `,
)
rl.close()
if (phrase.trim().toLowerCase() !== CONFIRM_PHRASE) {
console.log('Confirmation phrase did not match. Aborting.')
process.exit(1)
}
// Single transaction: bypass flag is transaction-local.
await client.query('BEGIN')
let result: ExecResult
try {
result = await executePlan(client, company.id, user.id, plan)
const grandAfter = await grandTotals(client, company.id)
console.log(`\nPost-flight grand totals (still inside transaction):`)
console.log(` total_debit=${grandAfter.total_debit} total_credit=${grandAfter.total_credit}`)
if (!grandTotalsEqual(grandBefore, grandAfter)) {
throw new Error(
`INVARIANT BROKEN: grand debit/credit sums diverge after remap. ` +
`Before debit=${grandBefore.total_debit} credit=${grandBefore.total_credit}; ` +
`After debit=${grandAfter.total_debit} credit=${grandAfter.total_credit}. Rolling back.`,
)
}
const classAfter = await classTotals(client, company.id)
console.log('\nPost-flight per-class totals (reclassified -- shifts expected):')
for (const r of classAfter) {
console.log(` class ${r.account_class}: debit=${r.total_debit} credit=${r.total_credit}`)
}
await client.query('COMMIT')
} catch (err) {
await client.query('ROLLBACK').catch(() => {})
throw err
}
console.log('\n─────────────────────────────────────────────────────────')
console.log('Done.')
console.log('─────────────────────────────────────────────────────────')
console.log(`Inserted accounts : ${result.inserted}`)
console.log(`Updated lines : ${result.updatedLines}`)
console.log(`Deleted accounts : ${result.deletedAccounts}`)
console.log('\nNext: open the balance sheet and trial balance in gnubok as Krister to confirm classification.')
} finally {
client.release()
await pool.end()
}
}
main().catch(err => {
console.error('\nFATAL:', err instanceof Error ? err.message : err)
process.exit(1)
})
-103
View File
@@ -1,103 +0,0 @@
-- Verification queries for the BAS96 -> BAS2025 remap on CeSu Invest AB.
-- Run each block separately in the Supabase SQL editor, or all together
-- and click through the result tabs.
-- ────────────────────────────────────────────────────────────────
-- 1. No leftover __mig__ rows? (should return 0)
-- ────────────────────────────────────────────────────────────────
SELECT COUNT(*) AS mig_rows_remaining
FROM public.chart_of_accounts coa
JOIN public.companies c ON c.id = coa.company_id
WHERE c.name = 'CeSu Invest AB'
AND LEFT(coa.account_number, 7) = '__mig__';
-- ────────────────────────────────────────────────────────────────
-- 2. No leftover BAS96 numbers in chart_of_accounts? (should return 0)
-- ────────────────────────────────────────────────────────────────
SELECT coa.account_number, coa.account_name
FROM public.chart_of_accounts coa
JOIN public.companies c ON c.id = coa.company_id
WHERE c.name = 'CeSu Invest AB'
AND coa.account_number IN (
'1040','1050','1051','1052','1053','1055','1056','1060','1061',
'1210','1623','1624','1625','1626','1627','1628','1629',
'1631','1632','2210','2211','2330','2480','2510','2690',
'2864','2991','2992','2997','2999'
);
-- (Note: 1360 and 1630 are intentionally OMITTED here because they exist
-- as legitimate BAS2025 targets after the remap.)
-- ────────────────────────────────────────────────────────────────
-- 3. No leftover BAS96 numbers in journal_entry_lines? (should return 0)
-- ────────────────────────────────────────────────────────────────
SELECT l.account_number, COUNT(*) AS line_count
FROM public.journal_entry_lines l
JOIN public.journal_entries je ON je.id = l.journal_entry_id
JOIN public.companies c ON c.id = je.company_id
WHERE c.name = 'CeSu Invest AB'
AND l.account_number IN (
'1040','1050','1051','1052','1053','1055','1056','1060','1061',
'1210','1623','1624','1625','1626','1627','1628','1629',
'1631','1632','2210','2211','2330','2480','2510','2690',
'2864','2991','2992','2997','2999'
)
GROUP BY l.account_number;
-- ────────────────────────────────────────────────────────────────
-- 4. account_id <-> account_number consistency on every line.
-- Should return 0 -- every line's account_number must match the
-- chart_of_accounts row it points to.
-- ────────────────────────────────────────────────────────────────
SELECT COUNT(*) AS mismatched_lines
FROM public.journal_entry_lines l
JOIN public.journal_entries je ON je.id = l.journal_entry_id
JOIN public.companies c ON c.id = je.company_id
JOIN public.chart_of_accounts coa ON coa.id = l.account_id
WHERE c.name = 'CeSu Invest AB'
AND coa.account_number <> l.account_number;
-- ────────────────────────────────────────────────────────────────
-- 5. Grand totals -- debits = credits and look plausible.
-- ────────────────────────────────────────────────────────────────
SELECT
SUM(l.debit_amount) AS total_debit,
SUM(l.credit_amount) AS total_credit,
SUM(l.debit_amount) - SUM(l.credit_amount) AS debit_minus_credit
FROM public.journal_entry_lines l
JOIN public.journal_entries je ON je.id = l.journal_entry_id
JOIN public.companies c ON c.id = je.company_id
WHERE c.name = 'CeSu Invest AB';
-- ────────────────────────────────────────────────────────────────
-- 6. Per-account breakdown (post-remap) — spot-check the BAS2025 numbers.
-- ────────────────────────────────────────────────────────────────
SELECT
coa.account_number,
coa.account_name,
COALESCE(SUM(l.debit_amount), 0) AS debit_sum,
COALESCE(SUM(l.credit_amount), 0) AS credit_sum,
COUNT(l.id) AS line_count
FROM public.chart_of_accounts coa
JOIN public.companies c ON c.id = coa.company_id
LEFT JOIN public.journal_entry_lines l ON l.account_id = coa.id
WHERE c.name = 'CeSu Invest AB'
AND coa.account_number IN (
'1311','1330','1350','1351','1352','1353','1354','1355','1356',
'1360','1361','1385','1386','1510','1630','1760',
'1930','1940','1941','1942','1943','1944',
'2081','2086','2091','2099','2126','2650','2710','2890','2941'
)
GROUP BY coa.account_number, coa.account_name
ORDER BY coa.account_number;
-- ────────────────────────────────────────────────────────────────
-- 7. Summary headcount: chart_of_accounts for CeSu Invest AB.
-- ────────────────────────────────────────────────────────────────
SELECT
COUNT(*) AS total_accounts,
COUNT(*) FILTER (WHERE account_class = 1) AS class_1_assets,
COUNT(*) FILTER (WHERE account_class = 2) AS class_2_eq_liab,
COUNT(*) FILTER (WHERE LEFT(account_number, 7) = '__mig__') AS migration_leftovers
FROM public.chart_of_accounts coa
JOIN public.companies c ON c.id = coa.company_id
WHERE c.name = 'CeSu Invest AB';
@@ -0,0 +1,166 @@
-- Fix: undo_sie_import owner/admin gate breaks when the RPC runs on the
-- service-role client.
--
-- Background: 20260528120100_undo_sie_import.sql checks the caller's role with
-- WHERE cm.user_id = auth.uid()
-- and raises 'Only company owners and admins can undo SIE imports' when no
-- owner/admin row matches.
--
-- A later change (commit ade0cd66 "run SIE bulk-delete RPCs on the service
-- client") routes this RPC through createServiceClient() to escape the 8s
-- statement_timeout on large imports. That client is cookie-less and sends the
-- service-role key as the JWT, so inside the RPC auth.uid() is NULL — the role
-- lookup matches nothing and the function ALWAYS raises. undo_sie_import is
-- therefore completely broken on hosted (where SUPABASE_SERVICE_ROLE_KEY is
-- set): "Kunde inte ångra import: Only company owners and admins can undo SIE
-- imports".
--
-- Fix: accept the authorising user explicitly as p_user_id and resolve the
-- role against COALESCE(p_user_id, auth.uid()). The application layer (API
-- route / pending-operation commit) already has the human user's id and now
-- passes it through. Backward compatible: callers using a real user JWT and no
-- p_user_id (e.g. a direct SQL/MCP call) still resolve via auth.uid().
--
-- The 2-arg signature is dropped first so PostgREST has a single, unambiguous
-- overload to resolve the RPC against.
--
-- Audit note: the behandlingshistorik (per-row audit_log on each
-- journal_entries DELETE) is written by write_audit_log(), which records the
-- entry's own user_id — NOT auth.uid() — so the deletion trail is unaffected
-- by which client runs the RPC. This change only restores the authorisation
-- gate; it does not alter what gets logged.
--
-- pg-test: lib/import/__tests__/undo-sie-import-actor.pg.test.ts
DROP FUNCTION IF EXISTS public.undo_sie_import(uuid, uuid);
CREATE OR REPLACE FUNCTION public.undo_sie_import(
p_company_id uuid,
p_import_id uuid,
p_user_id uuid DEFAULT NULL
)
RETURNS integer
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO 'public'
AS $function$
DECLARE
v_fiscal_period_id uuid;
v_opening_balance_entry_id uuid;
v_is_closed boolean;
v_locked_at timestamptz;
v_deleted integer := 0;
v_caller_role text;
v_actor uuid := COALESCE(p_user_id, auth.uid());
BEGIN
SELECT cm.role INTO v_caller_role
FROM company_members cm
WHERE cm.company_id = p_company_id
AND cm.user_id = v_actor;
IF v_caller_role IS NULL OR v_caller_role NOT IN ('owner', 'admin') THEN
RAISE EXCEPTION 'Only company owners and admins can undo SIE imports';
END IF;
SELECT fiscal_period_id, opening_balance_entry_id
INTO v_fiscal_period_id, v_opening_balance_entry_id
FROM public.sie_imports
WHERE id = p_import_id
AND company_id = p_company_id
AND status = 'completed';
IF NOT FOUND THEN
RAISE EXCEPTION 'Import % not found or not in completed status', p_import_id;
END IF;
IF v_fiscal_period_id IS NOT NULL THEN
SELECT is_closed, locked_at
INTO v_is_closed, v_locked_at
FROM public.fiscal_periods
WHERE id = v_fiscal_period_id;
IF v_is_closed OR v_locked_at IS NOT NULL THEN
RAISE EXCEPTION 'Cannot undo SIE import in a locked or closed fiscal period';
END IF;
END IF;
PERFORM set_config('gnubok.allow_delete', 'true', true);
-- Detach documents (entry- and line-level).
UPDATE public.document_attachments
SET journal_entry_id = NULL,
journal_entry_line_id = NULL
WHERE journal_entry_id IN (
SELECT je.id
FROM public.journal_entries je
WHERE je.company_id = p_company_id
AND je.fiscal_period_id = v_fiscal_period_id
AND je.source_type IN ('import', 'opening_balance')
AND je.status IN ('posted', 'cancelled')
)
OR journal_entry_line_id IN (
SELECT jel.id
FROM public.journal_entry_lines jel
JOIN public.journal_entries je ON je.id = jel.journal_entry_id
WHERE je.company_id = p_company_id
AND je.fiscal_period_id = v_fiscal_period_id
AND je.source_type IN ('import', 'opening_balance')
AND je.status IN ('posted', 'cancelled')
);
-- Clear the fiscal-period OB pointer (two-step around
-- enforce_opening_balance_immutability).
IF v_opening_balance_entry_id IS NOT NULL THEN
UPDATE public.fiscal_periods
SET opening_balances_set = false
WHERE id = v_fiscal_period_id
AND opening_balance_entry_id = v_opening_balance_entry_id;
UPDATE public.fiscal_periods
SET opening_balance_entry_id = NULL
WHERE id = v_fiscal_period_id
AND opening_balance_entry_id = v_opening_balance_entry_id;
END IF;
-- Drop the sie_imports -> opening_balance_entry FK before delete.
UPDATE public.sie_imports
SET opening_balance_entry_id = NULL
WHERE id = p_import_id;
-- Hard-delete the import's journal entries (both transaction vouchers
-- and the opening_balance entry).
WITH deleted AS (
DELETE FROM public.journal_entries
WHERE company_id = p_company_id
AND fiscal_period_id = v_fiscal_period_id
AND source_type IN ('import', 'opening_balance')
AND status IN ('posted', 'cancelled')
RETURNING id
)
SELECT count(*) INTO v_deleted FROM deleted;
-- Reset voucher_sequences per series to the max remaining number.
UPDATE public.voucher_sequences vs
SET last_number = COALESCE((
SELECT MAX(je.voucher_number)
FROM public.journal_entries je
WHERE je.company_id = vs.company_id
AND je.fiscal_period_id = vs.fiscal_period_id
AND je.voucher_series = vs.voucher_series
AND je.voucher_number > 0
), 0),
updated_at = now()
WHERE vs.company_id = p_company_id
AND vs.fiscal_period_id = v_fiscal_period_id;
UPDATE public.sie_imports
SET status = 'undone',
replaced_at = now()
WHERE id = p_import_id
AND company_id = p_company_id;
RETURN v_deleted;
END;
$function$;
NOTIFY pgrst, 'reload schema';