Bug/open banking flow (#854)
* fix(enable-banking): pin Mobile BankID (decoupled) auth_method so Handelsbanken corporate connects We never sent auth_method to Enable Banking, so it fell back to the ASPSP's visible default — REDIRECT for Handelsbanken. For Handelsbanken *corporate* PSUs the redirect flow does not support Mobile BankID, so authorization failed right after the user approved in the BankID app. Mobile BankID at Handelsbanken is a DECOUPLED method flagged hidden_method=true, which Enable Banking only uses when requested explicitly. Resolve the bank's preferred auth method before /auth: query the ASPSP's auth_methods and pick the DECOUPLED (Mobile BankID) method when present, otherwise leave auth_method unset so banks that already work are untouched. The method name is read dynamically per psu_type, so it is robust across sandbox/production naming. - api-client: add approach/hidden_method to AuthMethod, fix ASPSP.auth_methods field name (was available_auth_methods, never populated), add getPreferredAuthMethod(), thread optional authMethod through startAuthorization - index: resolve authMethod in /connect and pass it on both fresh + reconnect - tests: cover method selection and request-body shaping Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(invoice-inbox): clean up bulk-selection toolbar UI Redesign the selection toolbar shown when inbox items are checked: one solid primary "Bokför valda" button with outlined secondary actions ("Fråga assistenten", "Ta bort") and a plain selection count. Removes the redundant "Avmarkera" button (users uncheck the still-visible box), fixes label clipping, and gives the toolbar more breathing room. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(entitlements): bypass paywall in local development Add isPaywallBypassed() so all gated capabilities are testable locally without a subscription. Fires only on NODE_ENV=development (npm run dev) or an explicit DISABLE_PAYWALL=true escape hatch — production builds run under NODE_ENV=production and the entitlement suite runs under 'test', so both keep exercising the real gate. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(tic): resolve enskild firma bolagsuppgifter via 12-digit personnummer TIC's Lens search is fuzzy and only resolves an enskild firma from the 12-digit (century-prefixed) personnummer; a 10-digit form fuzzy-matched an unrelated entity. Expand personnummer to 12 digits before querying and reject hits whose registration number is unrelated to the request. Add a "Hämta" action to the settings Bolagsuppgifter panel to (re)fetch on demand. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(transactions): implement categorize core for bank transaction categorization - Added `categorize-core.ts` to handle categorization of bank transactions, supporting single and bulk operations. - Introduced `categorizeMatchedTransaction` and `bulkBookMatchedInboxItems` functions for transaction processing. - Implemented fiscal period validation and duplicate booking detection. - Enhanced logging and error handling for transaction categorization. feat(scripts): add diagnostic script for Handelsbanken ASPSP metadata - Created `check-handelsbanken-aspsp.mjs` to fetch and display available authentication methods for Handelsbanken. - Outputs metadata for business and personal PSU types, including default authentication methods. fix(migrations): increase statement timeout for SIE bulk delete operations - Updated `20260629160000_sie_bulk_delete_statement_timeout.sql` to set a longer statement timeout for bulk delete RPCs to prevent cancellations during large imports. feat(migrations): add bulk book inbox items to pending operations - Expanded `pending_operations` table to include `bulk_book_inbox_items` operation type in `20260630120000_pending_operations_add_bulk_book_inbox_items.sql`. - Supports bulk booking of matched inbox items against bank transactions. test(pg): add tests for replace_period_opening_balance_link RPC - Implemented tests in `replace-period-opening-balance-link.pg.test.ts` to validate the functionality of the opening-balance correction flow. - Ensured immutability of opening balance links and proper handling of posted vs. non-posted entries. * fix(sie-export): update journal entries and lines handling in SIE export tests * fix(migrations): resolve version collision on 20260629160000 The SIE bulk-delete statement_timeout migration shared version 20260629160000 with journal_entries_list_series_filter (merged from main via #798/#823), causing a schema_migrations_pkey duplicate key error on apply. Rename the branch's migration to 20260629160100. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(compliance): resolve compliance-swarm + review findings - opening-balance/correct: compensating rollback for the non-atomic storno+rebook so a mid-sequence failure never leaves two posted OB entries (ASVS V2.3); durable audit event on every failure path (V16); reference the original verifikationsnummer in the corrected entry per BFL 5 kap 5§; document that requireWrite already enforces write-role + membership (V8.2.1 was a false positive) - reports sources routes: validate the cursor date component as ISO (/^\d{4}-\d{2}-\d{2}$/) before use, 400 on malformed (ASVS V1.2), applied to both the VAT-declaration and trial-balance routes - AgentSessionList: await the rename PATCH, revert the optimistic title and toast on failure (ASVS V4.5) - bank booking: exclude same-batch siblings from the booking-time duplicate guard so bulk-booking distinct same-(date,amount) transactions no longer false-positives; pre-existing duplicate detection is preserved - BulkBookInboxDialog: drop the unsafe currency-based reverse_charge default, add an omvänd skattskyldighet advisory, and type VAT options to the backend VatTreatment union - OpeningBalanceRowEditor: hold onChange in a ref (synced in effect, not during render) so an unstable callback can't cause a render loop Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
2da9c71eb3
commit
f63d3e3100
@@ -14,6 +14,7 @@ import { formatAccountWithName } from '@/lib/bookkeeping/client-account-names'
|
||||
import JournalEntryPreview from './JournalEntryPreview'
|
||||
import AccountCombobox from '@/components/bookkeeping/AccountCombobox'
|
||||
import DocumentUploadZone from '@/components/bookkeeping/DocumentUploadZone'
|
||||
import DocumentViewerPane from '@/components/bookkeeping/DocumentViewerPane'
|
||||
import type { UploadedFile } from '@/components/bookkeeping/DocumentUploadZone'
|
||||
import VatTreatmentSelect from './VatTreatmentSelect'
|
||||
import { VAT_TREATMENT_OPTIONS } from './transaction-types'
|
||||
@@ -68,7 +69,6 @@ export default function QuickReviewDialog({
|
||||
const [uploadedFiles, setUploadedFiles] = useState<UploadedFile[]>([])
|
||||
const [showUploadZone, setShowUploadZone] = useState(false)
|
||||
const [showVatDropdown, setShowVatDropdown] = useState(false)
|
||||
const [isOpeningDoc, setIsOpeningDoc] = useState(false)
|
||||
// Mirror of `transaction` so we can patch in a freshly-fetched SEK conversion
|
||||
// before the user confirms — the verifikation must always be in SEK and the
|
||||
// engine reads these fields straight off the transaction row.
|
||||
@@ -78,24 +78,6 @@ export default function QuickReviewDialog({
|
||||
|
||||
const preAttachedDocumentId = transaction?.document_id ?? null
|
||||
|
||||
const handleOpenAttachedDoc = useCallback(async () => {
|
||||
if (!preAttachedDocumentId || isOpeningDoc) return
|
||||
setIsOpeningDoc(true)
|
||||
try {
|
||||
const res = await fetch(`/api/documents/${preAttachedDocumentId}`)
|
||||
if (!res.ok) {
|
||||
toast({ title: t('open_attached_failed'), variant: 'destructive' })
|
||||
return
|
||||
}
|
||||
const { data } = await res.json()
|
||||
if (data?.download_url) {
|
||||
window.open(data.download_url, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
} finally {
|
||||
setIsOpeningDoc(false)
|
||||
}
|
||||
}, [preAttachedDocumentId, isOpeningDoc, toast, t])
|
||||
|
||||
// Handle account changes — clear VAT for liability/equity accounts (class 2)
|
||||
const handleAccountChange = useCallback((account: string) => {
|
||||
setAccountOverride(account)
|
||||
@@ -248,7 +230,7 @@ export default function QuickReviewDialog({
|
||||
}
|
||||
onOpenChange(o)
|
||||
}}>
|
||||
<DialogContent className="max-w-md sm:max-w-lg max-h-[85vh] overflow-y-auto">
|
||||
<DialogContent className={preAttachedDocumentId ? 'max-w-6xl max-h-[90vh] overflow-y-auto' : 'max-w-md sm:max-w-lg max-h-[85vh] overflow-y-auto'}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('title')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
@@ -256,6 +238,17 @@ export default function QuickReviewDialog({
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* When a document is pre-attached, show it side-by-side (receipt left,
|
||||
review right). With no document the wrappers use display:contents so
|
||||
the dialog collapses to the original single-column layout. */}
|
||||
<div className={preAttachedDocumentId ? 'grid grid-cols-1 gap-6 lg:grid-cols-[minmax(0,1fr)_minmax(0,520px)]' : 'contents'}>
|
||||
{preAttachedDocumentId && (
|
||||
<div className="h-[45vh] lg:sticky lg:top-0 lg:h-[72vh] lg:self-start">
|
||||
<DocumentViewerPane documentId={preAttachedDocumentId} className="h-full" />
|
||||
</div>
|
||||
)}
|
||||
<div className={preAttachedDocumentId ? 'space-y-4' : 'contents'}>
|
||||
|
||||
{/* Transaction summary */}
|
||||
<div className="flex items-center gap-3 rounded-lg border p-3">
|
||||
<div
|
||||
@@ -433,27 +426,9 @@ export default function QuickReviewDialog({
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Document — either show the doc the inbox attached pre-categorize,
|
||||
or let the user upload one if none is attached yet. */}
|
||||
{preAttachedDocumentId ? (
|
||||
<div className="rounded-lg border flex items-center justify-between px-3 py-2.5 text-sm">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Paperclip className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<span className="font-medium">{t('attached_doc_label')}</span>
|
||||
<span className="text-xs text-muted-foreground truncate">
|
||||
{t('attached_doc_source')}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleOpenAttachedDoc}
|
||||
disabled={isOpeningDoc}
|
||||
className="text-xs text-primary hover:underline shrink-0"
|
||||
>
|
||||
{isOpeningDoc ? t('opening') : t('view')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
{/* No pre-attached document — let the user upload one. (When a document
|
||||
IS pre-attached it's shown in the left preview column instead.) */}
|
||||
{!preAttachedDocumentId && (
|
||||
<div className="rounded-lg border">
|
||||
<button
|
||||
type="button"
|
||||
@@ -517,6 +492,8 @@ export default function QuickReviewDialog({
|
||||
{isProcessing ? t('booking') : rateLoading ? t('fetching_rate') : t('book')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
|
||||
@@ -4,11 +4,11 @@ import { useState, useEffect } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { formatCurrency, formatDate } from '@/lib/utils'
|
||||
import { ArrowUpRight, ArrowDownRight, ChevronDown, ChevronUp, FileText, Inbox, Paperclip, X } from 'lucide-react'
|
||||
import { ArrowUpRight, ArrowDownRight, FileText, Inbox, X } from 'lucide-react'
|
||||
import JournalEntryForm from '@/components/bookkeeping/JournalEntryForm'
|
||||
import DocumentViewerPane from '@/components/bookkeeping/DocumentViewerPane'
|
||||
import DocumentUploadZone from '@/components/bookkeeping/DocumentUploadZone'
|
||||
import type { UploadedFile } from '@/components/bookkeeping/DocumentUploadZone'
|
||||
import InboxDocumentPicker from '@/components/bookkeeping/InboxDocumentPicker'
|
||||
@@ -115,7 +115,6 @@ export default function TransactionBookingDialog({
|
||||
const { toast } = useToast()
|
||||
const [uploadedFiles, setUploadedFiles] = useState<UploadedFile[]>([])
|
||||
const [pickedInboxDocs, setPickedInboxDocs] = useState<AvailableInboxDoc[]>([])
|
||||
const [showUploadZone, setShowUploadZone] = useState(false)
|
||||
const [inboxPickerOpen, setInboxPickerOpen] = useState(false)
|
||||
const [bankAccount, setBankAccount] = useState<string | null>(null)
|
||||
|
||||
@@ -215,24 +214,32 @@ export default function TransactionBookingDialog({
|
||||
|
||||
setUploadedFiles([])
|
||||
setPickedInboxDocs([])
|
||||
setShowUploadZone(false)
|
||||
onBooked(transactionId, journalEntryId, pinnedDocId)
|
||||
}
|
||||
|
||||
const attachedCount =
|
||||
uploadedFiles.filter((f) => f.status === 'uploaded').length + pickedInboxDocs.length
|
||||
// The receipt to show beside the form. A transaction may arrive with a
|
||||
// pre-linked document; otherwise the user attaches one in-dialog (upload or
|
||||
// inbox pick) and it appears here as soon as it's available.
|
||||
const uploadedDoc = uploadedFiles.find((f) => f.status === 'uploaded' && f.id)
|
||||
const pickedDoc = pickedInboxDocs[0]
|
||||
const preexistingDocId = transaction.document_id ?? null
|
||||
const inDialogDocId = uploadedDoc?.id ?? pickedDoc?.document_id ?? null
|
||||
const currentDocId = preexistingDocId ?? inDialogDocId
|
||||
const currentDocMime = preexistingDocId ? null : uploadedDoc?.file.type ?? null
|
||||
const currentDocName = preexistingDocId
|
||||
? null
|
||||
: uploadedDoc?.fileName ?? pickedDoc?.file_name ?? null
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => {
|
||||
if (!o) {
|
||||
setUploadedFiles([])
|
||||
setPickedInboxDocs([])
|
||||
setShowUploadZone(false)
|
||||
setInboxPickerOpen(false)
|
||||
}
|
||||
onOpenChange(o)
|
||||
}}>
|
||||
<DialogContent className="sm:max-w-2xl max-h-[95dvh] sm:max-h-[90vh] overflow-y-auto">
|
||||
<DialogContent className="max-w-6xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('title')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
@@ -265,99 +272,108 @@ export default function TransactionBookingDialog({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Document upload section */}
|
||||
<div className="rounded-lg border">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowUploadZone(!showUploadZone)}
|
||||
className="flex items-center justify-between w-full px-3 py-2 text-sm hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Paperclip className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="font-medium">{t('doc_label')}</span>
|
||||
{attachedCount > 0 && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t('doc_attached_count', { count: attachedCount })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{showUploadZone ? (
|
||||
<ChevronUp className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
{showUploadZone && (
|
||||
<div className="px-3 pb-3 space-y-2">
|
||||
<DocumentUploadZone
|
||||
files={uploadedFiles}
|
||||
onFilesChange={setUploadedFiles}
|
||||
compact
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[minmax(0,1fr)_minmax(0,520px)]">
|
||||
{/* Document column — sticky on desktop so the receipt stays visible
|
||||
while the form scrolls; stacks above the form on smaller screens. */}
|
||||
<div className="flex h-[45vh] flex-col gap-3 lg:sticky lg:top-0 lg:h-[72vh] lg:self-start">
|
||||
{currentDocId ? (
|
||||
<DocumentViewerPane
|
||||
documentId={currentDocId}
|
||||
mime={currentDocMime}
|
||||
fileName={currentDocName}
|
||||
className="min-h-0 flex-1"
|
||||
/>
|
||||
{pickedInboxDocs.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
{pickedInboxDocs.map((doc) => (
|
||||
<div
|
||||
key={doc.document_id}
|
||||
className="flex items-center gap-2 text-sm py-1.5 px-2 rounded bg-muted/50"
|
||||
>
|
||||
<FileText className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<span className="truncate flex-1">
|
||||
{doc.supplier_name ?? doc.file_name}
|
||||
</span>
|
||||
{doc.amount != null && (
|
||||
<span className="text-xs text-muted-foreground tabular-nums shrink-0">
|
||||
{formatCurrency(doc.amount, doc.currency ?? 'SEK')}
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 shrink-0"
|
||||
aria-label={t('doc_picked_remove')}
|
||||
onClick={() =>
|
||||
setPickedInboxDocs((prev) =>
|
||||
prev.filter((d) => d.document_id !== doc.document_id),
|
||||
)
|
||||
}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={() => setInboxPickerOpen(true)}
|
||||
>
|
||||
<Inbox className="h-4 w-4 mr-2" />
|
||||
{t('doc_pick_existing')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="min-h-0 flex-1">
|
||||
<DocumentUploadZone
|
||||
files={uploadedFiles}
|
||||
onFilesChange={setUploadedFiles}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{bankAccount !== null && (
|
||||
<JournalEntryForm
|
||||
key={`${transaction.id}-${preselectedTemplate?.id ?? 'default'}-${bankAccount}`}
|
||||
embedded
|
||||
initialLines={
|
||||
preselectedTemplate
|
||||
? buildInitialLinesFromTemplate(transaction, preselectedTemplate, bankAccount)
|
||||
: buildInitialLines(transaction, t('bank_line_description'), bankAccount)
|
||||
}
|
||||
initialDate={transaction.date}
|
||||
initialDescription={transaction.description}
|
||||
submitUrl={`/api/transactions/${transaction.id}/book`}
|
||||
sourceType="bank_transaction"
|
||||
sourceId={transaction.id}
|
||||
onEntryCreated={(entryId) => handleBooked(transaction.id, entryId)}
|
||||
/>
|
||||
)}
|
||||
{/* Attach controls — only when the transaction has no pre-linked
|
||||
document (a pre-linked one is already the verifikat's underlag). */}
|
||||
{!preexistingDocId && (
|
||||
<div className="shrink-0 space-y-2">
|
||||
{pickedInboxDocs.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
{pickedInboxDocs.map((doc) => (
|
||||
<div
|
||||
key={doc.document_id}
|
||||
className="flex items-center gap-2 text-sm py-1.5 px-2 rounded bg-muted/50"
|
||||
>
|
||||
<FileText className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<span className="truncate flex-1">
|
||||
{doc.supplier_name ?? doc.file_name}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 shrink-0"
|
||||
aria-label={t('doc_picked_remove')}
|
||||
onClick={() =>
|
||||
setPickedInboxDocs((prev) =>
|
||||
prev.filter((d) => d.document_id !== doc.document_id),
|
||||
)
|
||||
}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setInboxPickerOpen(true)}
|
||||
>
|
||||
<Inbox className="h-4 w-4 mr-2" />
|
||||
{t('doc_pick_existing')}
|
||||
</Button>
|
||||
{inDialogDocId && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setUploadedFiles([])
|
||||
setPickedInboxDocs([])
|
||||
}}
|
||||
>
|
||||
<X className="h-3.5 w-3.5 mr-1.5" />
|
||||
{t('doc_clear')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Booking form */}
|
||||
<div className="space-y-4">
|
||||
{bankAccount !== null && (
|
||||
<JournalEntryForm
|
||||
key={`${transaction.id}-${preselectedTemplate?.id ?? 'default'}-${bankAccount}`}
|
||||
embedded
|
||||
initialLines={
|
||||
preselectedTemplate
|
||||
? buildInitialLinesFromTemplate(transaction, preselectedTemplate, bankAccount)
|
||||
: buildInitialLines(transaction, t('bank_line_description'), bankAccount)
|
||||
}
|
||||
initialDate={transaction.date}
|
||||
initialDescription={transaction.description}
|
||||
submitUrl={`/api/transactions/${transaction.id}/book`}
|
||||
sourceType="bank_transaction"
|
||||
sourceId={transaction.id}
|
||||
onEntryCreated={(entryId) => handleBooked(transaction.id, entryId)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<InboxDocumentPicker
|
||||
open={inboxPickerOpen}
|
||||
|
||||
Reference in New Issue
Block a user