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:
+5
-2
@@ -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
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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')}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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) {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
// 3–8 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 4–7 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 4–7 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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
},
|
||||
|
||||
@@ -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å."
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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;
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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';
|
||||
Reference in New Issue
Block a user