diff --git a/app/(dashboard)/bookkeeping/[id]/page.tsx b/app/(dashboard)/bookkeeping/[id]/page.tsx index 99fe0a60..f5c2d4d0 100644 --- a/app/(dashboard)/bookkeeping/[id]/page.tsx +++ b/app/(dashboard)/bookkeeping/[id]/page.tsx @@ -8,12 +8,13 @@ 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, FileText, Link2 } from 'lucide-react' +import { Loader2, ArrowLeft, Paperclip, AlertTriangle, Lock, MessageSquare, Pencil, Check, X, Copy, ChevronDown, CalendarClock, FileText, Link2, RotateCcw } from 'lucide-react' import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, + DropdownMenuSeparator, } from '@/components/ui/dropdown-menu' import { useCanWrite } from '@/lib/hooks/use-can-write' import { formatDate } from '@/lib/utils' @@ -45,6 +46,8 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i const [showEdit, setShowEdit] = useState(false) const [showRecordate, setShowRecordate] = useState(false) const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) + const [showReverseConfirm, setShowReverseConfirm] = useState(false) + const [isReversing, setIsReversing] = useState(false) const [isDeleting, setIsDeleting] = useState(false) const [isCommitting, setIsCommitting] = useState(false) const [isLastInSeries, setIsLastInSeries] = useState(false) @@ -155,6 +158,33 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i } }, [id, router, toast, t]) + // Pure reversal (storno) — cancels the verifikat with a stornoverifikation and + // no replacement, per BFL 5 kap 5§. Distinct from "Rätta", which always books + // a replacement entry. Routes through the engine's reverseEntry (storno + + // reverses_id link; original → 'reversed', never deleted). + const handleReverse = useCallback(async () => { + setIsReversing(true) + try { + const res = await fetch(`/api/bookkeeping/journal-entries/${id}/reverse`, { method: 'POST' }) + const result = await res.json() + if (res.ok) { + const storno = result.data + toast({ + title: t('toast_reverse_done_title'), + description: t('toast_reverse_done_description', { voucher: formatVoucher(storno ?? {}) }), + }) + setShowReverseConfirm(false) + await fetchData() + } else { + toast({ title: t('toast_reverse_failed'), description: getErrorMessage(result, { context: 'journal_entry' }), variant: 'destructive' }) + } + } catch { + toast({ title: t('toast_reverse_failed'), variant: 'destructive' }) + } finally { + setIsReversing(false) + } + }, [id, toast, fetchData, t]) + useEffect(() => { fetchData() }, [fetchData]) @@ -297,6 +327,11 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i {t('correct_date')} + + setShowReverseConfirm(true)}> + + {t('reverse_action')} + )} @@ -712,6 +747,25 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i + + {/* Reverse (storno) confirmation dialog */} + +
+ +
+

{t('reverse_dialog_heading', { voucher: formatVoucher(entry) })}

+

{t('reverse_dialog_body')}

+
+
+
) } diff --git a/app/api/import/bank-file/execute/route.ts b/app/api/import/bank-file/execute/route.ts index c6365a53..c4dd7009 100644 --- a/app/api/import/bank-file/execute/route.ts +++ b/app/api/import/bank-file/execute/route.ts @@ -12,6 +12,13 @@ import type { Transaction } from '@/types' ensureInitialized() +// Bank-file imports run a sequential, per-row ingest (insert + invoice/supplier +// matching + FX lookup). A full-year file (300+ rows) takes ~85s of server time, +// which sits right on the platform's default function limit and gets killed +// mid-run — the import "spins then aborts" for the user. Give it the same 5-minute +// budget the SIE import route uses (app/api/import/sie/execute/route.ts). +export const maxDuration = 300 + interface ExecuteRequest { transactions: ParsedBankTransaction[] format: BankFileFormatId diff --git a/app/api/invoices/preview-pdf/route.ts b/app/api/invoices/preview-pdf/route.ts index dfc206a1..86e7cccd 100644 --- a/app/api/invoices/preview-pdf/route.ts +++ b/app/api/invoices/preview-pdf/route.ts @@ -2,7 +2,7 @@ import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { renderToBuffer } from '@react-pdf/renderer' import { InvoicePDF } from '@/lib/invoices/pdf-template' -import { prepareInvoicePdfRender } from '@/lib/invoices/pdf-render-helpers' +import { prepareInvoicePdfRender, buildSwishQrDataUrl } from '@/lib/invoices/pdf-render-helpers' import { getVatRules } from '@/lib/invoices/vat-rules' import { requireCompanyId } from '@/lib/company/context' import type { Invoice, InvoiceItem, Customer, CompanySettings, InvoiceDocumentType } from '@/types' @@ -180,6 +180,7 @@ export async function POST(request: Request) { const { branding, company: renderCompany } = await prepareInvoicePdfRender( company as CompanySettings, ) + const swishQrDataUrl = await buildSwishQrDataUrl(company as CompanySettings, previewInvoice) const pdfBuffer = await renderToBuffer( InvoicePDF({ invoice: previewInvoice, @@ -188,6 +189,7 @@ export async function POST(request: Request) { company: renderCompany, isPreview: true, branding, + swishQrDataUrl, }) ) diff --git a/components/agent/AgentTrigger.tsx b/components/agent/AgentTrigger.tsx index 7859f825..c9adcb6b 100644 --- a/components/agent/AgentTrigger.tsx +++ b/components/agent/AgentTrigger.tsx @@ -22,8 +22,10 @@ import { CAPABILITY } from '@/lib/entitlements/keys' // // Page-specific triggers (e.g. "Granska med assistent" on a supplier invoice) // still call useAgentSheet() directly from their own buttons because they -// know exactly which entity to pass. (Per-transaction help is reached from -// Dokumentinkorgen, not a transactions-page row button.) +// know exactly which entity to pass. (Per-transaction help has its own +// row-level "Fråga [namn]" button in TransactionInboxCard — and the matching +// "Fråga assistenten" in Dokumentinkorgen — both passing a transaction_id the +// pathname-only FAB can't know.) export default function AgentTrigger() { const { openAgentSheet, isOpen, identity } = useAgentSheet() const pathname = usePathname() diff --git a/components/bookkeeping/CorrectionEntryDialog.tsx b/components/bookkeeping/CorrectionEntryDialog.tsx index fe3391b5..1a4099aa 100644 --- a/components/bookkeeping/CorrectionEntryDialog.tsx +++ b/components/bookkeeping/CorrectionEntryDialog.tsx @@ -178,7 +178,14 @@ export default function CorrectionEntryDialog({ entry, open, onOpenChange, onCor {/* Corrected lines (editable) */}
-

Rättade rader

+
+

Rättade rader

+

+ Det här är hela den nya verifikationen — alla konton som ska finnas kvar måste stå + kvar. Tar du bort ett konto nollställs det (stornon återför det). Vill du bara återföra + hela verifikatet utan att ersätta det, använd Återför (storno) istället. +

+
{lines.map((line, index) => ( diff --git a/components/bookkeeping/CorrectionPreview.tsx b/components/bookkeeping/CorrectionPreview.tsx index c0b293d4..6e1a089b 100644 --- a/components/bookkeeping/CorrectionPreview.tsx +++ b/components/bookkeeping/CorrectionPreview.tsx @@ -4,6 +4,7 @@ import { AccountNumber } from '@/components/ui/account-number' import { buildCorrectionRows, formatSignedAmount, + type AccountRow, type CorrectionLineInput, } from '@/components/bookkeeping/correction-preview-rows' import type { JournalEntryLine } from '@/types' @@ -28,6 +29,18 @@ export default function CorrectionPreview({ originalLines, correctedLines }: Pro return (Number.isFinite(d) && d > 0) || (Number.isFinite(c) && c > 0) }) + // An account that was on the original but the user dropped from the rättelse: + // the storno still drains it to zero (delta = −original). Flag it so the cell + // reads "tas bort" instead of a bare "–", which would imply "unchanged". + const isRemoved = (row: AccountRow) => + hasAnyCorrection && !row.correctionPresent && Math.abs(row.original) >= 0.005 + + // The per-account deltas sum to the corrected lines' debit − credit. A non-zero + // sum means the proposed rättelse is not yet balanced — surface that here so the + // förändring column is read as a work-in-progress, not a miscalculation. + const netDelta = rows.reduce((sum, r) => sum + r.delta, 0) + const unbalanced = hasAnyCorrection && Math.abs(netDelta) >= 0.005 + if (rows.length === 0) return null return ( @@ -62,8 +75,8 @@ export default function CorrectionPreview({ originalLines, correctedLines }: Pro {formatSignedAmount(row.storno)} - - {hasAnyCorrection ? formatSignedAmount(row.correction) : '–'} + + {!hasAnyCorrection ? '–' : isRemoved(row) ? 'tas bort' : formatSignedAmount(row.correction)}
Rättelse
-
- {hasAnyCorrection ? formatSignedAmount(row.correction) : '–'} +
+ {!hasAnyCorrection ? '–' : isRemoved(row) ? 'tas bort' : formatSignedAmount(row.correction)}
@@ -107,8 +120,13 @@ export default function CorrectionPreview({ originalLines, correctedLines }: Pro

Förändring = storno + rättelse. Det är det netto som tillkommer ovanpå originalet när du - bokför. + bokför. Ett konto du tar bort nollställs av stornon.

+ {unbalanced && ( +

+ Förslaget balanserar inte ännu – debet och kredit i rättelsen måste vara lika. +

+ )}
) } diff --git a/components/bookkeeping/JournalEntryList.tsx b/components/bookkeeping/JournalEntryList.tsx index c1365a48..043d2698 100644 --- a/components/bookkeeping/JournalEntryList.tsx +++ b/components/bookkeeping/JournalEntryList.tsx @@ -26,15 +26,17 @@ import { STORAGE_KEY_PREFIX as FISCAL_YEAR_STORAGE_KEY_PREFIX, ALL_YEARS_VALUE as FISCAL_YEAR_ALL_VALUE, } from '@/components/common/FiscalYearSelector' -import { ChevronDown, ChevronRight, ChevronLeft, ChevronsLeft, ChevronsRight, Paperclip, AlertTriangle, CircleSlash, Loader2, BookOpen, X, Copy, Lock, Search, SlidersHorizontal } from 'lucide-react' +import { ChevronDown, ChevronRight, ChevronLeft, ChevronsLeft, ChevronsRight, Paperclip, AlertTriangle, CircleSlash, Loader2, BookOpen, X, Copy, Lock, Search, SlidersHorizontal, RotateCcw } from 'lucide-react' import { formatDate, formatCurrency } from '@/lib/utils' import { formatVoucher } from '@/lib/bookkeeping/voucher-series-resolver' +import { resolveCurrentPeriodId } from '@/lib/bookkeeping/suggest-fiscal-period' import { Input } from '@/components/ui/input' import { AccountNumber } from '@/components/ui/account-number' import { getAccountDescription } from '@/lib/bookkeeping/account-descriptions' import JournalEntryAttachments from '@/components/bookkeeping/JournalEntryAttachments' import NoDocRequiredToggle from '@/components/bookkeeping/NoDocRequiredToggle' import CorrectionEntryDialog from '@/components/bookkeeping/CorrectionEntryDialog' +import { ConfirmationDialog } from '@/components/ui/confirmation-dialog' import JournalEntryStatusBadge from '@/components/bookkeeping/JournalEntryStatusBadge' import AttachmentPreviewSheet from '@/components/bookkeeping/AttachmentPreviewSheet' import { useToast } from '@/components/ui/use-toast' @@ -92,6 +94,8 @@ export default function JournalEntryList() { const [bulkReason, setBulkReason] = useState('') const [bulkSubmitting, setBulkSubmitting] = useState(false) const [correctionEntry, setCorrectionEntry] = useState(null) + const [reverseEntryTarget, setReverseEntryTarget] = useState(null) + const [isReversing, setIsReversing] = useState(false) const [previewEntryId, setPreviewEntryId] = useState(null) const [sortBy, setSortBy] = useState('date_desc') const [sortHydrated, setSortHydrated] = useState(false) @@ -214,41 +218,53 @@ export default function JournalEntryList() { setPageSizeHydrated(true) }, [company?.id]) - // Restore the persisted fiscal-year selection (per company), reading the same - // localStorage key FiscalYearSelector writes. The selector lives inside the - // filter dialog and only mounts when opened, so we resolve the saved scope - // here — independent of the dialog — to keep the initial fetch correct. - // periodHydrated gates the first fetch so the list loads already scoped. - useEffect(() => { - if (company?.id && typeof window !== 'undefined') { - const stored = window.localStorage.getItem(FISCAL_YEAR_STORAGE_KEY_PREFIX + company.id) - setPeriodId(stored && stored !== FISCAL_YEAR_ALL_VALUE ? stored : null) - } else { - setPeriodId(null) - } - setPeriodHydrated(true) - }, [company?.id]) - - // Fetch fiscal periods so the active räkenskapsår can be labelled on the - // filter bar without opening the dialog (BFL period-orientation: the user - // should always see which year the ledger is scoped to). Read-only — the - // dialog's FiscalYearSelector still owns selection; this copy resolves the - // name for display. + // Fetch fiscal periods AND resolve the initial fiscal-year scope in one pass. + // The list is period-oriented (BFL): verifikationsnummer run as an unbroken + // series *per räkenskapsår*, so the same number (e.g. A42) recurs once per + // year. Showing every year at once makes those look like duplicates and makes + // a bare "A42" reference ambiguous — so we default to the räkenskapsår the + // user is currently in rather than "all years". An explicit "Alla + // räkenskapsår" choice (persisted as ALL_YEARS_VALUE) is still honoured. + // Resolving the scope here — not in the dialog's FiscalYearSelector, which + // only mounts when opened — keeps the first fetch correct. periodHydrated + // gates that first fetch so the list loads already scoped to the resolved year. useEffect(() => { if (!company?.id) { setPeriods([]) + setPeriodId(null) + setPeriodHydrated(true) return } let cancelled = false ;(async () => { + let fetched: FiscalPeriod[] = [] try { const res = await fetch('/api/bookkeeping/fiscal-periods') - if (!res.ok) return - const { data } = await res.json() - if (!cancelled) setPeriods((data || []) as FiscalPeriod[]) + if (res.ok) { + const { data } = await res.json() + fetched = (data || []) as FiscalPeriod[] + } } catch { - // Non-critical — the chip falls back to the active-filter count badge. + // Non-critical — fall through with an empty list (scope stays "all years"). } + if (cancelled) return + setPeriods(fetched) + + const stored = + typeof window !== 'undefined' + ? window.localStorage.getItem(FISCAL_YEAR_STORAGE_KEY_PREFIX + company.id) + : null + if (stored === FISCAL_YEAR_ALL_VALUE) { + // User explicitly chose "all years" — respect it. + setPeriodId(null) + } else if (stored && fetched.some((p) => p.id === stored)) { + setPeriodId(stored) + } else { + // No (valid) saved scope → default to the current räkenskapsår. + const today = new Date().toISOString().split('T')[0] + setPeriodId(resolveCurrentPeriodId(fetched, today)) + } + setPeriodHydrated(true) })() return () => { cancelled = true @@ -299,13 +315,21 @@ export default function JournalEntryList() { const loadedEntries = data || [] setEntries(loadedEntries) setCount(total || 0) + + // The pristine empty card vs. the (toggle-bearing) "drafts exist" state hinges + // on draftCount. When the committed list comes back empty, resolve the draft + // count BEFORE clearing loading so the toggle doesn't flash out for a frame on + // a stale count of 0. Every other case refreshes the badge in the background. + if (loadedEntries.length === 0 && listMode === 'committed') { + await fetchDraftCount() + } else { + fetchDraftCount() + } setLoading(false) // Fetch attachment counts for the loaded entries const ids = loadedEntries.map((e: JournalEntry) => e.id) fetchAttachmentCounts(ids) - - fetchDraftCount() } // Cheap count-only query for the "Utkast" badge — all years, so the badge @@ -364,6 +388,35 @@ export default function JournalEntryList() { } } + // Pure reversal (storno) of a posted verifikat — books a stornoverifikation + // with no replacement, per BFL 5 kap 5§. Routes through the engine's + // reverseEntry (storno + reverses_id link; original → 'reversed', never + // deleted). "Rätta" stays the path for booking a replacement entry instead. + const handleReverse = async () => { + const target = reverseEntryTarget + if (!target) return + setIsReversing(true) + try { + const res = await fetch(`/api/bookkeeping/journal-entries/${target.id}/reverse`, { method: 'POST' }) + const result = await res.json() + if (res.ok) { + const storno = result.data + toast({ + title: t('toast_reverse_done_title'), + description: t('toast_reverse_done_description', { voucher: formatVoucher(storno ?? {}) }), + }) + setReverseEntryTarget(null) + await fetchEntries() + } else { + toast({ title: t('toast_reverse_failed'), description: getErrorMessage(result, { context: 'journal_entry' }), variant: 'destructive' }) + } + } catch { + toast({ title: t('toast_reverse_failed'), variant: 'destructive' }) + } finally { + setIsReversing(false) + } + } + // A posted, document-requiring entry with no attachment yet and not already // exempt — i.e. the rows that show the warning triangle. Only these can be // batch-marked "Inget underlag krävs". @@ -560,7 +613,12 @@ export default function JournalEntryList() { }) } - if (!loading && entries.length === 0 && !hasActiveFilters) { + // Pristine, untouched ledger: nothing posted, no drafts, no filters, and we're + // on the committed view. ONLY this genuinely-empty case may short-circuit the + // whole component — every other empty state (a draft exists, or we're in the + // drafts view) must fall through to the main render below so the + // Verifikat/Utkast toggle stays reachable. + if (!loading && entries.length === 0 && !hasActiveFilters && listMode === 'committed' && draftCount === 0) { return ( @@ -897,14 +955,32 @@ export default function JournalEntryList() { ) : filteredEntries.length === 0 ? ( + // Empty placeholder, scoped to the situation: an empty drafts view, a + // filtered committed view with no matches, or a committed view with no + // posted entries yet (but drafts exist — hence we got here, not the + // pristine early return above).
- + {listMode === 'drafts' || !hasActiveFilters ? ( + + ) : ( + + )}
-

{t('no_results_title')}

+

+ {listMode === 'drafts' + ? t('empty_drafts_title') + : hasActiveFilters + ? t('no_results_title') + : t('empty_title')} +

- {t('no_results_description')} + {listMode === 'drafts' + ? t('empty_drafts_description') + : hasActiveFilters + ? t('no_results_description') + : t('empty_description')}

@@ -1276,6 +1352,17 @@ export default function JournalEntryList() { {t('create_correction')} )} + {canWrite && entry.status === 'posted' && entry.source_type !== 'storno' && entry.source_type !== 'correction' && ( + + )}
- {/* Swish on invoices is "coming soon" — the toggle is disabled until the - payment-QR flow ships (gated by SHOW_SWISH_ON_INVOICE in pdf-template). */} -
+
-
- - {t('coming_soon')} -
+

{t('show_swish_help')}

- + saveToggle('invoice_show_swish', v)} + aria-label={t('show_swish_label')} + />
diff --git a/components/transactions/TransactionInboxCard.tsx b/components/transactions/TransactionInboxCard.tsx index 521d2432..e74872c2 100644 --- a/components/transactions/TransactionInboxCard.tsx +++ b/components/transactions/TransactionInboxCard.tsx @@ -25,6 +25,7 @@ import { FileText, Link2, Loader2, + MessageCircle, MoreHorizontal, Paperclip, Pencil, @@ -46,6 +47,7 @@ import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-exten const HAS_AI_EXTRACTION = ENABLED_EXTENSION_IDS.has('document-extraction') import { TransactionAttachmentIndicator } from './TransactionAttachmentIndicator' import { useCanWrite } from '@/lib/hooks/use-can-write' +import { useAgentSheet } from '@/components/agent/AgentSheetProvider' import type { TransactionWithInvoice, CategorizeHandler } from './transaction-types' interface TransactionInboxCardProps { @@ -104,6 +106,11 @@ export default function TransactionInboxCard({ // Attaching underlag is a write — hide the affordance from viewers so they // don't dead-end on a 403 (mirrors the gate in TransactionHistoryList). const { canWrite } = useCanWrite() + // The transaction-side entry point to the assistant ("Lena"). openAgentSheet + // hands this specific bank line to the transaction.categorization intent — + // the mirror of "Fråga assistenten" in Dokumentinkorgen, so the user can + // start a booking with the agent from the inbox they actually live in. + const { openAgentSheet, identity } = useAgentSheet() const isProcessing = processingId === transaction.id const isDisabled = processingId !== null && processingId !== transaction.id const isIncome = transaction.amount > 0 @@ -142,6 +149,12 @@ export default function TransactionInboxCard({ // Unbooked rows are still actionable (match, split, edit, categorize) — that // includes imported bank rows, which are the whole point of the inbox. const isUnbooked = !transaction.journal_entry_id + // "Fråga [namn]" hands the row to the assistant for categorization/booking. + // Only on unbooked rows (nothing to categorize once it's a verifikat) and + // only after the user has built their agent in /onboarding/agent + // (identity.isVerified) — same gate as the FAB / AgentSparkleButton. + const assistantName = identity.displayName?.trim() || 'min assistent' + const showAskAssistant = isUnbooked && identity.isVerified // ...but only rows the USER created in the app may be deleted. Imported rows // (bank sync / CSV) are ignore-only — mirrors the server guard in // DELETE /api/transactions/[id]. See lib/transactions/origin.ts. @@ -326,13 +339,35 @@ export default function TransactionInboxCard({ )} - {/* The Paperclip indicator next to the description - (TransactionAttachmentIndicator) is the single click - target for opening the underlag. We deliberately don't - duplicate that with a second icon in the trailing slot. - Per-transaction agent help has moved to Dokumentinkorgen: - match the underlag to the transaction and ask from there, - where the receipt/invoice is in view. */} + {/* "Fråga [namn]" — hand this bank line to the assistant for + categorization/booking. The transaction-side entry point to + the agent, mirroring "Fråga assistenten" in Dokumentinkorgen. + The intent reads any linked underlag automatically, so it + works whether or not the row already has a receipt attached. + Icon-only ghost so it sits quietly in the row's action + group. (The Paperclip indicator next to the description + stays the single click target for opening the underlag — + we don't duplicate that here.) */} + {showAskAssistant && ( + + )} {/* Secondary actions (split, edit, delete) collapse into a ⋯ overflow menu so the row stays uncluttered. */} {showOverflowMenu && ( diff --git a/extensions/general/enable-banking/__tests__/session-expired.test.ts b/extensions/general/enable-banking/__tests__/session-expired.test.ts index 0ad1430b..2eebf3cd 100644 --- a/extensions/general/enable-banking/__tests__/session-expired.test.ts +++ b/extensions/general/enable-banking/__tests__/session-expired.test.ts @@ -260,3 +260,112 @@ describe('POST /connect (enable-banking) — reconnect in place', () => { expect(updateSpy.mock.calls[1][0]).toEqual({ authorization_id: 'auth-123' }) }) }) + +describe('POST /connect (enable-banking) — psu_type persistence', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + function stubAuth() { + 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 () => '', + })) + ) + } + + it('reuses the stored psu_type on reconnect when the client sends no override', async () => { + // A 'personal' connection must NOT silently flip to 'business' on renewal — + // that was the Handelsbanken signing-failure trap. + stubAuth() + const updateSpy = vi.fn() + const ctx = makeContext( + { + id: 'conn-1', + company_id: 'company-1', + bank_name: 'Handelsbanken', + provider: 'handelsbanken-se', + session_id: null, + status: 'expired', + psu_type: 'personal', + }, + updateSpy + ) + + 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: 'Handelsbanken', aspsp_country: 'SE' }), + }) + + const res = await connectRoute.handler(req, ctx) + expect(res.status).toBe(200) + // The CSRF-state staging update (first write) carries the reused type. + expect(updateSpy.mock.calls[0][0]).toMatchObject({ psu_type: 'personal' }) + }) + + it('lets an explicit psu_type override the stored type (switch account type in place)', async () => { + stubAuth() + const updateSpy = vi.fn() + const ctx = makeContext( + { + id: 'conn-1', + company_id: 'company-1', + bank_name: 'Handelsbanken', + provider: 'handelsbanken-se', + session_id: null, + status: 'expired', + psu_type: 'business', + }, + updateSpy + ) + + 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: 'Handelsbanken', + aspsp_country: 'SE', + psu_type: 'personal', + }), + }) + + const res = await connectRoute.handler(req, ctx) + expect(res.status).toBe(200) + expect(updateSpy.mock.calls[0][0]).toMatchObject({ psu_type: 'personal' }) + }) + + it('persists psu_type on a fresh connect (derived from entity_type)', async () => { + stubAuth() + const insertSpy = vi.fn() + // Fresh connect: the shared single() resolver returns this object for BOTH + // the companies entity_type lookup and the post-insert row read, so giving it + // entity_type drives the derivation and id provides the returned row. + const ctx = makeContext( + { id: 'conn-new', entity_type: 'enskild_firma' }, + vi.fn(), + insertSpy + ) + + const req = new Request('http://localhost/api/extensions/ext/enable-banking/connect', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ aspsp_name: 'Handelsbanken', aspsp_country: 'SE' }), + }) + + const res = await connectRoute.handler(req, ctx) + expect(res.status).toBe(200) + expect(insertSpy).toHaveBeenCalledTimes(1) + expect(insertSpy.mock.calls[0][0]).toMatchObject({ psu_type: 'personal' }) + }) +}) diff --git a/extensions/general/enable-banking/components/BankConnectionStatus.tsx b/extensions/general/enable-banking/components/BankConnectionStatus.tsx index 7fd999c1..b4783857 100644 --- a/extensions/general/enable-banking/components/BankConnectionStatus.tsx +++ b/extensions/general/enable-banking/components/BankConnectionStatus.tsx @@ -3,6 +3,13 @@ import { useState } from 'react' import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' import { formatDate } from '@/lib/utils' import { getDaysUntilExpiry, isConsentExpiringSoon } from '../lib/api-client' import Link from 'next/link' @@ -14,6 +21,7 @@ import { Trash2, Loader2, CheckCircle, + ChevronDown, XCircle, Upload, } from 'lucide-react' @@ -23,7 +31,7 @@ interface BankConnectionStatusProps { connection: BankConnection onSync: (connectionId: string) => void onDisconnect: (connectionId: string) => void - onReconnect?: (connection: BankConnection) => void + onReconnect?: (connection: BankConnection, psuType?: 'personal' | 'business') => void onManageAccounts?: (connectionId: string) => void isSyncing?: boolean } @@ -133,13 +141,30 @@ export function BankConnectionStatus({
{(isConnectionExpired || isConnectionError) && onReconnect && ( - + + + + + + {/* Let the user pick the account type for the bank login. The + server reuses the last-used type by default, but some banks + (notably Handelsbanken) only sign with one of them — e.g. an + AB owner who signs with a personal Mobile BankID needs + "Privatkonto", not the company default "Företagskonto". */} + + Logga in på banken som + + onReconnect(connection, 'business')}> + Företagskonto + + onReconnect(connection, 'personal')}> + Privatkonto + + + )} {isConnectionError && (