diff --git a/app/(dashboard)/bookkeeping/[id]/page.tsx b/app/(dashboard)/bookkeeping/[id]/page.tsx
index 11d298d2..d83a9c43 100644
--- a/app/(dashboard)/bookkeeping/[id]/page.tsx
+++ b/app/(dashboard)/bookkeeping/[id]/page.tsx
@@ -8,13 +8,20 @@ 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 } from 'lucide-react'
+import { Loader2, ArrowLeft, Paperclip, AlertTriangle, Lock, MessageSquare, Pencil, Check, X, Copy, ChevronDown, CalendarClock } from 'lucide-react'
+import {
+ DropdownMenu,
+ DropdownMenuTrigger,
+ DropdownMenuContent,
+ DropdownMenuItem,
+} from '@/components/ui/dropdown-menu'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import { formatDate } from '@/lib/utils'
import { formatVoucher } from '@/lib/bookkeeping/voucher-series-resolver'
import JournalEntryAttachments from '@/components/bookkeeping/JournalEntryAttachments'
import JournalEntryStatusBadge, { useSourceTypeLabels } from '@/components/bookkeeping/JournalEntryStatusBadge'
import CorrectionEntryDialog from '@/components/bookkeeping/CorrectionEntryDialog'
+import RecordateEntryDialog from '@/components/bookkeeping/RecordateEntryDialog'
import CorrectionChain from '@/components/bookkeeping/CorrectionChain'
import { ConfirmationDialog } from '@/components/ui/confirmation-dialog'
import { useToast } from '@/components/ui/use-toast'
@@ -33,6 +40,7 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState(null)
const [showCorrection, setShowCorrection] = useState(false)
+ const [showRecordate, setShowRecordate] = useState(false)
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
const [isCommitting, setIsCommitting] = useState(false)
@@ -238,17 +246,31 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i
)}
{canCorrect && (
- setShowCorrection(true)}
- disabled={!canWrite}
- title={!canWrite ? t('read_only_tooltip') : undefined}
- >
- {!canWrite ? : }
- {t('edit_entry')}
-
+
+
+
+ {!canWrite ? : }
+ {t('correct_menu')}
+
+
+
+
+ setShowCorrection(true)}>
+
+ {t('correct_lines')}
+
+ setShowRecordate(true)}>
+
+ {t('correct_date')}
+
+
+
)}
{entry.status === 'posted' && (
)}
+ {/* Recordate (move to correct date) dialog */}
+ {showRecordate && entry && (
+ {
+ setShowRecordate(false)
+ fetchData()
+ }}
+ />
+ )}
+
{/* Delete confirmation dialog */}
('upload')
const [bankIsLoading, setBankIsLoading] = useState(false)
const [bankError, setBankError] = useState(null)
+ const [bankErrorTitle, setBankErrorTitle] = useState(null)
// Parse results
const [parseResult, setParseResult] = useState(null)
@@ -145,6 +146,7 @@ function BankFileImportWizard() {
const handleFileSelect = useCallback(async (file: File, formatOverride?: BankFileFormatId) => {
setBankError(null)
+ setBankErrorTitle(null)
setBankIsLoading(true)
try {
@@ -162,10 +164,25 @@ function BankFileImportWizard() {
const data = await res.json()
if (!res.ok) {
- if (data.error === 'duplicate') {
- setBankError(data.message)
+ // Structured error envelope: { error: { code, message, message_en, details } }
+ const err = data?.error
+ if (err && typeof err === 'object') {
+ if (err.code === 'BANK_FILE_DUPLICATE') {
+ const importedAt = err.details?.importedAt ? formatDate(err.details.importedAt) : null
+ const count = typeof err.details?.importedCount === 'number' ? err.details.importedCount : null
+ const when = importedAt
+ ? ` ${importedAt}${count !== null ? ` (${count} transaktioner)` : ''}`
+ : ''
+ setBankErrorTitle('Filen är redan importerad')
+ setBankError(
+ `Den här filen är redan importerad${when}. Transaktionerna finns redan under Transaktioner. ` +
+ 'Exportera en ny fil från banken om du vill lägga till fler transaktioner.'
+ )
+ } else {
+ setBankError(err.message || 'Kunde inte läsa filen')
+ }
} else {
- setBankError(data.error || 'Kunde inte läsa filen')
+ setBankError(typeof err === 'string' ? err : 'Kunde inte läsa filen')
}
return
}
@@ -259,6 +276,7 @@ function BankFileImportWizard() {
setFilename('')
setIngestResult(null)
setBankError(null)
+ setBankErrorTitle(null)
setRawFileContent('')
}
@@ -314,6 +332,7 @@ function BankFileImportWizard() {
onFileSelect={handleFileSelect}
isLoading={bankIsLoading}
error={bankError}
+ errorTitle={bankErrorTitle}
detectedFormat={detectedFormat}
detectedFormatName={detectedFormatName}
/>
diff --git a/app/(dashboard)/supplier-invoices/new/page.tsx b/app/(dashboard)/supplier-invoices/new/page.tsx
index 1364587b..3d969a35 100644
--- a/app/(dashboard)/supplier-invoices/new/page.tsx
+++ b/app/(dashboard)/supplier-invoices/new/page.tsx
@@ -24,8 +24,9 @@ import { cn, formatCurrency } from '@/lib/utils'
import { useUnsavedChanges } from '@/lib/hooks/use-unsaved-changes'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import BankTransactionPicker from '@/components/transactions/BankTransactionPicker'
-import { ArrowLeft, Plus, Trash2, ChevronDown, Loader2, Lock, AlertCircle, MessageCircle, Link2 } from 'lucide-react'
-import type { Supplier, BASAccount, VatTreatment, EntityType, InvoiceExtractionResult } from '@/types'
+import CreatePeriodDialog from '@/components/bookkeeping/CreatePeriodDialog'
+import { ArrowLeft, Plus, Trash2, ChevronDown, Loader2, Lock, AlertCircle, AlertTriangle, CalendarPlus, MessageCircle, Link2 } from 'lucide-react'
+import type { Supplier, BASAccount, VatTreatment, EntityType, InvoiceExtractionResult, FiscalPeriod } from '@/types'
interface LineItem {
description: string
@@ -217,6 +218,10 @@ export default function NewSupplierInvoicePage() {
const [suppliersLoaded, setSuppliersLoaded] = useState(false)
const [accounts, setAccounts] = useState([])
const [entityType, setEntityType] = useState('enskild_firma')
+ const [accountingMethod, setAccountingMethod] = useState<'accrual' | 'cash'>('accrual')
+ const [periods, setPeriods] = useState([])
+ const [periodsLoaded, setPeriodsLoaded] = useState(false)
+ const [showCreatePeriod, setShowCreatePeriod] = useState(false)
const [isSubmitting, setIsSubmitting] = useState(false)
const [showReview, setShowReview] = useState(false)
const [pendingData, setPendingData] = useState(null)
@@ -300,10 +305,23 @@ export default function NewSupplierInvoicePage() {
const isEF = entityType === 'enskild_firma'
+ // Out-of-period guard (mirrors the manual voucher form). A registration JE is
+ // only posted at registration time under the accrual method or when the
+ // invoice is marked paid privately — cash method books at payment, so an
+ // out-of-period date is fine there and we stay quiet. periodsLoaded gates the
+ // warning so it never flashes before the fiscal periods have been fetched.
+ const willBookAtRegistration = accountingMethod === 'accrual' || watchedPaidPrivately
+ const invoiceDateOutsidePeriod =
+ periodsLoaded &&
+ !!watchedInvoiceDate &&
+ !periods.some((p) => watchedInvoiceDate >= p.period_start && watchedInvoiceDate <= p.period_end)
+ const showNoPeriodWarning = willBookAtRegistration && invoiceDateOutsidePeriod
+
useEffect(() => {
fetchSuppliers()
fetchAccounts()
fetchEntityType()
+ fetchPeriods()
}, [])
// One-shot: load inbox item and prefill form. Runs after suppliers are
@@ -508,8 +526,25 @@ export default function NewSupplierInvoicePage() {
const res = await fetch('/api/settings')
const { data } = await res.json()
if (data?.entity_type) setEntityType(data.entity_type)
+ // Cash method books at payment, not registration — drives whether the
+ // out-of-period warning is relevant (see willBookAtRegistration below).
+ if (data?.accounting_method === 'cash' || data?.accounting_method === 'accrual') {
+ setAccountingMethod(data.accounting_method)
+ }
} catch {
- // Default to enskild_firma
+ // Default to enskild_firma / accrual
+ }
+ }
+
+ async function fetchPeriods() {
+ try {
+ const res = await fetch('/api/bookkeeping/fiscal-periods')
+ const { data } = await res.json()
+ setPeriods(data || [])
+ } catch {
+ // Non-critical — the server still hard-blocks an out-of-period booking.
+ } finally {
+ setPeriodsLoaded(true)
}
}
@@ -1129,6 +1164,26 @@ export default function NewSupplierInvoicePage() {
>
)}
+
+ {showNoPeriodWarning && (
+
+
+
+
{t('no_period_warning', { date: watchedInvoiceDate })}
+
{t('no_period_help')}
+
+
setShowCreatePeriod(true)}
+ className="shrink-0"
+ >
+
+ {t('create_period')}
+
+
+ )}
@@ -1678,6 +1733,14 @@ export default function NewSupplierInvoicePage() {
+
+
)
}
diff --git a/app/(dashboard)/transactions/page.tsx b/app/(dashboard)/transactions/page.tsx
index af8afff2..8bfd6c00 100644
--- a/app/(dashboard)/transactions/page.tsx
+++ b/app/(dashboard)/transactions/page.tsx
@@ -39,6 +39,7 @@ import MatchAllocationDialog from '@/components/transactions/MatchAllocationDial
import BulkBookDialog from '@/components/transactions/BulkBookDialog'
import TransactionBookingDialog from '@/components/transactions/TransactionBookingDialog'
import QuickReviewDialog from '@/components/transactions/QuickReviewDialog'
+import EditTransactionTitleDialog from '@/components/transactions/EditTransactionTitleDialog'
import TemplatePicker from '@/components/transactions/TemplatePicker'
import { getDefaultAccountForCategory, getDefaultVatTreatmentForCategory } from '@/lib/bookkeeping/category-mapping'
@@ -196,6 +197,8 @@ export default function TransactionsPage() {
const { toast } = useToast()
const { dialogProps: deleteDialogProps, confirm: confirmDelete } = useDestructiveConfirm()
+ // Bank transaction whose title is being edited (null = dialog closed).
+ const [editTitleTarget, setEditTitleTarget] = useState(null)
const supabase = createClient()
const searchParams = useSearchParams()
const highlightId = searchParams.get('highlight')
@@ -1236,6 +1239,46 @@ export default function TransactionsPage() {
}
}
+ function openEditTitleDialog(transaction: TransactionWithInvoice) {
+ setEditTitleTarget(transaction)
+ }
+
+ // Persist a new title via PATCH. Returns true on success so the dialog can
+ // close; updates the local list optimistically (description + edited tag).
+ async function handleSaveTitle(description: string): Promise {
+ const target = editTitleTarget
+ if (!target) return false
+ try {
+ const response = await fetch(`/api/transactions/${target.id}`, {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ description }),
+ })
+ const result = await response.json()
+ if (!response.ok) {
+ toast({
+ title: t('edit_title_failed'),
+ description: getErrorMessage(result, { context: 'transaction' }),
+ variant: 'destructive',
+ })
+ return false
+ }
+ const updated = result.data as { description: string; title_edited_at: string | null }
+ setTransactions((prev) =>
+ prev.map((tx) =>
+ tx.id === target.id
+ ? { ...tx, description: updated.description, title_edited_at: updated.title_edited_at }
+ : tx,
+ ),
+ )
+ toast({ title: t('edit_title_saved') })
+ return true
+ } catch {
+ toast({ title: t('edit_title_failed'), variant: 'destructive' })
+ return false
+ }
+ }
+
async function handleSkvBokfor(row: StoredSkattekontoTransaction) {
setSkvProcessingId(row.id)
try {
@@ -1702,6 +1745,7 @@ export default function TransactionsPage() {
onOpenSplitMatch={openSplitMatchDialog}
onOpenCategoryDialog={openCategoryDialog}
onDelete={handleDeleteTransaction}
+ onEditTitle={openEditTitleDialog}
onToggleSelect={toggleBatchSelect}
/>
) : (
@@ -1985,6 +2029,16 @@ export default function TransactionsPage() {
+ {
+ if (!v) setEditTitleTarget(null)
+ }}
+ currentTitle={editTitleTarget?.description ?? ''}
+ originalTitle={editTitleTarget?.original_description ?? null}
+ onSave={handleSaveTitle}
+ />
+
" before a
+ * write is attempted. Mirrors resolvePeriodStatusForDate / the DB triggers.
+ */
+export async function GET(request: Request) {
+ const supabase = await createClient()
+ const { data: { user } } = await supabase.auth.getUser()
+ if (!user) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
+ }
+
+ const date = new URL(request.url).searchParams.get('date')
+ if (!date || !/^\d{4}-\d{2}-\d{2}$/.test(date)) {
+ return NextResponse.json({ error: 'Ogiltigt datum (förväntat ÅÅÅÅ-MM-DD)' }, { status: 400 })
+ }
+
+ const companyId = await requireCompanyId(supabase, user.id)
+
+ try {
+ const status = await resolvePeriodStatusForDate(supabase, companyId, date)
+
+ let period_name: string | null = null
+ if (status.period_id) {
+ const { data: period } = await supabase
+ .from('fiscal_periods')
+ .select('name')
+ .eq('id', status.period_id)
+ .eq('company_id', companyId)
+ .maybeSingle()
+ period_name = period?.name ?? null
+ }
+
+ return NextResponse.json({
+ data: {
+ status: status.status,
+ period_id: status.period_id,
+ lock_date: status.lock_date,
+ period_name,
+ },
+ })
+ } catch (err) {
+ return NextResponse.json(
+ {
+ error: {
+ code: 'PERIOD_STATUS_ERROR',
+ message: err instanceof Error ? err.message : 'Kunde inte hämta periodstatus',
+ },
+ },
+ { status: 500 }
+ )
+ }
+}
diff --git a/app/api/bookkeeping/journal-entries/[id]/recordate/__tests__/route.test.ts b/app/api/bookkeeping/journal-entries/[id]/recordate/__tests__/route.test.ts
new file mode 100644
index 00000000..d9716ce8
--- /dev/null
+++ b/app/api/bookkeeping/journal-entries/[id]/recordate/__tests__/route.test.ts
@@ -0,0 +1,142 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import {
+ createMockRequest,
+ parseJsonResponse,
+ createMockRouteParams,
+ makeJournalEntry,
+} from '@/tests/helpers'
+import { TargetPeriodLockedError, MeaninglessCorrectionError } from '@/lib/bookkeeping/errors'
+
+const mockCreateClient = vi.fn()
+vi.mock('@/lib/supabase/server', () => ({
+ createClient: () => mockCreateClient(),
+}))
+
+vi.mock('@/lib/init', () => ({
+ ensureInitialized: vi.fn(),
+}))
+
+vi.mock('@/lib/company/context', () => ({
+ requireCompanyId: vi.fn().mockResolvedValue('company-1'),
+ getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
+}))
+
+vi.mock('@/lib/auth/require-write', () => ({
+ requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
+}))
+
+const mockRecordateEntry = vi.fn()
+vi.mock('@/lib/core/bookkeeping/storno-service', () => ({
+ recordateEntry: (...args: unknown[]) => mockRecordateEntry(...args),
+}))
+
+import { POST } from '../route'
+
+describe('POST /api/bookkeeping/journal-entries/[id]/recordate', () => {
+ const mockUser = { id: 'user-1', email: 'test@test.se' }
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockCreateClient.mockResolvedValue({
+ auth: { getUser: vi.fn().mockResolvedValue({ data: { user: mockUser } }) },
+ })
+ })
+
+ it('returns 401 when not authenticated', async () => {
+ mockCreateClient.mockResolvedValue({
+ auth: { getUser: vi.fn().mockResolvedValue({ data: { user: null } }) },
+ })
+
+ const request = createMockRequest('/api/bookkeeping/journal-entries/entry-1/recordate', {
+ method: 'POST',
+ body: { new_entry_date: '2025-07-03' },
+ })
+ const response = await POST(request, createMockRouteParams({ id: 'entry-1' }))
+ const { status, body } = await parseJsonResponse(response)
+
+ expect(status).toBe(401)
+ expect(body).toEqual({ error: 'Unauthorized' })
+ })
+
+ it('returns 400 when new_entry_date is missing', async () => {
+ const request = createMockRequest('/api/bookkeeping/journal-entries/entry-1/recordate', {
+ method: 'POST',
+ body: {},
+ })
+ const response = await POST(request, createMockRouteParams({ id: 'entry-1' }))
+ const { status, body } = await parseJsonResponse<{ error: string }>(response)
+
+ expect(status).toBe(400)
+ expect(body.error).toBe('Validation failed')
+ })
+
+ it('returns 400 when new_entry_date is not an ISO date', async () => {
+ const request = createMockRequest('/api/bookkeeping/journal-entries/entry-1/recordate', {
+ method: 'POST',
+ body: { new_entry_date: '03/07/2025' },
+ })
+ const response = await POST(request, createMockRouteParams({ id: 'entry-1' }))
+ const { status, body } = await parseJsonResponse<{ error: string }>(response)
+
+ expect(status).toBe(400)
+ expect(body.error).toBe('Validation failed')
+ })
+
+ it('returns reversal and corrected entries on success', async () => {
+ const reversal = makeJournalEntry({ id: 'reversal-1', reverses_id: 'entry-1', source_type: 'storno' })
+ const corrected = makeJournalEntry({
+ id: 'corrected-1',
+ correction_of_id: 'entry-1',
+ source_type: 'correction',
+ entry_date: '2025-07-03',
+ })
+ mockRecordateEntry.mockResolvedValue({ reversal, corrected })
+
+ const request = createMockRequest('/api/bookkeeping/journal-entries/entry-1/recordate', {
+ method: 'POST',
+ body: { new_entry_date: '2025-07-03' },
+ })
+ const response = await POST(request, createMockRouteParams({ id: 'entry-1' }))
+ const { status, body } = await parseJsonResponse<{ data: { reversal: unknown; corrected: unknown } }>(response)
+
+ expect(status).toBe(200)
+ expect(body.data.corrected).toEqual(corrected)
+ expect(mockRecordateEntry).toHaveBeenCalledWith(
+ expect.anything(),
+ 'company-1',
+ 'user-1',
+ 'entry-1',
+ '2025-07-03'
+ )
+ })
+
+ it('maps a no-op move (same date) to a 400 with the typed reason', async () => {
+ mockRecordateEntry.mockRejectedValue(new MeaninglessCorrectionError('no_date_change'))
+
+ const request = createMockRequest('/api/bookkeeping/journal-entries/entry-1/recordate', {
+ method: 'POST',
+ body: { new_entry_date: '2026-07-03' },
+ })
+ const response = await POST(request, createMockRouteParams({ id: 'entry-1' }))
+ const { status, body } = await parseJsonResponse<{ error: { code: string; details: { reason: string } } }>(response)
+
+ expect(status).toBe(400)
+ expect(body.error.code).toBe('MEANINGLESS_CORRECTION')
+ expect(body.error.details.reason).toBe('no_date_change')
+ })
+
+ it('maps a locked target period to a 409 with the typed code', async () => {
+ mockRecordateEntry.mockRejectedValue(new TargetPeriodLockedError('2025-07-03', '2025-12-31'))
+
+ const request = createMockRequest('/api/bookkeeping/journal-entries/entry-1/recordate', {
+ method: 'POST',
+ body: { new_entry_date: '2025-07-03' },
+ })
+ const response = await POST(request, createMockRouteParams({ id: 'entry-1' }))
+ const { status, body } = await parseJsonResponse<{ error: { code: string; details: { lockDate: string } } }>(response)
+
+ expect(status).toBe(409)
+ expect(body.error.code).toBe('TARGET_PERIOD_LOCKED')
+ expect(body.error.details.lockDate).toBe('2025-12-31')
+ })
+})
diff --git a/app/api/bookkeeping/journal-entries/[id]/recordate/route.ts b/app/api/bookkeeping/journal-entries/[id]/recordate/route.ts
new file mode 100644
index 00000000..fc7d3a89
--- /dev/null
+++ b/app/api/bookkeeping/journal-entries/[id]/recordate/route.ts
@@ -0,0 +1,47 @@
+import { createClient } from '@/lib/supabase/server'
+import { NextResponse } from 'next/server'
+import { recordateEntry } from '@/lib/core/bookkeeping/storno-service'
+import { bookkeepingErrorResponse } from '@/lib/bookkeeping/errors'
+import { ensureInitialized } from '@/lib/init'
+import { validateBody } from '@/lib/api/validate'
+import { RecordateJournalEntrySchema } from '@/lib/api/schemas'
+import { requireCompanyId } from '@/lib/company/context'
+import { requireWritePermission } from '@/lib/auth/require-write'
+
+ensureInitialized()
+
+export async function POST(
+ request: Request,
+ { params }: { params: Promise<{ id: string }> }
+) {
+ const { id } = await params
+ const supabase = await createClient()
+ const { data: { user } } = await supabase.auth.getUser()
+
+ if (!user) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
+ }
+
+ const writeCheck = await requireWritePermission(supabase, user.id)
+ if (!writeCheck.ok) return writeCheck.response
+
+ const companyId = await requireCompanyId(supabase, user.id)
+
+ const validation = await validateBody(request, RecordateJournalEntrySchema)
+ if (!validation.success) return validation.response
+ const body = validation.data
+
+ try {
+ const result = await recordateEntry(supabase, companyId, user.id, id, body.new_entry_date)
+ return NextResponse.json({ data: result })
+ } catch (err) {
+ const typed = bookkeepingErrorResponse(err)
+ if (typed) return typed
+ // Not a recognized domain error — an unexpected server fault, not a client
+ // error, so surface it as 500.
+ return NextResponse.json(
+ { error: err instanceof Error ? err.message : 'Failed to move entry' },
+ { status: 500 }
+ )
+ }
+}
diff --git a/app/api/settings/booking-templates/route.ts b/app/api/settings/booking-templates/route.ts
index 3fa5814c..c8c68aa2 100644
--- a/app/api/settings/booking-templates/route.ts
+++ b/app/api/settings/booking-templates/route.ts
@@ -5,6 +5,12 @@ import { requireWritePermission } from '@/lib/auth/require-write'
import { z } from 'zod'
import { validateBody } from '@/lib/api/validate'
+// The GET scope below builds a PostgREST .or() filter by string interpolation.
+// Guard every interpolated id against a strict UUID shape so a tainted value
+// can never inject filter syntax. Both ids are server-derived (companyId from
+// membership, teamId from a DB column), so this is defense-in-depth.
+const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
+
const BookingTemplateLineSchema = z.object({
account: z.string().regex(/^\d{4}$/),
label: z.string().min(1),
@@ -43,12 +49,39 @@ export async function GET() {
const companyId = await requireCompanyId(supabase, user.id)
- // RLS handles scoping (system OR company OR team)
+ // Resolve the team this company belongs to (if any) so team-shared
+ // templates stay visible while this company is selected.
+ const { data: company } = await supabase
+ .from('companies')
+ .select('team_id')
+ .eq('id', companyId)
+ .maybeSingle()
+ const teamId = company?.team_id ?? null
+
+ // requireCompanyId only ever returns a real membership UUID, but assert the
+ // shape before interpolating it into the .or() filter.
+ if (!UUID_RE.test(companyId)) {
+ return NextResponse.json({ error: 'Invalid company context' }, { status: 400 })
+ }
+
+ // Scope to the SELECTED company: system + this company + this company's team.
+ // RLS (btl_select) is membership-wide — it returns templates from *every*
+ // company the user belongs to — so the active-company narrowing must happen
+ // here in the API layer (mirrors counterparty-templates). Without this, a
+ // user who owns several companies sees all of their templates merged.
+ // Only interpolate a team id that passes the strict UUID guard.
+ const scope = [
+ 'is_system.eq.true',
+ `company_id.eq.${companyId}`,
+ ...(teamId && UUID_RE.test(teamId) ? [`team_id.eq.${teamId}`] : []),
+ ].join(',')
+
const [templatesRes, usageRes] = await Promise.all([
supabase
.from('booking_template_library')
.select('*')
.eq('is_active', true)
+ .or(scope)
.order('category')
.order('name'),
supabase
diff --git a/app/api/supplier-invoices/__tests__/route.test.ts b/app/api/supplier-invoices/__tests__/route.test.ts
index 20231d37..020f7d31 100644
--- a/app/api/supplier-invoices/__tests__/route.test.ts
+++ b/app/api/supplier-invoices/__tests__/route.test.ts
@@ -305,6 +305,45 @@ describe('POST /api/supplier-invoices', () => {
expect((body.error as unknown as { code: string }).code).toBe('SI_CREATE_FAILED')
})
+ it('rolls back and returns SI_CREATE_NO_FISCAL_PERIOD when invoice_date is outside every fiscal period', async () => {
+ const supplier = makeSupplier({ id: VALID_UUID })
+ const createdInvoice = makeSupplierInvoice({ id: 'si-1', invoice_date: '2099-06-01' })
+
+ // Fetch supplier
+ enqueue({ data: supplier, error: null })
+ // RPC get_next_arrival_number
+ enqueue({ data: 9 })
+ // Insert invoice
+ enqueue({ data: createdInvoice, error: null })
+ // Insert items
+ enqueue({ data: null, error: null })
+ // Fetch company settings → accrual, so a registration JE is attempted
+ enqueue({ data: { accounting_method: 'accrual' }, error: null })
+ // Engine returns null because no fiscal period covers 2099-06-01
+ mockCreateSupplierInvoiceRegistrationEntry.mockResolvedValue(null)
+ // Rollback: delete the orphan invoice (items cascade)
+ enqueue({ data: null, error: null })
+
+ const request = createMockRequest('/api/supplier-invoices', {
+ method: 'POST',
+ body: {
+ supplier_id: VALID_UUID,
+ supplier_invoice_number: 'LF-NOFY',
+ invoice_date: '2099-06-01',
+ due_date: '2099-07-01',
+ items: [{ description: 'Material', quantity: 1, unit_price: 8000, account_number: '4010' }],
+ },
+ })
+ const response = await POST(request)
+ const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
+
+ expect(status).toBe(400)
+ expect(body.error.code).toBe('SI_CREATE_NO_FISCAL_PERIOD')
+ expect(mockCreateSupplierInvoiceRegistrationEntry).toHaveBeenCalled()
+ // The orphan must be rolled back — the delete is the 6th queued call.
+ expect(mockSupabase.from).toHaveBeenCalledWith('supplier_invoices')
+ })
+
it('returns 409 with credit chain on duplicate supplier_invoice_number for credited original', async () => {
const supplier = makeSupplier({ id: VALID_UUID })
diff --git a/app/api/supplier-invoices/route.ts b/app/api/supplier-invoices/route.ts
index 7dc867d5..c55bc149 100644
--- a/app/api/supplier-invoices/route.ts
+++ b/app/api/supplier-invoices/route.ts
@@ -329,6 +329,17 @@ export const POST = withRouteContext(
journal_entry_id: journalEntry.id,
notes: 'Eget utlägg — betalat privat',
})
+ } else {
+ // createSupplierInvoicePrivatelyPaidEntry returns null ONLY when no
+ // fiscal period covers invoice_date (every other failure throws and
+ // lands in the catch below). Without this branch the invoice would be
+ // saved as status='paid' with no verifikat — a silent orphan. Roll
+ // back and surface an actionable error, per the fatal-orphan note above.
+ await supabase.from('supplier_invoices').delete().eq('id', invoice.id).eq('company_id', companyId)
+ return errorResponseFromCode('SI_CREATE_NO_FISCAL_PERIOD', log, {
+ requestId,
+ details: { invoiceDate: invoice.invoice_date },
+ })
}
} catch (err) {
await supabase.from('supplier_invoices').delete().eq('id', invoice.id).eq('company_id', companyId)
@@ -363,6 +374,19 @@ export const POST = withRouteContext(
.from('supplier_invoices')
.update({ registration_journal_entry_id: journalEntry.id })
.eq('id', invoice.id)
+ } else {
+ // createSupplierInvoiceRegistrationEntry returns null ONLY when no
+ // fiscal period covers invoice_date (every other failure throws and
+ // lands in the catch below). An orphan supplier_invoices row without a
+ // registration JE silently understates leverantörsskuld (2440) and
+ // ingående moms (2641) for the momsdeklaration — exactly the fatal
+ // case the note above warns about. Roll back and surface an
+ // actionable error instead of returning 200.
+ await supabase.from('supplier_invoices').delete().eq('id', invoice.id).eq('company_id', companyId)
+ return errorResponseFromCode('SI_CREATE_NO_FISCAL_PERIOD', log, {
+ requestId,
+ details: { invoiceDate: invoice.invoice_date },
+ })
}
} catch (err) {
await supabase.from('supplier_invoices').delete().eq('id', invoice.id).eq('company_id', companyId)
diff --git a/app/api/transactions/[id]/__tests__/route.test.ts b/app/api/transactions/[id]/__tests__/route.test.ts
index 421569c1..1dbef665 100644
--- a/app/api/transactions/[id]/__tests__/route.test.ts
+++ b/app/api/transactions/[id]/__tests__/route.test.ts
@@ -20,7 +20,19 @@ vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
}))
-import { DELETE } from '../route'
+// PATCH (edit title) goes through withRouteContext → requireAuth.
+vi.mock('@/lib/auth/require-auth', () => ({
+ requireAuth: vi.fn(),
+}))
+
+vi.mock('@/lib/sandbox/guard', () => ({
+ guardSandbox: vi.fn(),
+}))
+
+import { DELETE, PATCH } from '../route'
+import { requireAuth } from '@/lib/auth/require-auth'
+import { guardSandbox } from '@/lib/sandbox/guard'
+import { NextResponse } from 'next/server'
describe('DELETE /api/transactions/[id]', () => {
const mockUser = { id: 'user-1', email: 'test@test.se' }
@@ -117,3 +129,158 @@ describe('DELETE /api/transactions/[id]', () => {
expect(body).toEqual({ error: 'Failed to delete transaction' })
})
})
+
+describe('PATCH /api/transactions/[id] (edit title)', () => {
+ const mockUser = { id: 'user-1', email: 'test@test.se' }
+
+ function patchReq(body: unknown) {
+ return new Request('http://localhost/api/transactions/tx-1', {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ })
+ }
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ reset()
+ vi.mocked(requireAuth).mockResolvedValue({
+ user: mockUser as never,
+ supabase: mockSupabase as never,
+ error: null,
+ })
+ vi.mocked(guardSandbox).mockResolvedValue(null)
+ })
+
+ it('returns 401 when not authenticated', async () => {
+ vi.mocked(requireAuth).mockResolvedValue({
+ user: null as never,
+ supabase: mockSupabase as never,
+ error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
+ })
+
+ const res = await PATCH(patchReq({ description: 'Ny titel' }), createMockRouteParams({ id: 'tx-1' }))
+ const { status } = await parseJsonResponse(res)
+ expect(status).toBe(401)
+ })
+
+ it('returns 400 when the title is empty / whitespace-only', async () => {
+ const res = await PATCH(patchReq({ description: ' ' }), createMockRouteParams({ id: 'tx-1' }))
+ const { status } = await parseJsonResponse(res)
+ expect(status).toBe(400)
+ })
+
+ it('returns 404 when the transaction is not found', async () => {
+ enqueue({ data: null, error: { message: 'Not found' } })
+
+ const res = await PATCH(patchReq({ description: 'Ny titel' }), createMockRouteParams({ id: 'tx-1' }))
+ const { status } = await parseJsonResponse(res)
+ expect(status).toBe(404)
+ })
+
+ it('returns 409 when the transaction is booked', async () => {
+ enqueue({
+ data: {
+ id: 'tx-1',
+ description: 'X',
+ original_description: 'X',
+ journal_entry_id: 'je-1',
+ invoice_id: null,
+ supplier_invoice_id: null,
+ },
+ error: null,
+ })
+
+ const res = await PATCH(patchReq({ description: 'Ny titel' }), createMockRouteParams({ id: 'tx-1' }))
+ const { status, body } = await parseJsonResponse<{ error: { code: string } }>(res)
+ expect(status).toBe(409)
+ expect(body.error.code).toBe('TRANSACTION_TITLE_LOCKED')
+ })
+
+ it('returns 409 when matched to an invoice even if journal_entry_id is null', async () => {
+ enqueue({
+ data: {
+ id: 'tx-1',
+ description: 'X',
+ original_description: 'X',
+ journal_entry_id: null,
+ invoice_id: 'inv-1',
+ supplier_invoice_id: null,
+ },
+ error: null,
+ })
+
+ const res = await PATCH(patchReq({ description: 'Ny titel' }), createMockRouteParams({ id: 'tx-1' }))
+ const { status } = await parseJsonResponse(res)
+ expect(status).toBe(409)
+ })
+
+ it('updates the title for an editable (unbooked, unmatched) transaction', async () => {
+ enqueue({
+ data: {
+ id: 'tx-1',
+ description: 'ICA',
+ original_description: 'ICA',
+ journal_entry_id: null,
+ invoice_id: null,
+ supplier_invoice_id: null,
+ },
+ error: null,
+ }) // fetch
+ enqueue({
+ data: { id: 'tx-1', description: 'Lunch med kund', title_edited_at: '2026-06-01T10:00:00Z' },
+ error: null,
+ }) // update
+
+ const res = await PATCH(
+ patchReq({ description: 'Lunch med kund' }),
+ createMockRouteParams({ id: 'tx-1' }),
+ )
+ const { status, body } = await parseJsonResponse<{ data: { description: string } }>(res)
+ expect(status).toBe(200)
+ expect(body.data.description).toBe('Lunch med kund')
+ })
+
+ it('restores the original title (200) when the new title equals original_description', async () => {
+ enqueue({
+ data: {
+ id: 'tx-1',
+ description: 'Lunch med kund',
+ original_description: 'ICA MAXI',
+ journal_entry_id: null,
+ invoice_id: null,
+ supplier_invoice_id: null,
+ },
+ error: null,
+ }) // fetch
+ enqueue({
+ data: { id: 'tx-1', description: 'ICA MAXI', title_edited_at: null },
+ error: null,
+ }) // update
+
+ const res = await PATCH(patchReq({ description: 'ICA MAXI' }), createMockRouteParams({ id: 'tx-1' }))
+ const { status, body } = await parseJsonResponse<{ data: { title_edited_at: string | null } }>(res)
+ expect(status).toBe(200)
+ expect(body.data.title_edited_at).toBeNull()
+ })
+
+ it('returns 409 when the row is matched/booked between read and write (optimistic-lock miss)', async () => {
+ enqueue({
+ data: {
+ id: 'tx-1',
+ description: 'ICA',
+ original_description: 'ICA',
+ journal_entry_id: null,
+ invoice_id: null,
+ supplier_invoice_id: null,
+ },
+ error: null,
+ }) // fetch passes the read gate
+ enqueue({ data: null, error: null }) // UPDATE affects 0 rows (gate re-assert failed)
+
+ const res = await PATCH(patchReq({ description: 'Ny titel' }), createMockRouteParams({ id: 'tx-1' }))
+ const { status, body } = await parseJsonResponse<{ error: { code: string } }>(res)
+ expect(status).toBe(409)
+ expect(body.error.code).toBe('TRANSACTION_TITLE_LOCKED')
+ })
+})
diff --git a/app/api/transactions/[id]/route.ts b/app/api/transactions/[id]/route.ts
index 408b773c..f1b608c7 100644
--- a/app/api/transactions/[id]/route.ts
+++ b/app/api/transactions/[id]/route.ts
@@ -2,6 +2,12 @@ import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
+import { withRouteContext } from '@/lib/api/with-route-context'
+import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
+import { validateBody } from '@/lib/api/validate'
+import { UpdateTransactionTitleSchema } from '@/lib/api/schemas'
+import { guardSandbox } from '@/lib/sandbox/guard'
+import type { Transaction } from '@/types'
export async function DELETE(
_request: Request,
@@ -52,3 +58,101 @@ export async function DELETE(
return NextResponse.json({ success: true })
}
+
+/**
+ * Edit a bank transaction's title (description).
+ *
+ * Legal under BFL only while the row is a mutable staging label — i.e. NOT yet
+ * booked into a verifikat and NOT confirmed-matched to an invoice. Once booked
+ * the description is räkenskapsinformation and corrections go through storno
+ * (reverseEntry/correctEntry), so this route hard-blocks those rows. The bank's
+ * original title is preserved immutably in original_description (set at ingest)
+ * and is never written here; passing it back restores the "not edited" tag.
+ */
+export const PATCH = withRouteContext(
+ 'transaction.updateTitle',
+ async (request, ctx, { params }: { params: Promise<{ id: string }> }) => {
+ const { id } = await params
+ const { supabase, companyId, log, requestId, user } = ctx
+
+ const blocked = await guardSandbox(supabase, companyId)
+ if (blocked) return blocked
+
+ const validation = await validateBody(request, UpdateTransactionTitleSchema, {
+ log,
+ operation: 'transaction.updateTitle',
+ })
+ if (!validation.success) return validation.response
+ const { description } = validation.data
+
+ const { data: transaction, error: fetchError } = await supabase
+ .from('transactions')
+ .select('id, description, original_description, journal_entry_id, invoice_id, supplier_invoice_id')
+ .eq('id', id)
+ .eq('company_id', companyId)
+ .single()
+
+ if (fetchError || !transaction) {
+ return errorResponseFromCode('TX_CATEGORIZE_TX_NOT_FOUND', log, { requestId })
+ }
+
+ // Gate: editable only when neither booked nor confirmed-matched. (A
+ // confirmed invoice/supplier-invoice match also sets journal_entry_id, but
+ // we check all three for defense-in-depth.) An unbooked row has no fiscal
+ // period, so the period-lock requirement is satisfied implicitly.
+ if (transaction.journal_entry_id || transaction.invoice_id || transaction.supplier_invoice_id) {
+ return errorResponseFromCode('TRANSACTION_TITLE_LOCKED', log, { requestId })
+ }
+
+ // Restoring to the bank original clears the "edited" tag; any other value
+ // marks the title as user-edited. Compare against the TRIMMED original (the
+ // incoming description is already trimmed by the schema) so a legacy
+ // original carrying surrounding whitespace still restores cleanly.
+ const isRestore =
+ transaction.original_description != null &&
+ description === transaction.original_description.trim()
+ const titleEditedAt = isRestore ? null : new Date().toISOString()
+
+ const { data: updated, error: updateError } = await supabase
+ .from('transactions')
+ .update({ description, title_edited_at: titleEditedAt })
+ .eq('id', id)
+ .eq('company_id', companyId)
+ // Re-assert the FULL editable gate atomically against a concurrent book
+ // or auto-match. Ingest's supplier auto-match can set supplier_invoice_id
+ // WITHOUT journal_entry_id, so guarding journal_entry_id alone leaves a
+ // narrow TOCTOU window — mirror the read-time gate here.
+ .is('journal_entry_id', null)
+ .is('invoice_id', null)
+ .is('supplier_invoice_id', null)
+ // Return only what the client renders (data minimisation — the row also
+ // carries company_id and other internal fields the caller doesn't need).
+ .select('id, description, title_edited_at')
+ .maybeSingle>()
+
+ if (updateError) {
+ return errorResponse(updateError, log, { requestId })
+ }
+ if (!updated) {
+ // 0 rows updated → the row was booked/matched between read and write.
+ return errorResponseFromCode('TRANSACTION_TITLE_LOCKED', log, { requestId })
+ }
+
+ // Behandlingshistorik (BFNAR 2013:2 kap 8) — light-touch for a pre-verifikat
+ // working label; updated_at (trigger) captures "when". We deliberately do
+ // NOT log the description text: a bank label can carry PII (payee names,
+ // reference numbers). The before-value stays recoverable in
+ // original_description and the after-value is the row's current
+ // description, so the log only needs to record that/which way it changed.
+ log.info('transaction title edited', {
+ transactionId: id,
+ actor: user.id,
+ restored: isRestore,
+ previousLength: transaction.description?.length ?? 0,
+ newLength: description.length,
+ })
+
+ return NextResponse.json({ data: updated })
+ },
+ { requireWrite: true },
+)
diff --git a/app/api/v1/companies/[companyId]/supplier-invoices/__tests__/route.test.ts b/app/api/v1/companies/[companyId]/supplier-invoices/__tests__/route.test.ts
index b2ceafbe..66939a1c 100644
--- a/app/api/v1/companies/[companyId]/supplier-invoices/__tests__/route.test.ts
+++ b/app/api/v1/companies/[companyId]/supplier-invoices/__tests__/route.test.ts
@@ -365,6 +365,32 @@ describe('POST /api/v1/companies/:companyId/supplier-invoices', () => {
expect(body.error.details.step).toBe('registration_journal_entry')
})
+ it('rolls back SI row and returns SI_CREATE_NO_FISCAL_PERIOD when no period covers invoice_date', async () => {
+ // Engine returns null (not a throw) when no fiscal period covers the date.
+ mockedReg.mockResolvedValueOnce(null)
+ mockServiceClient.mockReturnValue(
+ makeFlexibleSupabase({
+ company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
+ suppliers: { data: SAMPLE_SUPPLIER, error: null },
+ company_settings: { data: { accounting_method: 'accrual' }, error: null },
+ fiscal_periods: { data: { id: 'fp-1', is_closed: false, locked_at: null }, error: null },
+ supplier_invoices: { data: SAMPLE_SI, error: null },
+ supplier_invoice_items: { data: null, error: null },
+ idempotency_keys: { data: null, error: null },
+ }),
+ )
+ const res = await createSI(
+ makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/supplier-invoices`, {
+ method: 'POST',
+ body: JSON.stringify(validBody),
+ }),
+ companyParams(COMPANY_ID),
+ )
+ expect(res.status).toBe(400)
+ const body = await res.json()
+ expect(body.error.code).toBe('SI_CREATE_NO_FISCAL_PERIOD')
+ })
+
it('returns a dry-run preview when ?dry_run=true', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
diff --git a/app/api/v1/companies/[companyId]/supplier-invoices/route.ts b/app/api/v1/companies/[companyId]/supplier-invoices/route.ts
index 97b5c0e9..4fb9fd35 100644
--- a/app/api/v1/companies/[companyId]/supplier-invoices/route.ts
+++ b/app/api/v1/companies/[companyId]/supplier-invoices/route.ts
@@ -701,9 +701,9 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>(
// Engine returned null (no open fiscal period). Strict-mode: roll back.
// Engine returned null before posting — no JE exists.
await rollbackSupplierInvoice(ctx.supabase, invoiceId, ctx.companyId!, ctx.log, 'no_fiscal_period', false)
- return v1ErrorResponseFromCode('SI_CREATE_FAILED', ctx.log, {
+ return v1ErrorResponseFromCode('SI_CREATE_NO_FISCAL_PERIOD', ctx.log, {
requestId: ctx.requestId,
- details: { step: 'registration_journal_entry', reason: 'no_fiscal_period' },
+ details: { step: 'registration_journal_entry', invoice_date: body.invoice_date },
})
}
} catch (err) {
diff --git a/app/api/v1/companies/[companyId]/transactions/ingest/route.ts b/app/api/v1/companies/[companyId]/transactions/ingest/route.ts
index 9124b6f1..d584e74b 100644
--- a/app/api/v1/companies/[companyId]/transactions/ingest/route.ts
+++ b/app/api/v1/companies/[companyId]/transactions/ingest/route.ts
@@ -22,6 +22,7 @@ import { registerEndpoint } from '@/lib/api/v1/registry'
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
import { ingestTransactions } from '@/lib/transactions/ingest'
+import { contentDedupKey } from '@/lib/transactions/external-id'
import type { RawTransaction } from '@/types'
const RawTx = z.object({
@@ -152,27 +153,25 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>(
const { data: bookedInRange } = await ctx.supabase
.from('transactions')
- .select('date, amount')
+ .select('date, amount, description')
.eq('company_id', ctx.companyId!)
.not('journal_entry_id', 'is', null)
.gte('date', dateFrom)
.lte('date', dateTo)
- // Normalize the amount to a fixed-precision string before keying.
- // Both JS number-to-string ("-349.5") and Postgres numeric round-trip
- // ("-349.50") collapse to the same "-349.50" representation here, so
- // a SIE amount with trailing-zero precision lines up with an already-
- // booked row whose amount JSON-encodes without it.
- const amountKey = (n: number): string => n.toFixed(2)
+ // Build the content-dedup key with the SAME helper the live pipeline uses
+ // (lib/transactions/ingest.ts), so the preview's content-match decision
+ // matches the eventual ingest exactly: öre-normalized amount (handles a
+ // PostgREST numeric returned as a string) plus the description prefix.
const bookedKeys = new Set(
(bookedInRange ?? []).map((r) => {
- const row = r as { date: string; amount: number }
- return `${row.date}|${amountKey(row.amount)}`
+ const row = r as { date: string; amount: number | string; description: string | null }
+ return contentDedupKey(row.date, row.amount, row.description)
}),
)
const previewRows = body.transactions.map((tx) => {
const extIdHit = knownExtIds.has(tx.external_id)
- const contentHit = bookedKeys.has(`${tx.date}|${amountKey(tx.amount)}`)
+ const contentHit = bookedKeys.has(contentDedupKey(tx.date, tx.amount, tx.description))
const wouldSkip = extIdHit || contentHit
const reason = extIdHit
? 'external_id_match'
diff --git a/components/bookkeeping/RecordateEntryDialog.tsx b/components/bookkeeping/RecordateEntryDialog.tsx
new file mode 100644
index 00000000..0c1ed489
--- /dev/null
+++ b/components/bookkeeping/RecordateEntryDialog.tsx
@@ -0,0 +1,248 @@
+'use client'
+
+import { useState, useEffect } from 'react'
+import { useRouter } from 'next/navigation'
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogFooter,
+} from '@/components/ui/dialog'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import { Badge } from '@/components/ui/badge'
+import { useToast } from '@/components/ui/use-toast'
+import { getErrorMessage } from '@/lib/errors/get-error-message'
+import { AlertTriangle, Lock, ArrowRight } from 'lucide-react'
+import { formatDate } from '@/lib/utils'
+import { formatVoucher } from '@/lib/bookkeeping/voucher-series-resolver'
+import type { JournalEntry } from '@/types'
+
+interface Props {
+ entry: JournalEntry
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ onMoved: () => void
+}
+
+type PeriodStatus = {
+ status: 'open' | 'locked' | 'closed'
+ period_id: string | null
+ lock_date: string | null
+ period_name: string | null
+}
+
+const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/
+
+export default function RecordateEntryDialog({ entry, open, onOpenChange, onMoved }: Props) {
+ const { toast } = useToast()
+ const router = useRouter()
+ const [newDate, setNewDate] = useState(entry.entry_date)
+ const [preview, setPreview] = useState(null)
+ const [previewLoading, setPreviewLoading] = useState(false)
+ const [previewError, setPreviewError] = useState(null)
+ const [isSubmitting, setIsSubmitting] = useState(false)
+
+ // Reset to the original date each time the dialog opens.
+ useEffect(() => {
+ if (open) {
+ setNewDate(entry.entry_date)
+ setPreview(null)
+ setPreviewError(null)
+ }
+ }, [open, entry.entry_date])
+
+ // Resolve the target period status whenever a valid, changed date is entered.
+ useEffect(() => {
+ if (!open) return
+ if (!ISO_DATE.test(newDate) || newDate === entry.entry_date) {
+ setPreview(null)
+ setPreviewError(null)
+ return
+ }
+ let cancelled = false
+ setPreviewLoading(true)
+ setPreviewError(null)
+ const handle = setTimeout(async () => {
+ try {
+ const res = await fetch(
+ `/api/bookkeeping/fiscal-periods/period-status?date=${encodeURIComponent(newDate)}`
+ )
+ if (!res.ok) throw new Error('period_status_failed')
+ const { data } = await res.json()
+ if (!cancelled) {
+ setPreview((data as PeriodStatus) ?? null)
+ setPreviewError(null)
+ }
+ } catch {
+ if (!cancelled) {
+ setPreview(null)
+ setPreviewError('Kunde inte kontrollera perioden. Försök igen.')
+ }
+ } finally {
+ if (!cancelled) setPreviewLoading(false)
+ }
+ }, 250)
+ return () => {
+ cancelled = true
+ clearTimeout(handle)
+ }
+ }, [newDate, open, entry.entry_date])
+
+ const dateChanged = ISO_DATE.test(newDate) && newDate !== entry.entry_date
+ const targetOpen = preview?.status === 'open' && !!preview?.period_id
+ const noCoveringPeriod = preview?.status === 'open' && !preview?.period_id
+ // Soft, non-blocking advisory when moving into a past date — the moms for
+ // that period may already have been filed.
+ const today = new Date().toISOString().slice(0, 10)
+ const movingIntoPast = dateChanged && newDate < today
+
+ const canSubmit = dateChanged && targetOpen && !isSubmitting
+
+ async function handleSubmit() {
+ if (!canSubmit) return
+ setIsSubmitting(true)
+ try {
+ const res = await fetch(`/api/bookkeeping/journal-entries/${entry.id}/recordate`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ new_entry_date: newDate }),
+ })
+ const result = await res.json()
+ if (!res.ok) {
+ const error = new Error('Failed to move entry') as Error & { body?: unknown; status?: number }
+ error.body = result
+ error.status = res.status
+ throw error
+ }
+ const correctedId = result.data?.corrected?.id
+ toast({
+ title: 'Verifikationen flyttad',
+ description: 'En storno och en rättelse med rätt datum har bokförts.',
+ action: correctedId ? (
+ router.push(`/bookkeeping/${correctedId}`)}>
+ Visa rättelsen
+
+ ) : undefined,
+ })
+ onOpenChange(false)
+ onMoved()
+ } catch (err) {
+ const anyErr = err as { body?: unknown; status?: number }
+ toast({
+ title: 'Kunde inte flytta verifikationen',
+ description: getErrorMessage(anyErr.body ?? err, { context: 'journal_entry', statusCode: anyErr.status }),
+ variant: 'destructive',
+ })
+ } finally {
+ setIsSubmitting(false)
+ }
+ }
+
+ return (
+
+
+
+ Rätta datum
+
+
+ {/* Explanation */}
+
+
Flytta verifikationen till rätt datum
+
+ En bokförd verifikation kan inte ändras direkt. Raderna behålls oförändrade — istället
+ skapas automatiskt:
+
+
+ En stornoverifikation som nollställer originalet i sin period
+ En ny verifikation med samma rader, bokförd på det nya datumet
+
+
+
+ {/* Original */}
+
+
+ {formatVoucher(entry)}
+ {formatDate(entry.entry_date)}
+ Original
+
+
{entry.description}
+
+
+ {/* New date */}
+
+
+ Nytt datum
+
+
setNewDate(e.target.value)}
+ className="tabular-nums"
+ />
+
+ {/* Target period feedback */}
+ {dateChanged && (
+
+ {previewLoading &&
Kontrollerar period… }
+
+ {!previewLoading && previewError && (
+
+
+ {previewError}
+
+ )}
+
+ {!previewLoading && !previewError && targetOpen && (
+
+
+ Flyttas till {preview?.period_name ?? 'rätt räkenskapsår'}
+
+ )}
+
+ {!previewLoading && noCoveringPeriod && (
+
+ Det finns ingen räkenskapsperiod som täcker datumet. Skapa eller öppna räkenskapsåret först.
+
+ )}
+
+ {!previewLoading && preview?.status === 'closed' && (
+
+ Räkenskapsåret är stängt (bokslut) och kan inte återöppnas. Bokför rättelsen i innevarande period istället.
+
+ )}
+
+ {!previewLoading && preview?.status === 'locked' && (
+
+
+ Perioden är låst{preview?.lock_date ? ` t.o.m. ${formatDate(preview.lock_date)}` : ''}. Lås upp perioden för att flytta verifikationen dit.
+
+ )}
+
+ )}
+
+ {/* Soft advisory: moving into a past period */}
+ {targetOpen && movingIntoPast && (
+
+
+
+ Om momsen för perioden redan är inlämnad kan du behöva lämna en rättad momsdeklaration.
+
+
+ )}
+
+
+
+ onOpenChange(false)} disabled={isSubmitting}>
+ Avbryt
+
+
+ {isSubmitting ? 'Flyttar…' : 'Flytta verifikationen'}
+
+
+
+
+ )
+}
diff --git a/components/import/BankFileUploadStep.tsx b/components/import/BankFileUploadStep.tsx
index ed1167c6..86432b7d 100644
--- a/components/import/BankFileUploadStep.tsx
+++ b/components/import/BankFileUploadStep.tsx
@@ -40,6 +40,7 @@ interface BankFileUploadStepProps {
onFileSelect: (file: File, formatOverride?: BankFileFormatId) => void
isLoading: boolean
error: string | null
+ errorTitle?: string | null
detectedFormat?: string | null
detectedFormatName?: string | null
}
@@ -48,6 +49,7 @@ export default function BankFileUploadStep({
onFileSelect,
isLoading,
error,
+ errorTitle,
detectedFormat,
detectedFormatName,
}: BankFileUploadStepProps) {
@@ -200,7 +202,7 @@ export default function BankFileUploadStep({
-
Kunde inte läsa filen
+
{errorTitle || 'Kunde inte läsa filen'}
{error}
diff --git a/components/reports/BankReconciliationView.tsx b/components/reports/BankReconciliationView.tsx
index 336a6cf7..98b0ffb5 100644
--- a/components/reports/BankReconciliationView.tsx
+++ b/components/reports/BankReconciliationView.tsx
@@ -50,6 +50,7 @@ interface ReconciliationStatus {
gl_1930_balance: number
gl_1930_period_movement: number
gl_1930_opening_balance: number
+ gl_1930_correction_adjustment: number
difference: number
is_reconciled: boolean
matched_count: number
@@ -644,6 +645,13 @@ export function BankReconciliationView() {
{' '}— räknas inte i avstämningen.
)}
+ {status.gl_1930_correction_adjustment !== 0 && (
+
+ Rättelser och stornon på i perioden:{' '}
+ {formatCurrency(status.gl_1930_correction_adjustment)}
+ {' '}— bokföringsmässiga rättelser utan motsvarande bankhändelse, räknas inte i avstämningen.
+
+ )}
Matchade: {status.matched_count}
Omatchade transaktioner: {status.unmatched_transaction_count}
diff --git a/components/transactions/EditTransactionTitleDialog.tsx b/components/transactions/EditTransactionTitleDialog.tsx
new file mode 100644
index 00000000..69e1bd2f
--- /dev/null
+++ b/components/transactions/EditTransactionTitleDialog.tsx
@@ -0,0 +1,131 @@
+'use client'
+
+import { useEffect, useState } from 'react'
+import { useTranslations } from 'next-intl'
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogDescription,
+ DialogFooter,
+} from '@/components/ui/dialog'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import { Loader2 } from 'lucide-react'
+
+interface EditTransactionTitleDialogProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ /** Current (possibly edited) title shown in the input. */
+ currentTitle: string
+ /** Bank's original title; when it differs from the current title a restore
+ * affordance is offered. */
+ originalTitle: string | null
+ /** Persist a new title. Resolves true on success (dialog closes), false to
+ * keep the dialog open (e.g. the request failed). */
+ onSave: (description: string) => Promise
+}
+
+/**
+ * Edit a bank transaction's working title. Carries the product-required warning
+ * ("Är du säker…") in the dialog body and offers a one-click restore back to
+ * the bank's original name. Gating (only unbooked/unmatched rows) is enforced
+ * server-side; callers only open this for editable rows.
+ */
+export default function EditTransactionTitleDialog({
+ open,
+ onOpenChange,
+ currentTitle,
+ originalTitle,
+ onSave,
+}: EditTransactionTitleDialogProps) {
+ const t = useTranslations('tx_inbox_card')
+ const [value, setValue] = useState(currentTitle)
+ const [isSaving, setIsSaving] = useState(false)
+
+ // Re-seed the field each time the dialog opens for a (possibly different) row.
+ useEffect(() => {
+ if (open) setValue(currentTitle)
+ }, [open, currentTitle])
+
+ const trimmed = value.trim()
+ const canRestore = originalTitle != null && originalTitle !== currentTitle
+ const isUnchanged = trimmed === currentTitle.trim()
+
+ async function persist(next: string) {
+ setIsSaving(true)
+ try {
+ const ok = await onSave(next)
+ if (ok) onOpenChange(false)
+ } finally {
+ setIsSaving(false)
+ }
+ }
+
+ return (
+ {
+ if (isSaving) return
+ onOpenChange(v)
+ }}
+ >
+
+
+ {t('edit_title_dialog_title')}
+ {t('edit_title_warning')}
+
+
+
{t('edit_title_label')}
+
setValue(e.target.value)}
+ maxLength={500}
+ autoFocus
+ disabled={isSaving}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter' && trimmed && !isUnchanged && !isSaving) {
+ e.preventDefault()
+ void persist(trimmed)
+ }
+ }}
+ />
+ {canRestore && (
+
+ {t('edit_title_original_hint', { name: originalTitle as string })}{' '}
+ void persist(originalTitle as string)}
+ disabled={isSaving}
+ className="underline underline-offset-2 hover:text-foreground transition-colors disabled:opacity-50"
+ >
+ {t('edit_title_restore')}
+
+
+ )}
+
+
+ onOpenChange(false)}
+ disabled={isSaving}
+ className="min-h-11 w-full sm:w-auto"
+ >
+ {t('edit_title_cancel')}
+
+ void persist(trimmed)}
+ disabled={isSaving || !trimmed || isUnchanged}
+ className="min-h-11 w-full sm:w-auto"
+ >
+ {isSaving ? : null}
+ {t('edit_title_save')}
+
+
+
+
+ )
+}
diff --git a/components/transactions/TransactionInboxCard.tsx b/components/transactions/TransactionInboxCard.tsx
index ab9bea9f..9139f287 100644
--- a/components/transactions/TransactionInboxCard.tsx
+++ b/components/transactions/TransactionInboxCard.tsx
@@ -22,6 +22,7 @@ import {
FileText,
Link2,
Loader2,
+ Pencil,
Split,
Trash2,
} from 'lucide-react'
@@ -54,6 +55,8 @@ interface TransactionInboxCardProps {
onOpenSplitMatch?: (transaction: TransactionWithInvoice) => void
onOpenCategoryDialog: (transaction: TransactionWithInvoice) => void
onDelete?: (id: string) => void
+ /** Open the edit-title dialog. Only wired for editable (unbooked/unmatched) rows. */
+ onEditTitle?: (transaction: TransactionWithInvoice) => void
onToggleSelect: (id: string) => void
onAnimationComplete?: (id: string) => void
}
@@ -69,6 +72,7 @@ export default function TransactionInboxCard({
onOpenSplitMatch,
onOpenCategoryDialog,
onDelete,
+ onEditTitle,
onToggleSelect,
onAnimationComplete,
}: TransactionInboxCardProps) {
@@ -109,6 +113,11 @@ export default function TransactionInboxCard({
const isUncategorized = transaction.is_business === null && !transaction.journal_entry_id
const showCheckbox = isBatchMode && isUncategorized
const isDeletable = !transaction.journal_entry_id
+ // Title is editable only on a mutable staging row — not booked and not
+ // confirmed-matched. Mirrors the server-side gate in PATCH /api/transactions/[id].
+ const isTitleEditable =
+ !transaction.journal_entry_id && !transaction.invoice_id && !transaction.supplier_invoice_id
+ const originalName = transaction.original_description
// Primary action: invoice/supplier-invoice match keeps the 1-click shortcut;
// otherwise the user opens the template picker.
@@ -290,6 +299,22 @@ export default function TransactionInboxCard({
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. */}
+ {isTitleEditable && onEditTitle && (
+ {
+ e.stopPropagation()
+ onEditTitle(transaction)
+ }}
+ aria-label={t('edit_title_aria')}
+ title={t('edit_title_aria')}
+ disabled={isProcessing || isDisabled}
+ >
+
+
+ )}
{isDeletable && onDelete && (
{formatDate(transaction.date)}
+ {transaction.title_edited_at && (
+ <>
+
+
+ {t('edited_badge')}
+
+ >
+ )}
{skvCounterpartDate && (
<>
diff --git a/extensions/general/enable-banking/lib/__tests__/api-client.test.ts b/extensions/general/enable-banking/lib/__tests__/api-client.test.ts
index 2b664f3b..99ca854d 100644
--- a/extensions/general/enable-banking/lib/__tests__/api-client.test.ts
+++ b/extensions/general/enable-banking/lib/__tests__/api-client.test.ts
@@ -17,6 +17,8 @@ import {
getAccountTransactions,
getAllTransactions,
getAllTransactionsWithRaw,
+ convertTransaction,
+ type Transaction,
} from '../api-client'
describe('api-client', () => {
@@ -248,3 +250,47 @@ describe('JWT cache', () => {
expect(typeof jwt._resetTokenCache).toBe('function')
})
})
+
+describe('convertTransaction', () => {
+ function makeTx(overrides: Partial = {}): Transaction {
+ return {
+ transaction_amount: { amount: '250.00', currency: 'SEK' },
+ credit_debit_indicator: 'DBIT',
+ booking_date: '2024-06-15',
+ ...overrides,
+ }
+ }
+
+ it('uses remittance_information when present', () => {
+ const tx = makeTx({ remittance_information: ['Faktura 123', ' '] })
+ expect(convertTransaction(tx, 'SEK').description).toBe('Faktura 123')
+ })
+
+ it('falls back to the counterparty name when remittance is empty', () => {
+ const out = makeTx({ remittance_information: [' '], creditor_name: 'Telia AB' })
+ expect(convertTransaction(out, 'SEK').description).toBe('Telia AB')
+ })
+
+ it('derives a Swedish label from bank_transaction_code when remittance and counterparty are both absent', () => {
+ const tx = makeTx({ bank_transaction_code: 'PMNT-CCRD-POSD', merchant_category_code: '5411' })
+ // MCC 5411 wins (most specific).
+ expect(convertTransaction(tx, 'SEK').description).toBe('Inköp dagligvaror')
+ })
+
+ it('uses the ISO family label when only bank_transaction_code is present', () => {
+ const tx = makeTx({ bank_transaction_code: 'PMNT/CCRD' })
+ expect(convertTransaction(tx, 'SEK').description).toBe('Kortköp')
+ })
+
+ it('falls back to the Swedish neutral (never English "Unknown") when nothing is recognized', () => {
+ const tx = makeTx({})
+ expect(convertTransaction(tx, 'SEK').description).toBe('Okänd transaktion')
+ })
+
+ it('carries the ISO codes through onto the converted transaction', () => {
+ const tx = makeTx({ bank_transaction_code: 'PMNT/RCDT', proprietary_bank_transaction_code: 'XB' })
+ const out = convertTransaction(tx, 'SEK')
+ expect(out.bank_transaction_code).toBe('PMNT/RCDT')
+ expect(out.proprietary_bank_transaction_code).toBe('XB')
+ })
+})
diff --git a/extensions/general/enable-banking/lib/__tests__/sync.test.ts b/extensions/general/enable-banking/lib/__tests__/sync.test.ts
index 1cde0d1b..4c821d75 100644
--- a/extensions/general/enable-banking/lib/__tests__/sync.test.ts
+++ b/extensions/general/enable-banking/lib/__tests__/sync.test.ts
@@ -216,10 +216,127 @@ describe('syncAccountTransactions', () => {
expect(mockIngest).toHaveBeenCalledTimes(1)
const rawTxns = mockIngest.mock.calls[0][3]
expect(rawTxns).toHaveLength(1)
- expect(rawTxns[0].external_id).toBe('eb_acc-uid-1_tx-500')
+ // Content-derived external_id: eb_{accountScope}_{date}_{öre}_{occurrence}.
+ // Deliberately NOT keyed off the bank's (unstable) tx id.
+ expect(rawTxns[0].external_id).toBe('eb_acc-uid-1_2024-06-15_-50000_0')
expect(rawTxns[0].import_source).toBe('enable_banking')
})
+ it('gives identical same-day same-amount transactions distinct, stable external_ids', async () => {
+ // Two genuinely distinct transactions that share date + amount must both be
+ // kept (distinct ids), and re-running the sync must reproduce the SAME set
+ // of ids so the second sync dedupes instead of duplicating.
+ const apiTxns = [
+ { transaction_amount: { amount: '250', currency: 'SEK' }, booking_date: '2024-06-15' },
+ { transaction_amount: { amount: '250', currency: 'SEK' }, booking_date: '2024-06-15' },
+ ]
+ mockGetAllTransactionsWithRaw.mockResolvedValue({ transactions: apiTxns, rawPages: ['{}'] })
+ mockConvertTransaction.mockImplementation((tx: { transaction_amount: { amount: string }, booking_date: string }) => ({
+ id: `bank-id-${Math.random()}`, // unstable bank id — must NOT influence external_id
+ date: tx.booking_date,
+ booking_date: tx.booking_date,
+ amount: -parseFloat(tx.transaction_amount.amount),
+ currency: 'SEK',
+ description: 'Kaffe',
+ }))
+ mockUploadDocument.mockResolvedValue({ id: 'doc-1' })
+
+ await syncAccountTransactions(
+ {} as never, COMPANY_ID, USER_ID, CONNECTION_ID, makeAccount(),
+ '2024-06-01', '2024-06-30', mockIngest
+ )
+
+ const ids = mockIngest.mock.calls[0][3].map((t: { external_id: string }) => t.external_id)
+ expect(ids).toEqual([
+ 'eb_acc-uid-1_2024-06-15_-25000_0',
+ 'eb_acc-uid-1_2024-06-15_-25000_1',
+ ])
+ })
+
+ it('prefers IBAN over uid for the external_id account scope', async () => {
+ mockGetAllTransactionsWithRaw.mockResolvedValue({
+ transactions: [{ transaction_amount: { amount: '100', currency: 'SEK' }, booking_date: '2024-06-15' }],
+ rawPages: ['{}'],
+ })
+ mockConvertTransaction.mockReturnValue({
+ id: 'tx-1', date: '2024-06-15', booking_date: '2024-06-15', amount: 100, currency: 'SEK', description: 'Test',
+ })
+ mockUploadDocument.mockResolvedValue({ id: 'doc-1' })
+
+ await syncAccountTransactions(
+ {} as never, COMPANY_ID, USER_ID, CONNECTION_ID,
+ makeAccount({ iban: 'SE4550000000058398257466' }),
+ '2024-06-01', '2024-06-30', mockIngest
+ )
+
+ const ids = mockIngest.mock.calls[0][3].map((t: { external_id: string }) => t.external_id)
+ expect(ids).toEqual(['eb_SE4550000000058398257466_2024-06-15_10000_0'])
+ })
+
+ it('normalizes IBAN whitespace/case so the account scope is stable across syncs', async () => {
+ mockGetAllTransactionsWithRaw.mockResolvedValue({
+ transactions: [{ transaction_amount: { amount: '100', currency: 'SEK' }, booking_date: '2024-06-15' }],
+ rawPages: ['{}'],
+ })
+ mockConvertTransaction.mockReturnValue({
+ id: 'tx-1', date: '2024-06-15', booking_date: '2024-06-15', amount: 100, currency: 'SEK', description: 'Test',
+ })
+ mockUploadDocument.mockResolvedValue({ id: 'doc-1' })
+
+ // ASPSP returns the IBAN in grouped, lowercased display form.
+ await syncAccountTransactions(
+ {} as never, COMPANY_ID, USER_ID, CONNECTION_ID,
+ makeAccount({ iban: 'se45 5000 0000 0583 9825 7466' }),
+ '2024-06-01', '2024-06-30', mockIngest
+ )
+
+ const ids = mockIngest.mock.calls[0][3].map((t: { external_id: string }) => t.external_id)
+ // Same scope as the spaced/cased variant above.
+ expect(ids).toEqual(['eb_SE4550000000058398257466_2024-06-15_10000_0'])
+ })
+
+ it('reproduces the same SET of external_ids when a re-sync returns transactions in a different order', async () => {
+ // Two genuinely distinct same-day/same-amount transactions. A later sync may
+ // return them in any order; the dedupe guarantee is that the id SET is
+ // identical, so the re-sync collides on (company_id, external_id).
+ const mk = (booking_date: string, amount: string) => ({ transaction_amount: { amount, currency: 'SEK' }, booking_date })
+ const convert = (tx: { transaction_amount: { amount: string }, booking_date: string }) => ({
+ id: `bank-${Math.random()}`, // unstable bank id — irrelevant to external_id
+ date: tx.booking_date,
+ booking_date: tx.booking_date,
+ amount: -parseFloat(tx.transaction_amount.amount),
+ currency: 'SEK',
+ description: 'Lunch',
+ })
+ mockConvertTransaction.mockImplementation(convert)
+ mockUploadDocument.mockResolvedValue({ id: 'doc-1' })
+
+ // First sync order.
+ mockGetAllTransactionsWithRaw.mockResolvedValueOnce({
+ transactions: [mk('2024-06-15', '250'), mk('2024-06-15', '250')],
+ rawPages: ['{}'],
+ })
+ await syncAccountTransactions(
+ {} as never, COMPANY_ID, USER_ID, CONNECTION_ID, makeAccount(),
+ '2024-06-01', '2024-06-30', mockIngest
+ )
+ const firstIds = mockIngest.mock.calls[0][3].map((t: { external_id: string }) => t.external_id)
+
+ // Re-sync, reversed order (and different lookback window does not matter).
+ mockGetAllTransactionsWithRaw.mockResolvedValueOnce({
+ transactions: [mk('2024-06-15', '250'), mk('2024-06-15', '250')],
+ rawPages: ['{}'],
+ })
+ await syncAccountTransactions(
+ {} as never, COMPANY_ID, USER_ID, CONNECTION_ID, makeAccount(),
+ '2024-03-01', '2024-06-30', mockIngest
+ )
+ const secondIds = mockIngest.mock.calls[1][3].map((t: { external_id: string }) => t.external_id)
+
+ expect(new Set(firstIds)).toEqual(new Set(secondIds))
+ expect(firstIds).toHaveLength(2)
+ })
+
it('returns the min/max booking date the ASPSP returned for the activation UI', async () => {
// The min/max loop reads booking_date from the *raw* transactions (sync.ts:75-82),
// before convertTransaction runs — so the dates need to be set here.
diff --git a/extensions/general/enable-banking/lib/__tests__/transaction-label.test.ts b/extensions/general/enable-banking/lib/__tests__/transaction-label.test.ts
new file mode 100644
index 00000000..13834ca5
--- /dev/null
+++ b/extensions/general/enable-banking/lib/__tests__/transaction-label.test.ts
@@ -0,0 +1,50 @@
+import { describe, it, expect } from 'vitest'
+import { deriveTransactionLabel } from '../transaction-label'
+
+describe('deriveTransactionLabel', () => {
+ it('prefers MCC over the ISO code (most specific signal)', () => {
+ expect(deriveTransactionLabel({ mcc: '6011' })).toBe('Uttag')
+ expect(deriveTransactionLabel({ mcc: 5411 })).toBe('Inköp dagligvaror')
+ // MCC wins even when a (different-meaning) bank code is also present.
+ expect(
+ deriveTransactionLabel({ mcc: '6011', bankTransactionCode: 'PMNT/CCRD' }),
+ ).toBe('Uttag')
+ })
+
+ it('maps ISO 20022 Domain/Family codes', () => {
+ expect(deriveTransactionLabel({ bankTransactionCode: 'PMNT/RCDT' })).toBe('Inbetalning')
+ expect(deriveTransactionLabel({ bankTransactionCode: 'PMNT/ICDT' })).toBe('Betalning')
+ expect(deriveTransactionLabel({ bankTransactionCode: 'PMNT/CCRD' })).toBe('Kortköp')
+ expect(deriveTransactionLabel({ bankTransactionCode: 'PMNT/RDDT' })).toBe('Autogiro')
+ })
+
+ it('parses dash- and dot-separated three-part codes (Domain-Family-SubFamily)', () => {
+ expect(deriveTransactionLabel({ bankTransactionCode: 'PMNT-CCRD-POSD' })).toBe('Kortköp')
+ expect(deriveTransactionLabel({ bankTransactionCode: 'pmnt.rcdt.esct' })).toBe('Inbetalning')
+ })
+
+ it('falls back to a keyword scan for proprietary code strings', () => {
+ expect(
+ deriveTransactionLabel({ proprietaryBankTransactionCode: 'INTEREST PAYMENT' }),
+ ).toBe('Ränta')
+ expect(
+ deriveTransactionLabel({ proprietaryBankTransactionCode: 'ACCOUNT FEE' }),
+ ).toBe('Avgift')
+ expect(
+ deriveTransactionLabel({ proprietaryBankTransactionCode: 'ATM WITHDRAWAL' }),
+ ).toBe('Uttag')
+ })
+
+ it('uses the bare PMNT domain + direction as a last generic resort', () => {
+ expect(deriveTransactionLabel({ bankTransactionCode: 'PMNT', isCredit: true })).toBe('Inbetalning')
+ expect(deriveTransactionLabel({ bankTransactionCode: 'PMNT', isCredit: false })).toBe('Betalning')
+ })
+
+ it('returns null when nothing is recognized (caller falls through)', () => {
+ expect(deriveTransactionLabel({})).toBeNull()
+ expect(deriveTransactionLabel({ bankTransactionCode: 'ZZZZ/QQQQ' })).toBeNull()
+ expect(deriveTransactionLabel({ mcc: '0000' })).toBeNull()
+ // Bare unknown domain without isCredit cannot be classified.
+ expect(deriveTransactionLabel({ bankTransactionCode: 'PMNT' })).toBeNull()
+ })
+})
diff --git a/extensions/general/enable-banking/lib/api-client.ts b/extensions/general/enable-banking/lib/api-client.ts
index d9f1f93b..963d4c1e 100644
--- a/extensions/general/enable-banking/lib/api-client.ts
+++ b/extensions/general/enable-banking/lib/api-client.ts
@@ -13,6 +13,8 @@
*/
import { getAuthorizationHeader } from './jwt'
+import { deriveTransactionLabel } from './transaction-label'
+import { FALLBACK_DESCRIPTION } from '@/lib/transactions/external-id'
// Prefer _PRODUCTION variant; sandbox uses api.tilisy.com, production uses api.enablebanking.com
const ENABLE_BANKING_API_URL =
@@ -149,6 +151,11 @@ export interface BankTransaction {
counterparty_account?: string
reference?: string
merchant_category_code?: string
+ // ISO 20022 / proprietary transaction codes — carried through so the
+ // description fallback can derive a meaningful Swedish label when remittance
+ // text and a counterparty name are both absent. See deriveTransactionLabel.
+ bank_transaction_code?: string
+ proprietary_bank_transaction_code?: string
}
// Constants
@@ -639,14 +646,27 @@ export function convertTransaction(tx: Transaction, accountCurrency: string): Ba
booking_date: tx.booking_date || tx.value_date || new Date().toISOString().split('T')[0],
amount,
currency: tx.transaction_amount.currency || accountCurrency,
+ // Fallback chain: bank's payment message → counterparty name → a Swedish
+ // label derived from the ISO 20022 / MCC codes the bank DID send (card
+ // purchases, ATM, fees, interest) → 'Okänd transaktion'. The final fallback
+ // is also normalized at the ingest boundary, so any leftover lands as the
+ // same Swedish neutral.
description: tx.remittance_information?.filter(r => r.trim()).join(' ') ||
(isCredit ? debtorName : creditorName) ||
- 'Unknown',
+ deriveTransactionLabel({
+ bankTransactionCode: tx.bank_transaction_code,
+ proprietaryBankTransactionCode: tx.proprietary_bank_transaction_code,
+ mcc: tx.merchant_category_code,
+ isCredit,
+ }) ||
+ FALLBACK_DESCRIPTION,
counterparty_name: isCredit ? debtorName : creditorName,
counterparty_account: isCredit
? tx.debtor_account?.iban || tx.debtor_account?.bban
: tx.creditor_account?.iban || tx.creditor_account?.bban,
- merchant_category_code: tx.merchant_category_code
+ merchant_category_code: tx.merchant_category_code,
+ bank_transaction_code: tx.bank_transaction_code,
+ proprietary_bank_transaction_code: tx.proprietary_bank_transaction_code,
}
}
diff --git a/extensions/general/enable-banking/lib/sync.ts b/extensions/general/enable-banking/lib/sync.ts
index 8a80cbc1..388011c8 100644
--- a/extensions/general/enable-banking/lib/sync.ts
+++ b/extensions/general/enable-banking/lib/sync.ts
@@ -2,6 +2,7 @@ import type { SupabaseClient } from '@supabase/supabase-js'
import { getAllTransactionsWithRaw, convertTransaction, getAccountBalance } from './api-client'
import { uploadDocument } from '@/lib/core/documents/document-service'
import { ingestTransactions as defaultIngest } from '@/lib/transactions/ingest'
+import { buildStableExternalIds, FALLBACK_DESCRIPTION } from '@/lib/transactions/external-id'
import type { RawTransaction, IngestResult, IngestOptions } from '@/types'
import type { StoredAccount, TransactionsFetchStrategy } from '../types'
@@ -99,19 +100,38 @@ export async function syncAccountTransactions(
const bankTransactions = transactions.map(tx => convertTransaction(tx, account.currency))
+ // Derive a stable, content-based external_id per transaction. We deliberately
+ // do NOT key off the bank's transaction id (entry_reference/transaction_id):
+ // many Swedish ASPSPs regenerate those across requests, so a repeat "synka nu"
+ // produced a fresh id and re-imported transactions the user had already
+ // booked. buildStableExternalIds derives the id from (account, date, amount)
+ // plus an occurrence index, so re-syncs collide on (company_id, external_id)
+ // and dedupe while genuinely identical transactions are still kept apart.
+ // Normalize the IBAN (strip whitespace, uppercase) so formatting variants
+ // from the ASPSP ("SE45 5000 …" vs "SE455000…") don't change the scope and
+ // orphan every prior external_id. Falls back to the provider account uid.
+ const accountScope = account.iban?.replace(/\s+/g, '').toUpperCase() || account.uid
+ const externalIds = buildStableExternalIds(
+ 'eb',
+ accountScope,
+ bankTransactions.map((tx) => ({ date: tx.booking_date || tx.date, amount: tx.amount }))
+ )
+
// Convert Enable Banking format to generic RawTransaction. counterparty
// identification: prefer IBAN (international, normalized) over BBAN/BG
// numbers — the own-account detector matches on IBAN first, falling back
// to counterparty_account for Swedish domestic transfers.
- const rawTransactions: RawTransaction[] = bankTransactions.map((tx) => {
+ const rawTransactions: RawTransaction[] = bankTransactions.map((tx, i) => {
const cpAccount = tx.counterparty_account ?? null
const looksLikeIban = cpAccount && /^[A-Z]{2}\d/.test(cpAccount.replace(/\s+/g, ''))
return {
date: tx.booking_date || tx.date,
- description: tx.description || tx.counterparty_name || 'Unknown',
+ // tx.description is already non-empty (convertTransaction guarantees a
+ // label); the trailing fallbacks are defensive. Ingest re-normalizes.
+ description: tx.description || tx.counterparty_name || FALLBACK_DESCRIPTION,
amount: tx.amount,
currency: tx.currency || account.currency,
- external_id: `eb_${account.iban || account.uid}_${tx.id}`,
+ external_id: externalIds[i],
mcc_code: tx.merchant_category_code ? parseInt(tx.merchant_category_code, 10) : null,
merchant_name: tx.counterparty_name || null,
reference: tx.reference || null,
diff --git a/extensions/general/enable-banking/lib/transaction-label.ts b/extensions/general/enable-banking/lib/transaction-label.ts
new file mode 100644
index 00000000..33b0361b
--- /dev/null
+++ b/extensions/general/enable-banking/lib/transaction-label.ts
@@ -0,0 +1,100 @@
+/**
+ * Derive a Swedish, human-readable working label for a bank transaction from
+ * the structured codes an ASPSP DOES send when free-text remittance and a
+ * counterparty name are both absent — the classic card-purchase / ATM / fee /
+ * interest case that otherwise falls through to a generic placeholder.
+ *
+ * Pure and side-effect free, so it is trivially unit-testable and safe to call
+ * inside the transaction conversion fallback chain.
+ *
+ * Precedence (most specific first):
+ * 1. MCC (merchant_category_code) — identifies the merchant kind for a card
+ * purchase. Already trusted for auto-categorization
+ * (lib/bookkeeping/mapping-engine.ts).
+ * 2. ISO 20022 bank_transaction_code Domain/Family (e.g. "PMNT/CCRD").
+ * 3. Keyword scan over the (often proprietary, non-normalized) code strings.
+ * 4. Bare "PMNT" domain with no recognized family → direction-based generic.
+ *
+ * Returns null when nothing is recognized — the caller then falls through to
+ * its own final fallback (the ingest boundary normalizes any leftover empty /
+ * 'Unknown' value to 'Okänd transaktion').
+ *
+ * The mapping tables are intentionally small starters. ASPSP coverage of these
+ * codes varies and proprietary formats differ per bank — extend the tables
+ * against real archived `psd2-response_*.json` samples as they surface.
+ */
+
+export interface TransactionLabelInput {
+ /** ISO 20022 bank transaction code, e.g. "PMNT-CCRD-POSD" or "PMNT/RCDT". */
+ bankTransactionCode?: string | null
+ /** ASPSP-proprietary code (free-form, varies per bank). */
+ proprietaryBankTransactionCode?: string | null
+ /** Merchant category code (card transactions). */
+ mcc?: string | number | null
+ /** CRDT (money in) vs DBIT (money out) — used only for the bare-domain case. */
+ isCredit?: boolean
+}
+
+// ISO 20022 External Bank Transaction Codes, keyed by `DOMAIN/FAMILY`.
+const ISO20022_LABELS: Record = {
+ 'PMNT/RCDT': 'Inbetalning', // ReceivedCreditTransfers
+ 'PMNT/ICDT': 'Betalning', // IssuedCreditTransfers
+ 'PMNT/CCRD': 'Kortköp', // CustomerCardTransactions
+ 'PMNT/MCRD': 'Kortköp', // MerchantCardTransactions
+ 'PMNT/RDDT': 'Autogiro', // ReceivedDirectDebits
+ 'PMNT/IDDT': 'Autogiro', // IssuedDirectDebits
+ 'PMNT/CWDL': 'Uttag', // CashWithdrawal
+ 'PMNT/CAJT': 'Justering', // CashAdjustments
+}
+
+// MCC → coarse Swedish label. Tiny starter set.
+const MCC_LABELS: Record = {
+ '6011': 'Uttag', // ATM / automated cash disbursements
+ '5411': 'Inköp dagligvaror', // Grocery stores, supermarkets
+}
+
+// Keyword → label, scanned over the raw (incl. proprietary) code strings as a
+// last resort before null. Covers banks that send free-form codes, not ISO.
+const KEYWORD_LABELS: Array<[RegExp, string]> = [
+ [/INTRST|INTEREST|RÄNTA|RANTA/i, 'Ränta'],
+ [/\bFEE\b|CHRG|CHARGE|AVGIFT/i, 'Avgift'],
+ [/ATM|CASH.?WDL|WITHDRAW|UTTAG/i, 'Uttag'],
+ [/\bCARD\b|KORT|\bPOS\b/i, 'Kortköp'],
+ [/SALA|SALARY|\bLÖN\b|\bLON\b/i, 'Lön'],
+]
+
+export function deriveTransactionLabel(input: TransactionLabelInput): string | null {
+ // 1. MCC — most specific signal for card purchases.
+ const mcc = input.mcc != null ? String(input.mcc).trim() : ''
+ if (mcc && MCC_LABELS[mcc]) return MCC_LABELS[mcc]
+
+ const codes = [input.bankTransactionCode, input.proprietaryBankTransactionCode].filter(
+ (c): c is string => typeof c === 'string' && c.trim().length > 0,
+ )
+
+ // 2. ISO 20022 Domain/Family from the structured code.
+ for (const raw of codes) {
+ const parts = raw.toUpperCase().split(/[/\-_.\s]+/).filter(Boolean)
+ if (parts.length >= 2) {
+ const key = `${parts[0]}/${parts[1]}`
+ if (ISO20022_LABELS[key]) return ISO20022_LABELS[key]
+ }
+ }
+
+ // 3. Keyword scan over the raw code strings (covers proprietary formats).
+ for (const raw of codes) {
+ for (const [re, label] of KEYWORD_LABELS) {
+ if (re.test(raw)) return label
+ }
+ }
+
+ // 4. Bare "PMNT" domain with no recognized family → direction-based generic.
+ if (input.isCredit != null) {
+ for (const raw of codes) {
+ const domain = raw.toUpperCase().split(/[/\-_.\s]+/)[0]
+ if (domain === 'PMNT') return input.isCredit ? 'Inbetalning' : 'Betalning'
+ }
+ }
+
+ return null
+}
diff --git a/extensions/general/invoice-inbox/index.ts b/extensions/general/invoice-inbox/index.ts
index c48c2921..d32a6e0d 100644
--- a/extensions/general/invoice-inbox/index.ts
+++ b/extensions/general/invoice-inbox/index.ts
@@ -20,6 +20,7 @@ import {
import { createSupplierInvoiceRegistrationEntry } from '@/lib/bookkeeping/supplier-invoice-entries'
import { createJournalEntry } from '@/lib/bookkeeping/engine'
import { bookkeepingErrorResponse } from '@/lib/bookkeeping/errors'
+import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { linkToJournalEntry } from '@/lib/core/documents/document-service'
import { CreateSupplierInvoiceSchema, BookInboxItemDirectlySchema } from '@/lib/api/schemas'
import { appendProcessingHistory } from '@/lib/processing-history/append'
@@ -1766,6 +1767,19 @@ export const invoiceInboxExtension: Extension = {
.eq('id', item.document_id)
.eq('company_id', ctx.companyId)
}
+ } else {
+ // createSupplierInvoiceRegistrationEntry returns null ONLY when no
+ // fiscal period covers invoice_date (every other failure throws).
+ // Roll back so we never mark the inbox item converted against an
+ // unbooked supplier invoice (orphan understating 2440/2641).
+ await ctx.supabase
+ .from('supplier_invoices')
+ .delete()
+ .eq('id', invoice.id)
+ .eq('company_id', ctx.companyId)
+ return errorResponseFromCode('SI_CREATE_NO_FISCAL_PERIOD', ctx.log, {
+ details: { invoiceDate: (invoice as SupplierInvoice).invoice_date },
+ })
}
} catch (err) {
console.error('[invoice-inbox/convert] Failed to create registration journal entry:', err)
diff --git a/lib/api/schemas.ts b/lib/api/schemas.ts
index 55d7ce52..6052461a 100644
--- a/lib/api/schemas.ts
+++ b/lib/api/schemas.ts
@@ -458,6 +458,15 @@ export const CorrectJournalEntrySchema = z.object({
lines: z.array(CreateJournalEntryLineSchema).min(2, 'At least two lines are required for double-entry'),
})
+/**
+ * Move a posted verifikation to a different date (and thereby fiscal period)
+ * without changing its lines — fixes a booking entered with the wrong
+ * date/year. The corrected lines are copied server-side from the original.
+ */
+export const RecordateJournalEntrySchema = z.object({
+ new_entry_date: isoDate,
+})
+
// ============================================================
// Transaction schemas
// ============================================================
@@ -481,6 +490,15 @@ export const BookTransactionSchema = z.object({
lines: z.array(CreateJournalEntryLineSchema).min(1, 'At least one line is required'),
})
+/**
+ * Edit a bank transaction's title (description). Only the working label —
+ * gated server-side to unbooked, unmatched rows. Trimmed; whitespace-only is
+ * rejected by min(1). Passing the bank original restores the "not edited" tag.
+ */
+export const UpdateTransactionTitleSchema = z.object({
+ description: z.string().trim().min(1, 'Title cannot be empty').max(500),
+})
+
export const BookInboxItemDirectlySchema = z.object({
fiscal_period_id: uuid,
entry_date: isoDate,
diff --git a/lib/bookkeeping/__tests__/own-account-detector.test.ts b/lib/bookkeeping/__tests__/own-account-detector.test.ts
index 61f64d93..f6c66e8d 100644
--- a/lib/bookkeeping/__tests__/own-account-detector.test.ts
+++ b/lib/bookkeeping/__tests__/own-account-detector.test.ts
@@ -12,6 +12,8 @@ function makeTx(overrides: Partial = {}): Transaction {
external_id: 'eb_sek_1',
date: '2026-06-12',
description: 'Överföring till EUR-konto',
+ original_description: 'Överföring till EUR-konto',
+ title_edited_at: null,
amount: -1000,
currency: 'SEK',
amount_sek: -1000,
diff --git a/lib/bookkeeping/errors.ts b/lib/bookkeeping/errors.ts
index 125c7c1f..3f0b3a93 100644
--- a/lib/bookkeeping/errors.ts
+++ b/lib/bookkeeping/errors.ts
@@ -16,6 +16,9 @@ export const CURRENCY_REVALUATION_ALREADY_EXISTS = 'CURRENCY_REVALUATION_ALREADY
export const INVALID_MAPPING_RESULT = 'INVALID_MAPPING_RESULT' as const
export const BOOKKEEPING_DATABASE_ERROR = 'BOOKKEEPING_DATABASE_ERROR' as const
export const MEANINGLESS_CORRECTION = 'MEANINGLESS_CORRECTION' as const
+export const NO_OPEN_PERIOD_FOR_DATE = 'NO_OPEN_PERIOD_FOR_DATE' as const
+export const TARGET_PERIOD_CLOSED = 'TARGET_PERIOD_CLOSED' as const
+export const TARGET_PERIOD_LOCKED = 'TARGET_PERIOD_LOCKED' as const
// ============================================================================
// AccountsNotInChartError — kept for back-compat (many existing call sites)
@@ -139,7 +142,10 @@ export class CurrencyRevaluationAlreadyExistsError extends Error {
}
}
-export type MeaninglessCorrectionReason = 'net_zero_per_account' | 'identical_to_original'
+export type MeaninglessCorrectionReason =
+ | 'net_zero_per_account'
+ | 'identical_to_original'
+ | 'no_date_change'
export class MeaninglessCorrectionError extends Error {
readonly code = MEANINGLESS_CORRECTION
@@ -147,12 +153,57 @@ export class MeaninglessCorrectionError extends Error {
super(
reason === 'net_zero_per_account'
? 'Correction lines net to zero on every account — no economic event represented (BFL 5 kap. 5 §).'
- : 'Correction lines are identical to the original entry — nothing to correct.'
+ : reason === 'no_date_change'
+ ? 'New date equals the current date — nothing to move.'
+ : 'Correction lines are identical to the original entry — nothing to correct.'
)
this.name = 'MeaninglessCorrectionError'
}
}
+/**
+ * Raised when a verifikation is moved (recordate) to a date that no fiscal
+ * period covers. We do not auto-create periods on a correction.
+ */
+export class NoOpenPeriodForDateError extends Error {
+ readonly code = NO_OPEN_PERIOD_FOR_DATE
+ constructor(public readonly date: string) {
+ super(`No fiscal period covers ${date}`)
+ this.name = 'NoOpenPeriodForDateError'
+ }
+}
+
+/**
+ * Raised when the target date of a recordate falls in a closed fiscal year
+ * (bokslut). A closed year cannot be reopened — the correction must be booked
+ * in the current open period instead.
+ */
+export class TargetPeriodClosedError extends Error {
+ readonly code = TARGET_PERIOD_CLOSED
+ constructor(public readonly date: string) {
+ super(`The fiscal period covering ${date} is closed`)
+ this.name = 'TargetPeriodClosedError'
+ }
+}
+
+/**
+ * Raised when the target date of a recordate falls in a locked period or is
+ * covered by the company-wide bookkeeping lock date. Carries the lock date so
+ * the UI can offer an unlock affordance.
+ */
+export class TargetPeriodLockedError extends Error {
+ readonly code = TARGET_PERIOD_LOCKED
+ constructor(
+ public readonly date: string,
+ public readonly lockDate: string | null
+ ) {
+ super(
+ `The fiscal period covering ${date} is locked${lockDate ? ` (lock date ${lockDate})` : ''}`
+ )
+ this.name = 'TargetPeriodLockedError'
+ }
+}
+
export class InvalidMappingResultError extends Error {
readonly code = INVALID_MAPPING_RESULT
constructor(
@@ -222,7 +273,10 @@ export function isBookkeepingError(err: unknown): boolean {
err instanceof CurrencyRevaluationAlreadyExistsError ||
err instanceof InvalidMappingResultError ||
err instanceof BookkeepingDatabaseError ||
- err instanceof MeaninglessCorrectionError
+ err instanceof MeaninglessCorrectionError ||
+ err instanceof NoOpenPeriodForDateError ||
+ err instanceof TargetPeriodClosedError ||
+ err instanceof TargetPeriodLockedError
)
}
@@ -385,6 +439,45 @@ export function bookkeepingErrorResponse(err: unknown): NextResponse | null {
)
}
+ if (err instanceof NoOpenPeriodForDateError) {
+ return NextResponse.json(
+ {
+ error: {
+ code: err.code,
+ message: err.message,
+ details: { date: err.date },
+ },
+ },
+ { status: 400 }
+ )
+ }
+
+ if (err instanceof TargetPeriodClosedError) {
+ return NextResponse.json(
+ {
+ error: {
+ code: err.code,
+ message: err.message,
+ details: { date: err.date },
+ },
+ },
+ { status: 409 }
+ )
+ }
+
+ if (err instanceof TargetPeriodLockedError) {
+ return NextResponse.json(
+ {
+ error: {
+ code: err.code,
+ message: err.message,
+ details: { date: err.date, lockDate: err.lockDate },
+ },
+ },
+ { status: 409 }
+ )
+ }
+
if (err instanceof BookkeepingDatabaseError) {
return NextResponse.json(
{
diff --git a/lib/core/bookkeeping/__tests__/recordate-entry.pg.test.ts b/lib/core/bookkeeping/__tests__/recordate-entry.pg.test.ts
new file mode 100644
index 00000000..3d64b2a1
--- /dev/null
+++ b/lib/core/bookkeeping/__tests__/recordate-entry.pg.test.ts
@@ -0,0 +1,183 @@
+import { describe, expect, it } from 'vitest'
+import { randomUUID } from 'node:crypto'
+import { getPool } from '@/tests/pg/setup'
+import { seedCompany, insertFiscalPeriod } from '@/tests/pg/fixtures'
+
+/**
+ * Recordate (wrong-year fix) DB-layer invariants. recordateEntry moves a posted
+ * verifikation to a different fiscal year via storno + re-book: the original is
+ * reversed in its own period and an identical corrected entry is posted in the
+ * target period with the new date. The service runs through the Supabase JS
+ * client (out of scope for pg-real, see correct-correction.pg), so we drive the
+ * SQL directly to prove the guarantees the service depends on:
+ *
+ * 1. A correction can be posted into a DIFFERENT open period than the
+ * original, drawing its voucher number from that period's sequence, while
+ * the storno + original stay in the original period.
+ * 2. enforce_period_lock rejects any write into a locked target period — the
+ * DB backstop behind recordateEntry's pre-flight TargetPeriodLockedError.
+ */
+describe('recordate (pg-real)', () => {
+ async function insertDraft(opts: {
+ userId: string
+ companyId: string
+ fiscalPeriodId: string
+ entryDate: string
+ sourceType: string
+ reversesId?: string | null
+ correctionOfId?: string | null
+ }): Promise {
+ const id = randomUUID()
+ await getPool().query(
+ `INSERT INTO public.journal_entries
+ (id, user_id, company_id, fiscal_period_id, voucher_number, voucher_series,
+ entry_date, description, source_type, status, reverses_id, correction_of_id)
+ VALUES ($1, $2, $3, $4, 0, 'A', $5, $6, $7, 'draft', $8, $9)`,
+ [
+ id,
+ opts.userId,
+ opts.companyId,
+ opts.fiscalPeriodId,
+ opts.entryDate,
+ `Entry ${opts.sourceType}`,
+ opts.sourceType,
+ opts.reversesId ?? null,
+ opts.correctionOfId ?? null,
+ ],
+ )
+ return id
+ }
+
+ async function insertLines(entryId: string, debitAcc: string, creditAcc: string, amount: number) {
+ await getPool().query(
+ `INSERT INTO public.journal_entry_lines
+ (journal_entry_id, account_number, debit_amount, credit_amount)
+ VALUES ($1, $2, $3, 0), ($1, $4, 0, $3)`,
+ [entryId, debitAcc, amount, creditAcc],
+ )
+ }
+
+ async function commit(companyId: string, entryId: string): Promise {
+ const { rows } = await getPool().query<{ voucher_number: number }>(
+ `SELECT voucher_number FROM public.commit_journal_entry($1::uuid, $2::uuid)`,
+ [companyId, entryId],
+ )
+ return rows[0]!.voucher_number
+ }
+
+ async function markReversed(entryId: string, reversedById: string) {
+ await getPool().query(
+ `UPDATE public.journal_entries
+ SET status = 'reversed', reversed_by_id = $2
+ WHERE id = $1 AND status = 'posted'`,
+ [entryId, reversedById],
+ )
+ }
+
+ it('books the corrected entry in the target year while storno + original stay in the original year', async () => {
+ const { userId, companyId, fiscalPeriodId: fp2026 } = await seedCompany() // 2026, open
+ const fp2025 = await insertFiscalPeriod({
+ userId,
+ companyId,
+ name: '2025',
+ periodStart: '2025-01-01',
+ periodEnd: '2025-12-31',
+ })
+
+ // Original booked on the wrong year (2026-07-03, should be 2025-07-03).
+ const originalId = await insertDraft({
+ userId,
+ companyId,
+ fiscalPeriodId: fp2026,
+ entryDate: '2026-07-03',
+ sourceType: 'manual',
+ })
+ await insertLines(originalId, '6230', '1930', 1008.75)
+ await commit(companyId, originalId)
+
+ // Storno in the original period (nets 2026 to zero for this entry).
+ const stornoId = await insertDraft({
+ userId,
+ companyId,
+ fiscalPeriodId: fp2026,
+ entryDate: '2026-07-03',
+ sourceType: 'storno',
+ reversesId: originalId,
+ })
+ await insertLines(stornoId, '1930', '6230', 1008.75) // swapped legs
+ await commit(companyId, stornoId)
+ await markReversed(originalId, stornoId)
+
+ // Corrected re-booking in the *target* year with the right date.
+ const correctedId = await insertDraft({
+ userId,
+ companyId,
+ fiscalPeriodId: fp2025,
+ entryDate: '2025-07-03',
+ sourceType: 'correction',
+ correctionOfId: originalId,
+ })
+ await insertLines(correctedId, '6230', '1930', 1008.75)
+ const correctedVoucher = await commit(companyId, correctedId)
+
+ expect(correctedVoucher).toBeGreaterThan(0)
+
+ const { rows } = await getPool().query<{
+ id: string
+ status: string
+ fiscal_period_id: string
+ entry_date: string
+ correction_of_id: string | null
+ reverses_id: string | null
+ reversed_by_id: string | null
+ }>(
+ `SELECT id, status, fiscal_period_id, entry_date::text, correction_of_id, reverses_id, reversed_by_id
+ FROM public.journal_entries WHERE company_id = $1`,
+ [companyId],
+ )
+ const state = Object.fromEntries(rows.map((r) => [r.id, r]))
+
+ expect(state[originalId]).toMatchObject({
+ status: 'reversed',
+ fiscal_period_id: fp2026,
+ reversed_by_id: stornoId,
+ })
+ expect(state[stornoId]).toMatchObject({
+ status: 'posted',
+ fiscal_period_id: fp2026,
+ reverses_id: originalId,
+ })
+ expect(state[correctedId]).toMatchObject({
+ status: 'posted',
+ fiscal_period_id: fp2025,
+ entry_date: '2025-07-03',
+ correction_of_id: originalId,
+ })
+ })
+
+ it('enforce_period_lock rejects re-booking into a locked target period', async () => {
+ const { userId, companyId } = await seedCompany() // 2026, open
+ const fp2025 = await insertFiscalPeriod({
+ userId,
+ companyId,
+ name: '2025',
+ periodStart: '2025-01-01',
+ periodEnd: '2025-12-31',
+ })
+ await getPool().query(
+ `UPDATE public.fiscal_periods SET locked_at = now() WHERE id = $1`,
+ [fp2025],
+ )
+
+ // The trigger fires on INSERT, so even staging the corrected draft fails.
+ await expect(
+ insertDraft({
+ userId,
+ companyId,
+ fiscalPeriodId: fp2025,
+ entryDate: '2025-07-03',
+ sourceType: 'correction',
+ }),
+ ).rejects.toThrow(/locked\/closed fiscal period/)
+ })
+})
diff --git a/lib/core/bookkeeping/__tests__/recordate-entry.test.ts b/lib/core/bookkeeping/__tests__/recordate-entry.test.ts
new file mode 100644
index 00000000..82dfabe9
--- /dev/null
+++ b/lib/core/bookkeeping/__tests__/recordate-entry.test.ts
@@ -0,0 +1,157 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { eventBus } from '@/lib/events/bus'
+import { makeJournalEntry, makeJournalEntryLine } from '@/tests/helpers'
+import {
+ CannotCorrectNonPostedError,
+ NoOpenPeriodForDateError,
+ TargetPeriodClosedError,
+} from '@/lib/bookkeeping/errors'
+
+// ============================================================
+// Mock — sequential results, separate client/builder (see storno-service.test)
+// ============================================================
+
+let resultIdx: number
+let results: Array<{ data?: unknown; error?: unknown }>
+let inserts: Array<{ table: string; payload: unknown }>
+
+function makeBuilder(table: string) {
+ const b: Record = {}
+ for (const m of ['select', 'eq', 'in', 'update', 'delete']) {
+ b[m] = vi.fn().mockReturnValue(b)
+ }
+ b.insert = vi.fn().mockImplementation((payload: unknown) => {
+ inserts.push({ table, payload })
+ return b
+ })
+ b.single = vi.fn().mockImplementation(async () => results[resultIdx++] ?? { data: null, error: null })
+ b.maybeSingle = vi.fn().mockImplementation(async () => results[resultIdx++] ?? { data: null, error: null })
+ b.then = (resolve: (v: unknown) => void) => resolve(results[resultIdx++] ?? { data: null, error: null })
+ return b
+}
+
+function makeClient() {
+ return {
+ from: vi.fn().mockImplementation((table: string) => makeBuilder(table)),
+ rpc: vi.fn().mockImplementation(async () => results[resultIdx++] ?? { data: null, error: null }),
+ }
+}
+
+vi.mock('@/lib/bookkeeping/engine', () => ({
+ validateBalance: vi.fn().mockReturnValue({ valid: true, totalDebit: 1008.75, totalCredit: 1008.75 }),
+ getNextVoucherNumber: vi.fn(async () => 1),
+}))
+
+// resolvePeriodStatusForDate is the classification gate — mock it directly so
+// each test controls whether the target date is open/locked/closed/uncovered.
+const mockResolve = vi.fn()
+vi.mock('@/lib/core/bookkeeping/period-service', () => ({
+ resolvePeriodStatusForDate: (...args: unknown[]) => mockResolve(...args),
+}))
+
+import { recordateEntry } from '../storno-service'
+import { validateBalance, getNextVoucherNumber } from '@/lib/bookkeeping/engine'
+
+const original = makeJournalEntry({
+ id: 'orig-1',
+ status: 'posted',
+ description: 'One.com',
+ entry_date: '2026-07-03',
+ fiscal_period_id: 'fp-2026',
+ voucher_series: 'A',
+ lines: [
+ makeJournalEntryLine({ account_number: '6230', debit_amount: 1008.75, credit_amount: 0 }),
+ makeJournalEntryLine({ account_number: '1930', debit_amount: 0, credit_amount: 1008.75 }),
+ ],
+})
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ eventBus.clear()
+ resultIdx = 0
+ results = []
+ inserts = []
+ vi.mocked(validateBalance).mockReturnValue({ valid: true, totalDebit: 1008.75, totalCredit: 1008.75 })
+ let v = 0
+ vi.mocked(getNextVoucherNumber).mockImplementation(async () => ++v)
+})
+
+describe('recordateEntry', () => {
+ it('throws no_date_change when the new date equals the current date', async () => {
+ results = [{ data: original, error: null }]
+ const supabase = makeClient()
+ await expect(
+ recordateEntry(supabase as never, 'company-1', 'user-1', 'orig-1', '2026-07-03')
+ ).rejects.toMatchObject({ code: 'MEANINGLESS_CORRECTION', reason: 'no_date_change' })
+ // Classification is never reached for a no-op move.
+ expect(mockResolve).not.toHaveBeenCalled()
+ })
+
+ it('rejects a non-posted entry', async () => {
+ results = [{ data: { ...original, status: 'draft' }, error: null }]
+ const supabase = makeClient()
+ await expect(
+ recordateEntry(supabase as never, 'company-1', 'user-1', 'orig-1', '2025-07-03')
+ ).rejects.toBeInstanceOf(CannotCorrectNonPostedError)
+ })
+
+ it('refuses to move into a closed fiscal year', async () => {
+ results = [{ data: original, error: null }]
+ mockResolve.mockResolvedValue({ status: 'closed', period_id: 'fp-2025', lock_date: null })
+ const supabase = makeClient()
+ await expect(
+ recordateEntry(supabase as never, 'company-1', 'user-1', 'orig-1', '2025-07-03')
+ ).rejects.toBeInstanceOf(TargetPeriodClosedError)
+ })
+
+ it('refuses to move into a locked period and carries the lock date', async () => {
+ results = [{ data: original, error: null }]
+ mockResolve.mockResolvedValue({ status: 'locked', period_id: 'fp-2025', lock_date: '2025-12-31' })
+ const supabase = makeClient()
+ await expect(
+ recordateEntry(supabase as never, 'company-1', 'user-1', 'orig-1', '2025-07-03')
+ ).rejects.toMatchObject({ code: 'TARGET_PERIOD_LOCKED', lockDate: '2025-12-31' })
+ })
+
+ it('refuses when no fiscal period covers the date', async () => {
+ results = [{ data: original, error: null }]
+ mockResolve.mockResolvedValue({ status: 'open', period_id: null, lock_date: null })
+ const supabase = makeClient()
+ await expect(
+ recordateEntry(supabase as never, 'company-1', 'user-1', 'orig-1', '2025-07-03')
+ ).rejects.toBeInstanceOf(NoOpenPeriodForDateError)
+ })
+
+ it('moves the entry: storno in the original period, corrected in the target period with the new date', async () => {
+ mockResolve.mockResolvedValue({ status: 'open', period_id: 'fp-2025', lock_date: null })
+ const reversalEntry = makeJournalEntry({ id: 'reversal-1', reverses_id: 'orig-1' })
+ const correctedEntry = makeJournalEntry({ id: 'corrected-1', correction_of_id: 'orig-1' })
+ // recordateEntry fetches the original once and hands it to correctEntry via
+ // preloadedOriginal, so there is no second original fetch in the sequence.
+ results = [
+ { data: original, error: null }, // 0 recordate fetch original
+ { data: { name: '2025', period_start: '2025-01-01', period_end: '2025-12-31' }, error: null }, // 1 target period
+ { data: reversalEntry, error: null }, // 2 insert reversal
+ { data: null, error: null }, // 3 reversal lines
+ { data: null, error: null }, // 4 post reversal
+ { data: [{ id: 'a1', account_number: '6230' }, { id: 'a2', account_number: '1930' }], error: null }, // 5 accounts
+ { data: correctedEntry, error: null }, // 6 insert corrected
+ { data: null, error: null }, // 7 corrected lines
+ { data: null, error: null }, // 8 post corrected
+ { data: [{ id: 'orig-1' }], error: null }, // 9 CAS
+ { data: { ...reversalEntry, lines: [] }, error: null }, // 10 final reversal
+ { data: { ...correctedEntry, lines: [] }, error: null }, // 11 final corrected
+ { data: null, error: null }, // 12 relink documents
+ ]
+ const supabase = makeClient()
+ const result = await recordateEntry(supabase as never, 'company-1', 'user-1', 'orig-1', '2025-07-03')
+ expect(result.corrected.id).toBe('corrected-1')
+
+ const je = inserts
+ .filter((i) => i.table === 'journal_entries')
+ .map((i) => i.payload as { source_type: string; fiscal_period_id: string; entry_date: string })
+ expect(je[0]).toMatchObject({ source_type: 'storno', fiscal_period_id: 'fp-2026', entry_date: '2026-07-03' })
+ expect(je[1]).toMatchObject({ source_type: 'correction', fiscal_period_id: 'fp-2025', entry_date: '2025-07-03' })
+ expect(mockResolve).toHaveBeenCalledWith(expect.anything(), 'company-1', '2025-07-03')
+ })
+})
diff --git a/lib/core/bookkeeping/__tests__/storno-service.test.ts b/lib/core/bookkeeping/__tests__/storno-service.test.ts
index 63f09f58..7d15231c 100644
--- a/lib/core/bookkeeping/__tests__/storno-service.test.ts
+++ b/lib/core/bookkeeping/__tests__/storno-service.test.ts
@@ -340,3 +340,92 @@ describe('correctEntry', () => {
)
})
})
+
+describe('correctEntry — date/period override (recordate engine)', () => {
+ const originalEntry = makeJournalEntry({
+ id: 'orig-1',
+ status: 'posted',
+ description: 'Webbhotell',
+ entry_date: '2024-06-15',
+ fiscal_period_id: 'fp-1',
+ voucher_series: 'A',
+ lines: [
+ makeJournalEntryLine({ account_number: '5410', debit_amount: 1000, credit_amount: 0 }),
+ makeJournalEntryLine({ account_number: '1930', debit_amount: 0, credit_amount: 1000 }),
+ ],
+ })
+
+ // Same multiset as the original — allowed here because the *date* is the
+ // change (a wrong-year fix keeps the lines untouched).
+ const identicalLines = [
+ { account_number: '5410', debit_amount: 1000, credit_amount: 0 },
+ { account_number: '1930', debit_amount: 0, credit_amount: 1000 },
+ ]
+
+ it('re-books the corrected entry in the target period/date while the storno stays in the original period', async () => {
+ const reversalEntry = makeJournalEntry({ id: 'reversal-1', reverses_id: 'orig-1' })
+ const correctedEntry = makeJournalEntry({ id: 'corrected-1', correction_of_id: 'orig-1' })
+ results = [
+ { data: originalEntry, error: null }, // 0 fetch original
+ { data: { name: '2025', period_start: '2025-01-01', period_end: '2025-12-31' }, error: null }, // 1 target period
+ { data: reversalEntry, error: null }, // 2 insert reversal
+ { data: null, error: null }, // 3 reversal lines
+ { data: null, error: null }, // 4 post reversal
+ { data: [{ id: 'acc-5410', account_number: '5410' }, { id: 'acc-1930', account_number: '1930' }], error: null }, // 5 accounts
+ { data: correctedEntry, error: null }, // 6 insert corrected
+ { data: null, error: null }, // 7 corrected lines
+ { data: null, error: null }, // 8 post corrected
+ { data: [{ id: 'orig-1' }], error: null }, // 9 CAS
+ { data: { ...reversalEntry, lines: [] }, error: null }, // 10 final reversal
+ { data: { ...correctedEntry, lines: [] }, error: null }, // 11 final corrected
+ ]
+ const supabase = makeClient()
+ const result = await correctEntry(
+ supabase as never,
+ 'company-1',
+ 'user-1',
+ 'orig-1',
+ identicalLines,
+ { newEntryDate: '2025-06-15', newFiscalPeriodId: 'fp-2' }
+ )
+ expect(result.corrected).toBeDefined()
+
+ const je = inserts
+ .filter((i) => i.table === 'journal_entries')
+ .map((i) => i.payload as { source_type: string; fiscal_period_id: string; entry_date: string })
+ expect(je).toHaveLength(2)
+ expect(je[0]).toMatchObject({ source_type: 'storno', fiscal_period_id: 'fp-1', entry_date: '2024-06-15' })
+ expect(je[1]).toMatchObject({ source_type: 'correction', fiscal_period_id: 'fp-2', entry_date: '2025-06-15' })
+ })
+
+ it('rejects when the new date falls outside the target period bounds', async () => {
+ results = [
+ { data: originalEntry, error: null }, // 0 fetch original
+ { data: { name: '2025', period_start: '2025-01-01', period_end: '2025-05-31' }, error: null }, // 1 target period — 06-15 out of bounds
+ ]
+ const supabase = makeClient()
+ await expect(
+ correctEntry(supabase as never, 'company-1', 'user-1', 'orig-1', identicalLines, {
+ newEntryDate: '2025-06-15',
+ newFiscalPeriodId: 'fp-2',
+ })
+ ).rejects.toMatchObject({ code: 'ENTRY_DATE_OUTSIDE_FISCAL_PERIOD' })
+
+ // No storno should have been written.
+ expect(inserts.filter((i) => i.table === 'journal_entries')).toHaveLength(0)
+ })
+
+ it('rejects when the target period cannot be found', async () => {
+ results = [
+ { data: originalEntry, error: null }, // 0 fetch original
+ { data: null, error: { message: 'no rows' } }, // 1 target period missing
+ ]
+ const supabase = makeClient()
+ await expect(
+ correctEntry(supabase as never, 'company-1', 'user-1', 'orig-1', identicalLines, {
+ newEntryDate: '2025-06-15',
+ newFiscalPeriodId: 'fp-2',
+ })
+ ).rejects.toMatchObject({ code: 'FISCAL_PERIOD_NOT_FOUND' })
+ })
+})
diff --git a/lib/core/bookkeeping/storno-service.ts b/lib/core/bookkeeping/storno-service.ts
index d49470b5..fc23cd4d 100644
--- a/lib/core/bookkeeping/storno-service.ts
+++ b/lib/core/bookkeeping/storno-service.ts
@@ -6,14 +6,20 @@ import type {
JournalEntryLine,
} from '@/types'
import { validateBalance, getNextVoucherNumber } from '@/lib/bookkeeping/engine'
+import { resolvePeriodStatusForDate } from '@/lib/core/bookkeeping/period-service'
import {
AccountsNotInChartError,
BookkeepingDatabaseError,
CannotCorrectNonPostedError,
EntryAlreadyReversedError,
+ EntryDateOutsideFiscalPeriodError,
+ FiscalPeriodNotFoundError,
JournalEntryNotBalancedError,
JournalEntryNotFoundError,
MeaninglessCorrectionError,
+ NoOpenPeriodForDateError,
+ TargetPeriodClosedError,
+ TargetPeriodLockedError,
} from '@/lib/bookkeeping/errors'
/**
@@ -90,9 +96,20 @@ async function cancelEntry(supabase: SupabaseClient, entryId: string): Promise {
// Validate the corrected lines are balanced
const balance = validateBalance(correctedLines)
@@ -115,16 +143,20 @@ export async function correctEntry(
throw new MeaninglessCorrectionError('net_zero_per_account')
}
- // Fetch original entry with lines
- const { data: original, error: fetchError } = await supabase
- .from('journal_entries')
- .select('*, lines:journal_entry_lines(*)')
- .eq('id', originalEntryId)
- .eq('company_id', companyId)
- .single()
+ // Fetch original entry with lines — unless the caller already loaded it.
+ let original = options?.preloadedOriginal ?? null
+ if (!original) {
+ const { data, error: fetchError } = await supabase
+ .from('journal_entries')
+ .select('*, lines:journal_entry_lines(*)')
+ .eq('id', originalEntryId)
+ .eq('company_id', companyId)
+ .single()
- if (fetchError || !original) {
- throw new JournalEntryNotFoundError()
+ if (fetchError || !data) {
+ throw new JournalEntryNotFoundError()
+ }
+ original = data as OriginalWithLines
}
if (original.status !== 'posted') {
@@ -133,12 +165,45 @@ export async function correctEntry(
const originalLines = (original.lines as JournalEntryLine[]) || []
- // Reject when the proposed lines are identical to the original entry —
- // a rättelse must actually change something.
- if (isIdenticalToOriginal(correctedLines, originalLines)) {
+ // Resolve where the corrected entry lands. Defaults to the original's own
+ // date/period (a plain line-correction). A caller may override either to
+ // re-book the entry in another period (recordate / wrong-year fix).
+ const correctedDate = options?.newEntryDate ?? original.entry_date
+ const correctedPeriodId = options?.newFiscalPeriodId ?? original.fiscal_period_id
+ const dateOrPeriodChanged =
+ correctedDate !== original.entry_date || correctedPeriodId !== original.fiscal_period_id
+
+ // Reject when the proposed lines are identical to the original entry — a
+ // rättelse must actually change something. Skip this when the date/period is
+ // the change (moving a verifikation to the right year keeps the same lines).
+ if (!dateOrPeriodChanged && isIdenticalToOriginal(correctedLines, originalLines)) {
throw new MeaninglessCorrectionError('identical_to_original')
}
+ // When re-booking elsewhere, validate the corrected date falls within the
+ // target period's bounds (mirrors createDraftEntry). recordateEntry resolves
+ // the period from the date, so this also guards a mismatched explicit
+ // override and fails fast before any storno is written.
+ if (dateOrPeriodChanged) {
+ const { data: targetPeriod, error: targetErr } = await supabase
+ .from('fiscal_periods')
+ .select('name, period_start, period_end')
+ .eq('id', correctedPeriodId)
+ .eq('company_id', companyId)
+ .single()
+ if (targetErr || !targetPeriod) {
+ throw new FiscalPeriodNotFoundError()
+ }
+ if (correctedDate < targetPeriod.period_start || correctedDate > targetPeriod.period_end) {
+ throw new EntryDateOutsideFiscalPeriodError(
+ correctedDate,
+ targetPeriod.name,
+ targetPeriod.period_start,
+ targetPeriod.period_end
+ )
+ }
+ }
+
// ===== Step 1: Create storno (reversal) entry =====
const reversalVoucherNumber = await getNextVoucherNumber(
supabase,
@@ -219,7 +284,7 @@ export async function correctEntry(
const correctedVoucherNumber = await getNextVoucherNumber(
supabase,
companyId,
- original.fiscal_period_id,
+ correctedPeriodId,
original.voucher_series || 'A'
)
@@ -248,10 +313,10 @@ export async function correctEntry(
.insert({
company_id: companyId,
user_id: userId,
- fiscal_period_id: original.fiscal_period_id,
+ fiscal_period_id: correctedPeriodId,
voucher_number: correctedVoucherNumber,
voucher_series: original.voucher_series || 'A',
- entry_date: original.entry_date,
+ entry_date: correctedDate,
description: `Rättelse: ${original.description}`,
source_type: 'correction',
correction_of_id: originalEntryId,
@@ -360,3 +425,127 @@ export async function correctEntry(
return result
}
+
+/**
+ * Move a posted verifikation to a different date — and thereby a different
+ * fiscal period — without changing its lines. Fixes a booking entered with the
+ * wrong date/year (e.g. 2026-07-03 that should have been 2025-07-03).
+ *
+ * A posted verifikation is immutable (BFL), so this is a storno + re-book under
+ * the hood: the original is reversed in its own period (netting it to zero
+ * there) and an identical corrected verifikation is posted with `newDate` in
+ * the target period. The underlag follows the corrected entry. The full chain
+ * original → storno → correction stays linked (BFL 5 kap. 5 §).
+ *
+ * Fails fast with a typed error if the target date is not bookable: closed year
+ * (TargetPeriodClosedError), locked period / company lock date
+ * (TargetPeriodLockedError), or no covering period (NoOpenPeriodForDateError).
+ */
+export async function recordateEntry(
+ supabase: SupabaseClient,
+ companyId: string,
+ userId: string,
+ originalEntryId: string,
+ newDate: string
+): Promise<{ reversal: JournalEntry; corrected: JournalEntry }> {
+ // Fetch original with lines
+ const { data: original, error: fetchError } = await supabase
+ .from('journal_entries')
+ .select('*, lines:journal_entry_lines(*)')
+ .eq('id', originalEntryId)
+ .eq('company_id', companyId)
+ .single()
+
+ if (fetchError || !original) {
+ throw new JournalEntryNotFoundError()
+ }
+ if (original.status !== 'posted') {
+ throw new CannotCorrectNonPostedError(original.status)
+ }
+ if (newDate === original.entry_date) {
+ throw new MeaninglessCorrectionError('no_date_change')
+ }
+
+ // Classify the target date using the same two-layer logic the DB triggers
+ // enforce (company lock date + period is_closed/locked_at), so we surface a
+ // clear Swedish message instead of a raw trigger rejection.
+ const target = await resolvePeriodStatusForDate(supabase, companyId, newDate)
+ if (target.status === 'closed') {
+ throw new TargetPeriodClosedError(newDate)
+ }
+ if (target.status === 'locked') {
+ throw new TargetPeriodLockedError(newDate, target.lock_date)
+ }
+ if (!target.period_id) {
+ // 'open' but no covering period — we do not auto-create periods on a fix.
+ throw new NoOpenPeriodForDateError(newDate)
+ }
+
+ // Copy the original lines verbatim — they were correct; only the date was
+ // wrong. correctEntry rebuilds the storno from the original anyway.
+ const originalLines = (original.lines as JournalEntryLine[]) || []
+ const copiedLines: CreateJournalEntryLineInput[] = originalLines
+ .slice()
+ .sort((a, b) => a.sort_order - b.sort_order)
+ .map((line) => ({
+ account_number: line.account_number,
+ debit_amount: Number(line.debit_amount) || 0,
+ credit_amount: Number(line.credit_amount) || 0,
+ line_description: line.line_description || undefined,
+ currency: line.currency || undefined,
+ amount_in_currency:
+ line.amount_in_currency != null ? Number(line.amount_in_currency) : undefined,
+ exchange_rate: line.exchange_rate != null ? Number(line.exchange_rate) : undefined,
+ tax_code: line.tax_code || undefined,
+ cost_center: line.cost_center || undefined,
+ project: line.project || undefined,
+ }))
+
+ const result = await correctEntry(
+ supabase,
+ companyId,
+ userId,
+ originalEntryId,
+ copiedLines,
+ {
+ newEntryDate: newDate,
+ newFiscalPeriodId: target.period_id,
+ // Hand the entry we already fetched (with lines) to correctEntry so it
+ // doesn't re-read the same row.
+ preloadedOriginal: original as OriginalWithLines,
+ }
+ )
+
+ // Move the underlag to the corrected entry so it doesn't surface as a
+ // "verifikat utan underlag" in the target year. Best-effort — the
+ // correction_of_id chain preserves traceability even if this fails.
+ await relinkDocumentsToEntry(supabase, companyId, originalEntryId, result.corrected.id)
+
+ return result
+}
+
+/**
+ * Re-point every document_attachment from one entry to another. Used when a
+ * verifikation is moved to a different period so its underlag travels with the
+ * live (corrected) entry. The line-level link is cleared because the corrected
+ * entry has new line ids. Failures are logged, not thrown — the entry-level
+ * correction chain is the source of truth for traceability.
+ */
+async function relinkDocumentsToEntry(
+ supabase: SupabaseClient,
+ companyId: string,
+ fromEntryId: string,
+ toEntryId: string
+): Promise {
+ const { error } = await supabase
+ .from('document_attachments')
+ .update({ journal_entry_id: toEntryId, journal_entry_line_id: null })
+ .eq('company_id', companyId)
+ .eq('journal_entry_id', fromEntryId)
+ if (error) {
+ console.error(
+ `[storno] relinkDocumentsToEntry: failed to move documents ${fromEntryId} → ${toEntryId}:`,
+ error.message
+ )
+ }
+}
diff --git a/lib/errors/get-error-message.ts b/lib/errors/get-error-message.ts
index 7ce78bed..b953d1b1 100644
--- a/lib/errors/get-error-message.ts
+++ b/lib/errors/get-error-message.ts
@@ -324,8 +324,26 @@ export function getErrorMessage(
return 'Kontering saknas för transaktionen. Kontrollera bokföringsreglerna.'
}
+ if (structured.code === 'NO_OPEN_PERIOD_FOR_DATE') {
+ return 'Det finns ingen räkenskapsperiod som täcker det valda datumet. Skapa eller öppna räkenskapsåret först.'
+ }
+
+ if (structured.code === 'TARGET_PERIOD_CLOSED') {
+ return 'Räkenskapsåret för det valda datumet är stängt (bokslut) och kan inte återöppnas. Bokför rättelsen i innevarande period istället.'
+ }
+
+ if (structured.code === 'TARGET_PERIOD_LOCKED') {
+ const details = structured.details as { lockDate?: string } | undefined
+ return details?.lockDate
+ ? `Räkenskapsperioden för det valda datumet är låst (t.o.m. ${details.lockDate}). Lås upp perioden för att flytta verifikationen dit.`
+ : 'Räkenskapsperioden för det valda datumet är låst. Lås upp perioden för att flytta verifikationen dit.'
+ }
+
if (structured.code === 'MEANINGLESS_CORRECTION') {
const details = structured.details as { reason?: string } | undefined
+ if (details?.reason === 'no_date_change') {
+ return 'Det nya datumet är samma som det nuvarande — det finns inget att flytta.'
+ }
if (details?.reason === 'identical_to_original') {
return 'Rättelsen är identisk med originalverifikationen — inget har ändrats.'
}
diff --git a/lib/errors/structured-errors.ts b/lib/errors/structured-errors.ts
index 213ef635..af012733 100644
--- a/lib/errors/structured-errors.ts
+++ b/lib/errors/structured-errors.ts
@@ -259,6 +259,13 @@ const TRANSACTIONS: Record = {
message_sv: 'Transaktionen kunde inte hittas.',
message_en: 'Transaction not found.',
},
+ TRANSACTION_TITLE_LOCKED: {
+ httpStatus: 409,
+ message_sv:
+ 'Det går inte att ändra titeln på en bokförd eller matchad transaktion. Bokförda verifikat rättas med storno.',
+ message_en:
+ 'Cannot edit the title of a booked or matched transaction. Posted vouchers are corrected with storno.',
+ },
TX_CATEGORIZE_INVALID_ACCOUNT: {
httpStatus: 400,
message_sv: 'Det valda kontot finns inte i kontoplanen.',
@@ -1416,6 +1423,13 @@ const SUPPLIER_INVOICE_WAVE4: Record = {
message_sv: 'Ogiltig kombination av fakturafält. Kontrollera formuläret och försök igen.',
message_en: 'Invalid combination of supplier invoice fields.',
},
+ SI_CREATE_NO_FISCAL_PERIOD: {
+ httpStatus: 400,
+ message_sv:
+ 'Det finns inget räkenskapsår som täcker fakturadatumet. Lägg upp räkenskapsåret först, eller ändra fakturadatumet.',
+ message_en:
+ 'No fiscal year covers the invoice date. Create the fiscal year first, or change the invoice date.',
+ },
SI_PAID_ALREADY: {
httpStatus: 409,
message_sv: 'Leverantörsfakturan är redan betald eller krediterad.',
diff --git a/lib/import/bank-file/__tests__/parser.test.ts b/lib/import/bank-file/__tests__/parser.test.ts
index b042da11..e4446d4d 100644
--- a/lib/import/bank-file/__tests__/parser.test.ts
+++ b/lib/import/bank-file/__tests__/parser.test.ts
@@ -79,6 +79,34 @@ const HANDELSBANKEN_CSV_WITH_PREL = [
'2024-01-13;2024-01-13;LÖNEUTBETALNING;25000,00;12643,67',
].join('\n')
+// Real Handelsbanken web exports can prepend account/period metadata rows
+// (and a blank line) before the actual column header.
+const HANDELSBANKEN_CSV_WITH_PREAMBLE = [
+ 'Kontonummer;6789 123 456 789',
+ 'Kontohavare;Wiklund, Cristel',
+ 'Period;2024-01-01 - 2024-01-31',
+ '',
+ 'Reskontradatum;Transaktionsdatum;Text;Belopp;Saldo',
+ '2024-01-15;2024-01-15;SPOTIFY AB;-99,00;12345,67',
+ '2024-01-14;2024-01-14;HEMKÖP;-432,50;12444,67',
+ '2024-01-13;2024-01-13;LÖNEUTBETALNING;25000,00;12877,17',
+].join('\n')
+
+// Negative amounts exported with a Unicode minus (U+2212) instead of ASCII '-'.
+const HANDELSBANKEN_CSV_UNICODE_MINUS = [
+ 'Reskontradatum;Transaktionsdatum;Text;Belopp;Saldo',
+ '2024-01-15;2024-01-15;SPOTIFY AB;−139,00;12345,67',
+ '2024-01-14;2024-01-14;HEMKÖP;−1 432,50;12444,67',
+ '2024-01-13;2024-01-13;LÖNEUTBETALNING;25000,00;12877,17',
+].join('\n')
+
+// A quoted Text field that itself contains the semicolon delimiter.
+const HANDELSBANKEN_CSV_QUOTED_SEMICOLON = [
+ 'Reskontradatum;Transaktionsdatum;Text;Belopp;Saldo',
+ '2024-01-15;2024-01-15;"BETALNING; FAKTURA 100";-99,00;12345,67',
+ '2024-01-14;2024-01-14;HEMKÖP;-432,50;12444,67',
+].join('\n')
+
const CAMT053_XML = `
@@ -877,6 +905,47 @@ describe('parseBankFile — Handelsbanken format', () => {
const result = parseBankFile(diffDates, 'shb.csv')
expect(result.transactions[0].date).toBe('2024-01-15')
})
+
+ it('detects Handelsbanken CSV when a metadata preamble precedes the header', () => {
+ const format = detectFileFormat(HANDELSBANKEN_CSV_WITH_PREAMBLE, 'kontoutdrag.csv')
+ expect(format).not.toBeNull()
+ expect(format!.id).toBe('handelsbanken')
+ })
+
+ it('skips the metadata preamble rows and parses the transactions', () => {
+ const result = parseBankFile(HANDELSBANKEN_CSV_WITH_PREAMBLE, 'kontoutdrag.csv')
+
+ expect(result.format).toBe('handelsbanken')
+ expect(result.transactions).toHaveLength(3)
+ expect(result.issues).toHaveLength(0)
+ expect(result.stats.skipped_rows).toBe(0)
+
+ const descriptions = result.transactions.map((t) => t.description)
+ expect(descriptions).not.toContain('Kontonummer')
+ expect(result.transactions[0].description).toBe('SPOTIFY AB')
+ expect(result.transactions[2].amount).toBe(25000)
+ })
+
+ it('parses negative amounts that use a Unicode minus (U+2212) instead of dropping them', () => {
+ const result = parseBankFile(HANDELSBANKEN_CSV_UNICODE_MINUS, 'shb.csv')
+
+ expect(result.transactions).toHaveLength(3)
+ expect(result.issues).toHaveLength(0)
+ expect(result.stats.skipped_rows).toBe(0)
+ expect(result.transactions[0].amount).toBe(-139)
+ expect(result.transactions[1].amount).toBe(-1432.5)
+ expect(result.transactions[2].amount).toBe(25000)
+ })
+
+ it('handles a quoted Text field that contains the semicolon delimiter', () => {
+ const result = parseBankFile(HANDELSBANKEN_CSV_QUOTED_SEMICOLON, 'shb.csv')
+
+ expect(result.transactions).toHaveLength(2)
+ expect(result.issues).toHaveLength(0)
+ expect(result.transactions[0].description).toBe('BETALNING; FAKTURA 100')
+ expect(result.transactions[0].amount).toBe(-99)
+ expect(result.transactions[1].amount).toBe(-432.5)
+ })
})
describe('parseBankFile — Länsförsäkringar format', () => {
diff --git a/lib/import/bank-file/formats/handelsbanken.ts b/lib/import/bank-file/formats/handelsbanken.ts
index 1a599d92..7742bc71 100644
--- a/lib/import/bank-file/formats/handelsbanken.ts
+++ b/lib/import/bank-file/formats/handelsbanken.ts
@@ -7,18 +7,45 @@
* Encoding: UTF-8 or Windows-1252
*
* Notes:
- * - Filter rows with "Prel" prefix (preliminary/pending transactions)
+ * - Real Handelsbanken exports may prepend metadata rows (account number, period,
+ * balance) before the column header, so we scan the first lines for the header
+ * rather than assuming it is line 0.
+ * - Fields may be double-quoted and a quoted "Text" field can itself contain a
+ * semicolon, so we use the quote-aware parseCSVLine rather than split(';').
+ * - Negative amounts may use a Unicode minus (U+2212) or dash; normalizeMinusSign
+ * maps those to ASCII '-' so parseFloat does not return NaN.
+ * - Filter rows with "Prel" prefix (preliminary/pending transactions).
*/
import type { BankFileFormat, BankFileParseResult, ParsedBankTransaction, BankFileParseIssue } from '../types'
import { prepareContent } from '../../shared/encoding'
import { normalizeDate } from '../date-utils'
+import { parseCSVLine } from './nordea'
+import { normalizeMinusSign } from './generic-csv'
+
+// How many leading lines to scan for the header (allows for a metadata preamble)
+const HEADER_SCAN_LIMIT = 15
function parseCommaDecimal(value: string): number {
- const cleaned = value.replace(/\s/g, '').replace(',', '.')
+ // Swedish format "1 234,56" / "-1 234,56", tolerating Unicode minus on negatives
+ const cleaned = normalizeMinusSign(value).replace(/\s/g, '').replace(',', '.')
return parseFloat(cleaned)
}
+/**
+ * Check if a line is the Handelsbanken transaction header: semicolon-delimited
+ * and carrying a Handelsbanken date column plus the amount column. Specific
+ * enough not to steal SEB / Nordea Företag / ICA / Skandia files, which use
+ * different date labels (bokföringsdag, valutadag, datum).
+ */
+function isHandelsbankenHeader(line: string): boolean {
+ const lower = line.toLowerCase()
+ if (!lower.includes(';')) return false
+ const hasDate = lower.includes('reskontradatum') || lower.includes('transaktionsdatum')
+ const hasAmount = lower.includes('belopp')
+ return hasDate && hasAmount
+}
+
export const handelsbankenFormat: BankFileFormat = {
id: 'handelsbanken',
name: 'Handelsbanken',
@@ -27,11 +54,8 @@ export const handelsbankenFormat: BankFileFormat = {
detect(content: string, _filename: string): boolean {
const prepared = prepareContent(content)
- const firstLine = prepared.split('\n')[0]?.toLowerCase() || ''
- return (
- firstLine.includes(';') &&
- (firstLine.includes('reskontradatum') || firstLine.includes('transaktionsdatum'))
- )
+ const lines = prepared.split('\n')
+ return lines.slice(0, HEADER_SCAN_LIMIT).some(isHandelsbankenHeader)
},
parse(content: string): BankFileParseResult {
@@ -42,43 +66,59 @@ export const handelsbankenFormat: BankFileFormat = {
const issues: BankFileParseIssue[] = []
let skippedRows = 0
- // Parse header
- const headerLine = lines[0] || ''
- const headers = headerLine.split(';').map((h) => h.trim().toLowerCase().replace(/"/g, ''))
+ const emptyResult = (issue: BankFileParseIssue): BankFileParseResult => ({
+ format: 'handelsbanken',
+ format_name: 'Handelsbanken',
+ transactions: [],
+ date_from: null,
+ date_to: null,
+ issues: [issue],
+ stats: { total_rows: 0, parsed_rows: 0, skipped_rows: 0, total_income: 0, total_expenses: 0 },
+ })
- const dateIdx = headers.findIndex(
- (h) => h.includes('reskontradatum') || h.includes('transaktionsdatum')
+ // Find the header row, skipping any metadata preamble.
+ let headerLineIdx = -1
+ for (let i = 0; i < Math.min(lines.length, HEADER_SCAN_LIMIT); i++) {
+ if (isHandelsbankenHeader(lines[i])) {
+ headerLineIdx = i
+ break
+ }
+ }
+
+ if (headerLineIdx === -1) {
+ return emptyResult({
+ row: 1,
+ message: 'Kunde inte hitta rubrikraden (Transaktionsdatum/Reskontradatum, Belopp).',
+ severity: 'error',
+ })
+ }
+
+ const headers = parseCSVLine(lines[headerLineIdx], ';').map((h) =>
+ h.trim().toLowerCase().replace(/"/g, '')
)
+
+ const reskontraIdx = headers.findIndex((h) => h.includes('reskontradatum'))
const txDateIdx = headers.findIndex((h) => h.includes('transaktionsdatum'))
const descIdx = headers.findIndex((h) => h === 'text' || h.includes('beskrivning'))
const amountIdx = headers.findIndex((h) => h.includes('belopp'))
const balanceIdx = headers.findIndex((h) => h.includes('saldo'))
- if (dateIdx === -1 || amountIdx === -1) {
- issues.push({
- row: 1,
+ // Prefer transaktionsdatum (real transaction date) over reskontradatum (booking date)
+ const primaryDateIdx = txDateIdx >= 0 ? txDateIdx : reskontraIdx
+
+ if (primaryDateIdx === -1 || amountIdx === -1) {
+ return emptyResult({
+ row: headerLineIdx + 1,
message: 'Could not identify required columns',
severity: 'error',
})
- return {
- format: 'handelsbanken',
- format_name: 'Handelsbanken',
- transactions: [],
- date_from: null,
- date_to: null,
- issues,
- stats: { total_rows: 0, parsed_rows: 0, skipped_rows: 0, total_income: 0, total_expenses: 0 },
- }
}
- // Prefer transaktionsdatum over reskontradatum if available
- const primaryDateIdx = txDateIdx >= 0 ? txDateIdx : dateIdx
-
- for (let i = 1; i < lines.length; i++) {
+ for (let i = headerLineIdx + 1; i < lines.length; i++) {
const line = lines[i].trim()
if (!line) continue
- const fields = line.split(';').map((f) => f.trim().replace(/^"|"$/g, ''))
+ const fields = parseCSVLine(line, ';').map((f) => f.trim().replace(/^"|"$/g, ''))
const date = fields[primaryDateIdx]
const description = descIdx >= 0 ? fields[descIdx] : 'Unknown'
@@ -138,7 +178,7 @@ export const handelsbankenFormat: BankFileFormat = {
date_to: dates[dates.length - 1] || null,
issues,
stats: {
- total_rows: lines.length - 1,
+ total_rows: lines.length - headerLineIdx - 1,
parsed_rows: transactions.length,
skipped_rows: skippedRows,
total_income: Math.round(transactions.filter((t) => t.amount > 0).reduce((s, t) => s + t.amount, 0) * 100) / 100,
diff --git a/lib/pending-operations/__tests__/inbox-link-status.pg.test.ts b/lib/pending-operations/__tests__/inbox-link-status.pg.test.ts
new file mode 100644
index 00000000..c42eeaa1
--- /dev/null
+++ b/lib/pending-operations/__tests__/inbox-link-status.pg.test.ts
@@ -0,0 +1,244 @@
+import { randomUUID } from 'node:crypto'
+import { describe, expect, it } from 'vitest'
+import { insertDraftJournalEntry, seedCompany } from '@/tests/pg/fixtures'
+import { getPool } from '@/tests/pg/setup'
+
+/**
+ * Regression guard for the inbox-linking step of the two "from inbox" commit
+ * handlers in lib/pending-operations/commit.ts:
+ *
+ * - commitCreateVoucher (book-direct kvitto → verifikat)
+ * - commitCreateSupplierInvoiceFromInbox (inbox → leverantörsfaktura)
+ *
+ * Both resolve the source inbox row on approval by stamping a terminal link
+ * column (created_journal_entry_id / created_supplier_invoice_id). An earlier
+ * version of both handlers ALSO wrote `status: 'confirmed'` in that same
+ * UPDATE. But migration 20260504180000 tightened the status enum to
+ * `CHECK (status IN ('received','error'))` — 'confirmed' is no longer legal.
+ *
+ * Because the link column and the illegal status were set in ONE atomic
+ * UPDATE, Postgres rejected the whole statement. The handler swallowed the
+ * error with a non-fatal log.warn, so the verifikat / supplier invoice was
+ * created but the inbox item silently stayed in "needs action" (its link
+ * column never landed) and the OCR document was never attached. That was the
+ * reported bug.
+ *
+ * The unit suites (voucher-executors.test.ts, create-supplier-invoice-from-
+ * inbox.test.ts) mock @/lib/supabase/server, so the CHECK constraint is never
+ * exercised — the buggy UPDATE "succeeds" against the mock. Only a real
+ * Postgres catches it. This test locks the DB-level contract the fix depends
+ * on: resolving an inbox row writes ONLY the link column, never `status`.
+ */
+
+// status='received', source='upload' — a fresh, unresolved inbox row, exactly
+// what an uploaded item looks like before it's booked.
+async function insertInboxItem(params: {
+ userId: string
+ companyId: string
+ documentId?: string | null
+}): Promise {
+ const id = randomUUID()
+ await getPool().query(
+ `INSERT INTO public.invoice_inbox_items
+ (id, user_id, company_id, status, source, document_id)
+ VALUES ($1, $2, $3, 'received', 'upload', $4)`,
+ [id, params.userId, params.companyId, params.documentId ?? null],
+ )
+ return id
+}
+
+// Minimal supplier + supplier_invoice so created_supplier_invoice_id has a
+// valid FK target. arrival_number is UNIQUE per user; one per fresh tenant.
+async function insertSupplierInvoice(params: {
+ userId: string
+ companyId: string
+}): Promise {
+ const supplierId = randomUUID()
+ await getPool().query(
+ `INSERT INTO public.suppliers (id, user_id, company_id, name)
+ VALUES ($1, $2, $3, 'Test Leverantör AB')`,
+ [supplierId, params.userId, params.companyId],
+ )
+ const invoiceId = randomUUID()
+ await getPool().query(
+ `INSERT INTO public.supplier_invoices
+ (id, user_id, company_id, supplier_id, arrival_number,
+ supplier_invoice_number, invoice_date, due_date)
+ VALUES ($1, $2, $3, $4, 1, 'INV-1', '2026-05-01', '2026-05-31')`,
+ [invoiceId, params.userId, params.companyId, supplierId],
+ )
+ return invoiceId
+}
+
+async function readInbox(
+ inboxId: string,
+): Promise<{ status: string; created_journal_entry_id: string | null; created_supplier_invoice_id: string | null }> {
+ const res = await getPool().query<{
+ status: string
+ created_journal_entry_id: string | null
+ created_supplier_invoice_id: string | null
+ }>(
+ `SELECT status, created_journal_entry_id, created_supplier_invoice_id
+ FROM public.invoice_inbox_items WHERE id = $1`,
+ [inboxId],
+ )
+ return res.rows[0]!
+}
+
+describe('invoice_inbox_items status CHECK — root cause', () => {
+ it("rejects status='confirmed' (the value the old handlers wrote)", async () => {
+ const { userId, companyId } = await seedCompany()
+ const inboxId = await insertInboxItem({ userId, companyId })
+
+ await expect(
+ getPool().query(
+ `UPDATE public.invoice_inbox_items SET status = 'confirmed' WHERE id = $1`,
+ [inboxId],
+ ),
+ ).rejects.toThrow(/invoice_inbox_items_status_check|violates check constraint/)
+ })
+
+ it("accepts the two legal status values, received and error", async () => {
+ const { userId, companyId } = await seedCompany()
+ const inboxId = await insertInboxItem({ userId, companyId })
+
+ await getPool().query(
+ `UPDATE public.invoice_inbox_items SET status = 'error' WHERE id = $1`,
+ [inboxId],
+ )
+ await getPool().query(
+ `UPDATE public.invoice_inbox_items SET status = 'received' WHERE id = $1`,
+ [inboxId],
+ )
+ expect((await readInbox(inboxId)).status).toBe('received')
+ })
+})
+
+describe('commitCreateVoucher inbox link (book-direct kvitto)', () => {
+ // Mirrors the WHERE clause at lib/pending-operations/commit.ts (the race
+ // guard: only the first commit on a still-unresolved row wins).
+ const WHERE = `WHERE id = $2 AND company_id = $3
+ AND created_journal_entry_id IS NULL
+ AND created_supplier_invoice_id IS NULL
+ RETURNING id`
+
+ it("OLD buggy form (link + status='confirmed') is rejected ATOMICALLY — link never lands", async () => {
+ const { userId, companyId, fiscalPeriodId } = await seedCompany()
+ const inboxId = await insertInboxItem({ userId, companyId })
+ const jeId = await insertDraftJournalEntry({ userId, companyId, fiscalPeriodId })
+
+ await expect(
+ getPool().query(
+ `UPDATE public.invoice_inbox_items
+ SET created_journal_entry_id = $1, status = 'confirmed' ${WHERE}`,
+ [jeId, inboxId, companyId],
+ ),
+ ).rejects.toThrow(/invoice_inbox_items_status_check|violates check constraint/)
+
+ // The atomic rejection is the bug: the verifikat is posted, but the inbox
+ // row is untouched and stays in "needs action".
+ const row = await readInbox(inboxId)
+ expect(row.created_journal_entry_id).toBeNull()
+ expect(row.status).toBe('received')
+ })
+
+ it('FIXED form (link only) lands created_journal_entry_id and leaves status=received', async () => {
+ const { userId, companyId, fiscalPeriodId } = await seedCompany()
+ const inboxId = await insertInboxItem({ userId, companyId })
+ const jeId = await insertDraftJournalEntry({ userId, companyId, fiscalPeriodId })
+
+ const res = await getPool().query(
+ `UPDATE public.invoice_inbox_items
+ SET created_journal_entry_id = $1 ${WHERE}`,
+ [jeId, inboxId, companyId],
+ )
+ expect(res.rows).toHaveLength(1) // one row claimed
+
+ const row = await readInbox(inboxId)
+ expect(row.created_journal_entry_id).toBe(jeId)
+ // status untouched — the link column alone drops the row out of the
+ // "needs action" filter (the UI and list_unmatched_documents read it).
+ expect(row.status).toBe('received')
+ })
+
+ it('race guard: a second commit on an already-linked row updates 0 rows (no clobber)', async () => {
+ const { userId, companyId, fiscalPeriodId } = await seedCompany()
+ const inboxId = await insertInboxItem({ userId, companyId })
+ const je1 = await insertDraftJournalEntry({ userId, companyId, fiscalPeriodId })
+ const je2 = await insertDraftJournalEntry({ userId, companyId, fiscalPeriodId })
+
+ const first = await getPool().query(
+ `UPDATE public.invoice_inbox_items SET created_journal_entry_id = $1 ${WHERE}`,
+ [je1, inboxId, companyId],
+ )
+ expect(first.rows).toHaveLength(1)
+
+ // Loser: the `created_journal_entry_id IS NULL` predicate no longer holds.
+ const second = await getPool().query(
+ `UPDATE public.invoice_inbox_items SET created_journal_entry_id = $1 ${WHERE}`,
+ [je2, inboxId, companyId],
+ )
+ expect(second.rows).toHaveLength(0)
+ expect((await readInbox(inboxId)).created_journal_entry_id).toBe(je1)
+ })
+
+ it('UNIQUE(created_journal_entry_id) blocks two inbox rows pointing at the same verifikat', async () => {
+ const { userId, companyId, fiscalPeriodId } = await seedCompany()
+ const inboxA = await insertInboxItem({ userId, companyId })
+ const inboxB = await insertInboxItem({ userId, companyId })
+ const jeId = await insertDraftJournalEntry({ userId, companyId, fiscalPeriodId })
+
+ await getPool().query(
+ `UPDATE public.invoice_inbox_items SET created_journal_entry_id = $1 WHERE id = $2`,
+ [jeId, inboxA],
+ )
+ await expect(
+ getPool().query(
+ `UPDATE public.invoice_inbox_items SET created_journal_entry_id = $1 WHERE id = $2`,
+ [jeId, inboxB],
+ ),
+ ).rejects.toThrow(/unique|invoice_inbox_items_created_je/i)
+ })
+})
+
+describe('commitCreateSupplierInvoiceFromInbox inbox link', () => {
+ // Mirrors the WHERE clause at lib/pending-operations/commit.ts:1726 —
+ // id + company_id only; idempotency is handled by an early-return check
+ // upstream, so this UPDATE carries no null guards.
+ const WHERE = `WHERE id = $2 AND company_id = $3 RETURNING id`
+
+ it("OLD buggy form (link + status='confirmed') is rejected ATOMICALLY — link never lands", async () => {
+ const { userId, companyId } = await seedCompany()
+ const inboxId = await insertInboxItem({ userId, companyId })
+ const supplierInvoiceId = await insertSupplierInvoice({ userId, companyId })
+
+ await expect(
+ getPool().query(
+ `UPDATE public.invoice_inbox_items
+ SET created_supplier_invoice_id = $1, status = 'confirmed' ${WHERE}`,
+ [supplierInvoiceId, inboxId, companyId],
+ ),
+ ).rejects.toThrow(/invoice_inbox_items_status_check|violates check constraint/)
+
+ const row = await readInbox(inboxId)
+ expect(row.created_supplier_invoice_id).toBeNull()
+ expect(row.status).toBe('received')
+ })
+
+ it('FIXED form (link only) lands created_supplier_invoice_id and leaves status=received', async () => {
+ const { userId, companyId } = await seedCompany()
+ const inboxId = await insertInboxItem({ userId, companyId })
+ const supplierInvoiceId = await insertSupplierInvoice({ userId, companyId })
+
+ const res = await getPool().query(
+ `UPDATE public.invoice_inbox_items
+ SET created_supplier_invoice_id = $1 ${WHERE}`,
+ [supplierInvoiceId, inboxId, companyId],
+ )
+ expect(res.rows).toHaveLength(1)
+
+ const row = await readInbox(inboxId)
+ expect(row.created_supplier_invoice_id).toBe(supplierInvoiceId)
+ expect(row.status).toBe('received')
+ })
+})
diff --git a/lib/pending-operations/__tests__/voucher-executors.test.ts b/lib/pending-operations/__tests__/voucher-executors.test.ts
index a754ac49..48e3f828 100644
--- a/lib/pending-operations/__tests__/voucher-executors.test.ts
+++ b/lib/pending-operations/__tests__/voucher-executors.test.ts
@@ -313,10 +313,12 @@ describe('commitPendingOperation: create_voucher', () => {
// ── inbox-direct booking flow ──────────────────────────────────────
// gnubok_create_voucher accepts an optional inbox_item_id. On commit, the
- // executor must update invoice_inbox_items (created_journal_entry_id +
- // status='confirmed') and attach the OCR document to the new JE.
+ // executor must stamp invoice_inbox_items.created_journal_entry_id (the
+ // signal that drops the row out of "needs action") and attach the OCR
+ // document to the new JE. Status is left untouched — the status CHECK only
+ // allows received|error, so the link column alone marks the row processed.
- it('inbox-direct: posts the entry, marks inbox confirmed, and attaches the document', async () => {
+ it('inbox-direct: posts the entry, links the inbox row, and attaches the document', async () => {
vi.mocked(createJournalEntry).mockResolvedValueOnce(
makeJournalEntry({ id: 'je-inbox', voucher_number: 17, voucher_series: 'A' })
)
diff --git a/lib/pending-operations/commit.ts b/lib/pending-operations/commit.ts
index 95d1ca33..ed7c67a7 100644
--- a/lib/pending-operations/commit.ts
+++ b/lib/pending-operations/commit.ts
@@ -1677,6 +1677,27 @@ async function commitCreateSupplierInvoiceFromInbox(
})
}
}
+ } else {
+ // createSupplierInvoiceRegistrationEntry returns null ONLY when no
+ // fiscal period covers invoice_date (every other failure throws into
+ // the catch below). Without this branch the inbox item gets linked to
+ // an unbooked supplier invoice — the same 2440/2641 orphan the catch
+ // guards against. Roll back (items first, see FK note below) and return
+ // an actionable error instead of silently "succeeding".
+ await supabase
+ .from('supplier_invoice_items')
+ .delete()
+ .eq('supplier_invoice_id', invoice.id)
+ await supabase
+ .from('supplier_invoices')
+ .delete()
+ .eq('id', invoice.id)
+ .eq('company_id', companyId)
+ return {
+ error:
+ 'Det finns inget räkenskapsår som täcker fakturadatumet. Lägg upp räkenskapsåret först, eller ändra fakturadatumet.',
+ status: 400,
+ }
}
} catch (err) {
// Roll back: orphan supplier_invoices row without its registration JE
@@ -1719,11 +1740,14 @@ async function commitCreateSupplierInvoiceFromInbox(
}
// Terminal state for the inbox row: created_supplier_invoice_id is the
- // dedup key for next time this inbox item is touched. status='confirmed'
- // removes it from the "needs action" filter in the UI.
+ // dedup key for next time this inbox item is touched, and it's what the UI
+ // and list_unmatched_documents use to drop the row out of "needs action".
+ // Do NOT write status here — the status CHECK only allows received|error
+ // (migration 20260504180000); writing 'confirmed' makes Postgres reject the
+ // whole UPDATE, so the link column never lands and the item stays unresolved.
const { error: linkInboxErr } = await supabase
.from('invoice_inbox_items')
- .update({ created_supplier_invoice_id: invoice.id, status: 'confirmed' })
+ .update({ created_supplier_invoice_id: invoice.id })
.eq('id', inboxItemId)
.eq('company_id', companyId)
@@ -2301,9 +2325,12 @@ async function commitCreateVoucher(
// zero-rows-updated result and surfaces a structured warning. We also
// require .eq('created_supplier_invoice_id', null) so a concurrent
// create_supplier_invoice_from_inbox doesn't get clobbered either.
+ // Only the link column is written — the status CHECK allows received|error
+ // (migration 20260504180000), so writing 'confirmed' here would fail the
+ // whole UPDATE and silently leave the inbox item in "needs action".
const { data: updatedRows, error: linkInboxErr } = await supabase
.from('invoice_inbox_items')
- .update({ created_journal_entry_id: entry.id, status: 'confirmed' })
+ .update({ created_journal_entry_id: entry.id })
.eq('id', inboxItemId)
.eq('company_id', companyId)
.is('created_journal_entry_id', null)
diff --git a/lib/reconciliation/__tests__/bank-reconciliation.test.ts b/lib/reconciliation/__tests__/bank-reconciliation.test.ts
index 0a9dfe7d..1e9dafc9 100644
--- a/lib/reconciliation/__tests__/bank-reconciliation.test.ts
+++ b/lib/reconciliation/__tests__/bank-reconciliation.test.ts
@@ -600,8 +600,8 @@ describe('getReconciliationStatus', () => {
// 2) journal_entry_lines: 50,000 IB debit + 1000 matched debit on 1930
enqueue({
data: [
- { debit_amount: 50000, credit_amount: 0, journal_entries: { source_type: 'opening_balance' } },
- { debit_amount: 1000, credit_amount: 0, journal_entries: { source_type: 'bank_import' } },
+ { debit_amount: 50000, credit_amount: 0, journal_entries: { status: 'posted', source_type: 'opening_balance' } },
+ { debit_amount: 1000, credit_amount: 0, journal_entries: { status: 'posted', source_type: 'bank_import' } },
],
})
// 3) RPC get_unlinked_1930_lines: returns empty (RPC excludes IB after migration)
@@ -631,8 +631,8 @@ describe('getReconciliationStatus', () => {
// 2) GL lines: 50,000 IB + 1000 booked
enqueue({
data: [
- { debit_amount: 50000, credit_amount: 0, journal_entries: { source_type: 'opening_balance' } },
- { debit_amount: 1000, credit_amount: 0, journal_entries: { source_type: 'bank_import' } },
+ { debit_amount: 50000, credit_amount: 0, journal_entries: { status: 'posted', source_type: 'opening_balance' } },
+ { debit_amount: 1000, credit_amount: 0, journal_entries: { status: 'posted', source_type: 'bank_import' } },
],
})
// 3) RPC: empty
@@ -653,7 +653,7 @@ describe('getReconciliationStatus', () => {
enqueue({ data: [{ amount: 100, journal_entry_id: 'je-1', reconciliation_method: 'auto_exact' }] })
enqueue({
- data: [{ debit_amount: 100, credit_amount: 0, journal_entries: { source_type: 'bank_import' } }],
+ data: [{ debit_amount: 100, credit_amount: 0, journal_entries: { status: 'posted', source_type: 'bank_import' } }],
})
enqueue({ data: [] })
@@ -674,8 +674,8 @@ describe('getReconciliationStatus', () => {
enqueue({ data: [] })
enqueue({
data: [
- { debit_amount: 1000, credit_amount: 0, journal_entries: [{ source_type: 'opening_balance' }] },
- { debit_amount: 200, credit_amount: 0, journal_entries: [{ source_type: 'bank_import' }] },
+ { debit_amount: 1000, credit_amount: 0, journal_entries: [{ status: 'posted', source_type: 'opening_balance' }] },
+ { debit_amount: 200, credit_amount: 0, journal_entries: [{ status: 'posted', source_type: 'bank_import' }] },
],
})
enqueue({ data: [] })
@@ -685,4 +685,77 @@ describe('getReconciliationStatus', () => {
expect(status.gl_1930_opening_balance).toBe(1000)
expect(status.gl_1930_period_movement).toBe(200)
})
+
+ it('nets a book-only correction (storno + rättelse) to a reconciled period', async () => {
+ // The reported bug: a correction made via the storno flow puts a posted
+ // storno AND a posted correction on 1930, while the original flips to
+ // 'reversed'. Both posted vouchers have no bank-feed counterpart. Before the
+ // fix they inflated the period movement and showed as omatchade
+ // verifikationer, manufacturing a phantom diff. They must be excluded from
+ // the movement so a fully-matched period reconciles.
+ const { supabase, enqueue } = createQueueMockSupabase()
+
+ // 1) transactions: one real matched outflow of -9908.75
+ enqueue({
+ data: [{ amount: -9908.75, journal_entry_id: 'je-others', reconciliation_method: 'auto_exact' }],
+ })
+ // 2) GL lines on 1930: the matched outflow, plus the correction cluster.
+ // Original (credit 25000) is status='reversed'; storno (debit 25000) and
+ // correction (debit 25000) are posted. None of the cluster is linked.
+ enqueue({
+ data: [
+ { debit_amount: 0, credit_amount: 9908.75, journal_entries: { id: 'je-others', status: 'posted', source_type: 'bank_import' } },
+ { debit_amount: 0, credit_amount: 25000, journal_entries: { id: 'je-orig', status: 'reversed', source_type: 'manual' } },
+ { debit_amount: 25000, credit_amount: 0, journal_entries: { id: 'je-storno', status: 'posted', source_type: 'storno' } },
+ { debit_amount: 25000, credit_amount: 0, journal_entries: { id: 'je-corr', status: 'posted', source_type: 'correction' } },
+ ],
+ })
+ // 3) RPC: empty (migration excludes storno/correction; reversed isn't posted)
+ enqueue({ data: [] })
+
+ const status = await getReconciliationStatus(supabase as never, 'company-1')
+
+ // Posted balance still includes the storno/correction (+50000) and the
+ // matched outflow (-9908.75); the reversed original is not posted.
+ expect(status.gl_1930_balance).toBe(40091.25)
+ // …but those +50000 are book-only and excluded from the period movement.
+ expect(status.gl_1930_correction_adjustment).toBe(50000)
+ expect(status.gl_1930_period_movement).toBe(-9908.75)
+ expect(status.bank_transaction_total).toBe(-9908.75)
+ expect(status.difference).toBe(0)
+ expect(status.is_reconciled).toBe(true)
+ })
+
+ it('does not create a new phantom when a matched deposit is later corrected', async () => {
+ // Case 2: a +25000 deposit was matched to an entry, then that entry was
+ // corrected. The deposit's link stays on the now-'reversed' original. The
+ // movement excludes the storno/correction, so to stay symmetric the deposit
+ // (linked to a reversed entry) must drop off the bank side too — otherwise
+ // we'd swap the old -50000 phantom for a +25000 one.
+ const { supabase, enqueue } = createQueueMockSupabase()
+
+ // 1) transactions: the +25000 deposit, still linked to the reversed original
+ enqueue({
+ data: [{ amount: 25000, journal_entry_id: 'je-orig', reconciliation_method: 'manual' }],
+ })
+ // 2) GL lines: reversed original (debit 25000), storno (credit 25000),
+ // correction (debit 25000)
+ enqueue({
+ data: [
+ { debit_amount: 25000, credit_amount: 0, journal_entries: { id: 'je-orig', status: 'reversed', source_type: 'bank_transaction' } },
+ { debit_amount: 0, credit_amount: 25000, journal_entries: { id: 'je-storno', status: 'posted', source_type: 'storno' } },
+ { debit_amount: 25000, credit_amount: 0, journal_entries: { id: 'je-corr', status: 'posted', source_type: 'correction' } },
+ ],
+ })
+ // 3) RPC: empty
+ enqueue({ data: [] })
+
+ const status = await getReconciliationStatus(supabase as never, 'company-1')
+
+ // Deposit excluded (linked to a reversed entry); cluster excluded from movement.
+ expect(status.bank_transaction_total).toBe(0)
+ expect(status.gl_1930_period_movement).toBe(0)
+ expect(status.difference).toBe(0)
+ expect(status.is_reconciled).toBe(true)
+ })
})
diff --git a/lib/reconciliation/bank-reconciliation.ts b/lib/reconciliation/bank-reconciliation.ts
index 8103b3fd..c75fe200 100644
--- a/lib/reconciliation/bank-reconciliation.ts
+++ b/lib/reconciliation/bank-reconciliation.ts
@@ -45,11 +45,17 @@ export interface ReconciliationStatus {
* is computed against `gl_1930_period_movement`, not this.
*/
gl_1930_balance: number
- /** Ledger movement on 1930 excluding source_type='opening_balance' lines. */
+ /** Ledger movement on 1930 excluding opening_balance AND storno/correction
+ * lines — i.e. only movements that have a bank-feed counterpart. */
gl_1930_period_movement: number
/** IB on 1930 within the date range — surfaced separately so reconciliation
* doesn't treat it as an unmatched bank transaction. */
gl_1930_opening_balance: number
+ /** Net of posted storno/correction lines on 1930 within the date range.
+ * Excluded from gl_1930_period_movement (and from the unmatched-voucher set)
+ * because a book-only correction has no counterpart in the bank feed. Surfaced
+ * separately so the UI can explain why a corrected period still reconciles. */
+ gl_1930_correction_adjustment: number
/** bankTotal − gl_1930_period_movement. Zero when every period transaction is matched. */
difference: number
is_reconciled: boolean
@@ -278,54 +284,77 @@ export async function getReconciliationStatus(
const { data: transactions } = await txQuery
- // Get GL bank account lines (all, not just unlinked). Pull source_type
- // from the join so we can split IB out of the period-movement comparison —
- // an opening_balance line on 1930 is the prior year's closing balance, not
- // a bank transaction we should expect to match.
+ // Get GL bank account lines. Pull id/status/source_type from the join so we
+ // can (a) split out lines that have no bank-feed counterpart — opening_balance
+ // (prior year's closing balance) and storno/correction (book-only corrections)
+ // — and (b) identify reversed originals, whose still-linked bank transactions
+ // are superseded by the correction and must drop off the bank side too.
+ // 'reversed' is fetched alongside 'posted' precisely to resolve those links;
+ // reversed lines are NOT counted in any movement total.
let glQuery = supabase
.from('journal_entry_lines')
- .select('debit_amount, credit_amount, journal_entries!inner(company_id, entry_date, status, source_type)')
+ .select('debit_amount, credit_amount, journal_entries!inner(id, company_id, entry_date, status, source_type)')
.eq('account_number', bankAccount)
.eq('journal_entries.company_id', companyId)
- .eq('journal_entries.status', 'posted')
+ .in('journal_entries.status', ['posted', 'reversed'])
if (dateFrom) glQuery = glQuery.gte('journal_entries.entry_date', dateFrom)
if (dateTo) glQuery = glQuery.lte('journal_entries.entry_date', dateTo)
const { data: glLines } = await glQuery
+ type GlEntry = { id?: string | null; status?: string | null; source_type?: string | null }
type GlLineRow = {
debit_amount: number | string | null
credit_amount: number | string | null
- journal_entries: { source_type?: string | null } | { source_type?: string | null }[] | null
+ journal_entries: GlEntry | GlEntry[] | null
}
- function isOpeningBalance(line: GlLineRow): boolean {
+ // Supabase typings sometimes widen embedded relations to arrays even when the
+ // join is one-to-one. Handle both shapes defensively.
+ function entryOf(line: GlLineRow): GlEntry | null {
const je = line.journal_entries
- if (!je) return false
- // Supabase typings sometimes widen embedded relations to arrays even when
- // the join is one-to-one. Handle both shapes defensively.
- const sourceType = Array.isArray(je) ? je[0]?.source_type : je.source_type
- return sourceType === 'opening_balance'
+ if (!je) return null
+ return Array.isArray(je) ? je[0] ?? null : je
+ }
+ function lineAmount(line: GlLineRow): number {
+ return (Number(line.debit_amount) || 0) - (Number(line.credit_amount) || 0)
}
-
- // Calculate totals
- const bankTotal = (transactions || []).reduce(
- (sum, tx) => sum + (Number(tx.amount) || 0),
- 0
- )
const allLines = (glLines || []) as GlLineRow[]
- const glBalance = allLines.reduce(
- (sum, line) => sum + (Number(line.debit_amount) || 0) - (Number(line.credit_amount) || 0),
- 0
+ const postedLines = allLines.filter((l) => entryOf(l)?.status === 'posted')
+
+ // Reversed originals retain their bank-transaction link (the storno flow never
+ // re-points it), so a transaction pointing at one is a superseded booking —
+ // drop it from the bank side to keep the comparison symmetric with the
+ // movement, which excludes the matching storno/correction below.
+ const reversedEntryIds = new Set(
+ allLines
+ .filter((l) => entryOf(l)?.status === 'reversed')
+ .map((l) => entryOf(l)?.id)
+ .filter((id): id is string => Boolean(id))
)
- const glOpeningBalance = allLines
- .filter(isOpeningBalance)
- .reduce(
- (sum, line) => sum + (Number(line.debit_amount) || 0) - (Number(line.credit_amount) || 0),
- 0
- )
- const glPeriodMovement = glBalance - glOpeningBalance
+
+ // Calculate totals. Exclude transactions whose linked entry was reversed —
+ // their booking lives on in the correction, which is itself excluded from the
+ // movement, so counting the transaction would resurrect a phantom diff.
+ const bankTotal = (transactions || []).reduce((sum, tx) => {
+ if (tx.journal_entry_id && reversedEntryIds.has(tx.journal_entry_id)) return sum
+ return sum + (Number(tx.amount) || 0)
+ }, 0)
+
+ // gl_1930_balance keeps its historical meaning: the posted balance incl. IB.
+ const glBalance = postedLines.reduce((sum, line) => sum + lineAmount(line), 0)
+ const glOpeningBalance = postedLines
+ .filter((l) => entryOf(l)?.source_type === 'opening_balance')
+ .reduce((sum, line) => sum + lineAmount(line), 0)
+ const glCorrectionAdjustment = postedLines
+ .filter((l) => {
+ const st = entryOf(l)?.source_type
+ return st === 'storno' || st === 'correction'
+ })
+ .reduce((sum, line) => sum + lineAmount(line), 0)
+ // Period movement = only the lines that have a bank-feed counterpart.
+ const glPeriodMovement = glBalance - glOpeningBalance - glCorrectionAdjustment
const matchedCount = (transactions || []).filter(
(tx) => tx.journal_entry_id !== null
@@ -335,8 +364,8 @@ export async function getReconciliationStatus(
(tx) => tx.journal_entry_id === null && tx.is_ignored !== true
).length
- // Unlinked GL lines count (RPC excludes source_type='opening_balance' since
- // 20260514132534_unlinked_1930_lines_exclude_opening_balance.sql)
+ // Unlinked GL lines count (RPC excludes opening_balance, storno and correction
+ // since 20260601120000_unlinked_gl_lines_exclude_storno_correction.sql)
const unlinkedLines = await fetchUnlinkedGLLines(supabase, companyId, bankAccount, dateFrom, dateTo)
const difference = Math.round((bankTotal - glPeriodMovement) * 100) / 100
@@ -346,6 +375,7 @@ export async function getReconciliationStatus(
gl_1930_balance: Math.round(glBalance * 100) / 100,
gl_1930_period_movement: Math.round(glPeriodMovement * 100) / 100,
gl_1930_opening_balance: Math.round(glOpeningBalance * 100) / 100,
+ gl_1930_correction_adjustment: Math.round(glCorrectionAdjustment * 100) / 100,
difference,
is_reconciled: Math.abs(difference) < 0.01,
matched_count: matchedCount,
diff --git a/lib/transactions/__tests__/external-id.test.ts b/lib/transactions/__tests__/external-id.test.ts
new file mode 100644
index 00000000..52b7cd89
--- /dev/null
+++ b/lib/transactions/__tests__/external-id.test.ts
@@ -0,0 +1,144 @@
+import { describe, it, expect } from 'vitest'
+import {
+ amountToOre,
+ buildStableExternalIds,
+ contentDedupKey,
+ normalizeImportedDescription,
+ FALLBACK_DESCRIPTION,
+} from '../external-id'
+
+describe('amountToOre', () => {
+ it('normalizes a JS number to integer öre', () => {
+ expect(amountToOre(1234.5)).toBe(123450)
+ expect(amountToOre(-250)).toBe(-25000)
+ expect(amountToOre(0)).toBe(0)
+ })
+
+ it('normalizes a numeric string (PostgREST representation) to the same öre', () => {
+ // The core fix: a DB-fetched numeric string and a raw JS number for the
+ // same amount must collapse to the same integer.
+ expect(amountToOre('1234.50')).toBe(123450)
+ expect(amountToOre('1234.5')).toBe(amountToOre(1234.5))
+ expect(amountToOre('-250.00')).toBe(amountToOre(-250))
+ expect(amountToOre('100')).toBe(10000)
+ })
+
+ it('rounds sub-öre noise deterministically (never toFixed)', () => {
+ expect(amountToOre(0.1 + 0.2)).toBe(30) // 0.30000000000000004 → 30
+ expect(amountToOre(19.995)).toBe(2000)
+ })
+})
+
+describe('buildStableExternalIds', () => {
+ it('derives the id from account + date + öre, not from any bank id', () => {
+ const ids = buildStableExternalIds('eb', 'SE123', [{ date: '2024-06-15', amount: -500 }])
+ expect(ids).toEqual(['eb_SE123_2024-06-15_-50000_0'])
+ })
+
+ it('disambiguates genuinely identical transactions with an occurrence index', () => {
+ const ids = buildStableExternalIds('eb', 'acc', [
+ { date: '2024-06-15', amount: -250 },
+ { date: '2024-06-15', amount: -250 },
+ { date: '2024-06-15', amount: -250 },
+ ])
+ expect(ids).toEqual([
+ 'eb_acc_2024-06-15_-25000_0',
+ 'eb_acc_2024-06-15_-25000_1',
+ 'eb_acc_2024-06-15_-25000_2',
+ ])
+ })
+
+ it('produces the SAME set of ids regardless of provider ordering (re-sync dedupe)', () => {
+ const a = buildStableExternalIds('eb', 'acc', [
+ { date: '2024-06-15', amount: -250 },
+ { date: '2024-06-16', amount: -100 },
+ { date: '2024-06-15', amount: -250 },
+ ])
+ // Same transactions, different order on a later sync.
+ const b = buildStableExternalIds('eb', 'acc', [
+ { date: '2024-06-15', amount: -250 },
+ { date: '2024-06-15', amount: -250 },
+ { date: '2024-06-16', amount: -100 },
+ ])
+ expect(new Set(a)).toEqual(new Set(b))
+ })
+
+ it('treats string and number amounts as the same id (provider type drift)', () => {
+ const num = buildStableExternalIds('eb', 'acc', [{ date: '2024-06-15', amount: 1234.5 }])
+ const str = buildStableExternalIds('eb', 'acc', [{ date: '2024-06-15', amount: '1234.50' }])
+ expect(num).toEqual(str)
+ })
+
+ it('keeps distinct amounts and dates on separate occurrence counters', () => {
+ const ids = buildStableExternalIds('eb', 'acc', [
+ { date: '2024-06-15', amount: -250 },
+ { date: '2024-06-15', amount: -100 },
+ { date: '2024-06-16', amount: -250 },
+ { date: '2024-06-15', amount: -250 },
+ ])
+ expect(ids).toEqual([
+ 'eb_acc_2024-06-15_-25000_0',
+ 'eb_acc_2024-06-15_-10000_0',
+ 'eb_acc_2024-06-16_-25000_0',
+ 'eb_acc_2024-06-15_-25000_1',
+ ])
+ })
+
+ it('returns an empty array for an empty batch', () => {
+ expect(buildStableExternalIds('eb', 'acc', [])).toEqual([])
+ })
+})
+
+describe('contentDedupKey', () => {
+ it('matches a JS number against a PostgREST numeric string for the same amount', () => {
+ // The core dedup-bridge fix: an incoming raw number and a DB-fetched string
+ // for the same amount + date + description must produce the SAME key.
+ const incoming = contentDedupKey('2024-06-15', -250, 'ICA Maxi Solna')
+ const stored = contentDedupKey('2024-06-15', '-250.00', 'ICA Maxi Solna')
+ expect(incoming).toBe(stored)
+ })
+
+ it('normalizes description (lowercase, trim, 24-char prefix)', () => {
+ expect(contentDedupKey('2024-06-15', -100, ' ICA Maxi Solna '))
+ .toBe(contentDedupKey('2024-06-15', -100, 'ica maxi solna'))
+ // Differs only past the 24-char prefix → same key.
+ expect(contentDedupKey('2024-06-15', -100, 'Betalning till leverantör AAA'))
+ .toBe(contentDedupKey('2024-06-15', -100, 'Betalning till leverantör BBB'))
+ })
+
+ it('keeps distinct transactions apart when description differs in the prefix', () => {
+ expect(contentDedupKey('2024-06-15', -250, 'ICA Maxi'))
+ .not.toBe(contentDedupKey('2024-06-15', -250, 'Coop Stockholm'))
+ })
+
+ it('treats a null/undefined description as an empty prefix', () => {
+ expect(contentDedupKey('2024-06-15', -100, null))
+ .toBe(contentDedupKey('2024-06-15', -100, undefined))
+ expect(contentDedupKey('2024-06-15', -100, null))
+ .toBe(contentDedupKey('2024-06-15', -100, ''))
+ })
+})
+
+describe('normalizeImportedDescription', () => {
+ it('maps empty / whitespace-only titles to the Swedish neutral', () => {
+ expect(normalizeImportedDescription('')).toBe(FALLBACK_DESCRIPTION)
+ expect(normalizeImportedDescription(' ')).toBe(FALLBACK_DESCRIPTION)
+ expect(normalizeImportedDescription(null)).toBe(FALLBACK_DESCRIPTION)
+ expect(normalizeImportedDescription(undefined)).toBe(FALLBACK_DESCRIPTION)
+ })
+
+ it('maps the legacy English "Unknown" sentinel to the Swedish neutral (case-insensitive)', () => {
+ expect(normalizeImportedDescription('Unknown')).toBe(FALLBACK_DESCRIPTION)
+ expect(normalizeImportedDescription('unknown')).toBe(FALLBACK_DESCRIPTION)
+ expect(normalizeImportedDescription(' UNKNOWN ')).toBe(FALLBACK_DESCRIPTION)
+ })
+
+ it('preserves a real title and trims surrounding whitespace', () => {
+ expect(normalizeImportedDescription('ICA Maxi Solna')).toBe('ICA Maxi Solna')
+ expect(normalizeImportedDescription(' Lön juni ')).toBe('Lön juni')
+ })
+
+ it('does NOT clobber a real title that merely contains the word "unknown"', () => {
+ expect(normalizeImportedDescription('Unknown Pizza AB')).toBe('Unknown Pizza AB')
+ })
+})
diff --git a/lib/transactions/__tests__/ingest.test.ts b/lib/transactions/__tests__/ingest.test.ts
index 2ac6f118..680caed1 100644
--- a/lib/transactions/__tests__/ingest.test.ts
+++ b/lib/transactions/__tests__/ingest.test.ts
@@ -211,6 +211,85 @@ describe('ingestTransactions', () => {
expect(result.transaction_ids).toEqual([])
})
+ // -----------------------------------------------------------------------
+ // 2b-edit. Edit-safety regression: a user-edited stored title must NOT
+ // reopen the duplicate-import window. The content bridge keys off the
+ // immutable original_description, so a re-import whose bank text still
+ // matches the original is deduped even though the stored (editable)
+ // description was changed.
+ // -----------------------------------------------------------------------
+ it('dedupes against the original bank description even after the stored title was edited', async () => {
+ const { supabase, enqueue } = createQueueMockSupabase()
+ const raw = makeRaw({
+ date: '2024-06-15',
+ amount: -250.0,
+ description: 'ICA Maxi Solna', // original bank text, re-imported via CSV
+ external_id: 'lunar_csvhash999', // different external_id → primary dedup misses
+ import_source: 'csv_lunar',
+ })
+
+ // Booked transaction map query — none
+ enqueue({ data: [], error: null })
+ // Unbooked bank-synced row whose TITLE was edited by the user, but whose
+ // original_description still holds the bank's verbatim text.
+ enqueue({
+ data: [
+ {
+ date: '2024-06-15',
+ amount: -250.0,
+ original_description: 'ICA Maxi Solna',
+ description: 'Mataffär (egen rubrik)',
+ },
+ ],
+ error: null,
+ })
+ // Supplier invoices fetch
+ enqueue({ data: [], error: null })
+ // Batch external_id dedup query — external_id differs, so no match
+ enqueue({ data: [], error: null })
+
+ const result = await ingestTransactions(supabase as never, COMPANY_ID, USER_ID, [raw])
+
+ expect(result.duplicates).toBe(1)
+ expect(result.imported).toBe(0)
+ })
+
+ // -----------------------------------------------------------------------
+ // 2b-unknown. Legacy 'Unknown'/empty rows must still dedup: the stored-side
+ // content key is normalized the same way as the incoming side, so an
+ // existing row whose original_description is the legacy 'Unknown'
+ // sentinel matches an incoming 'Unknown' re-import (both → 'Okänd
+ // transaktion'). Without symmetric normalization this row would
+ // re-import as a duplicate.
+ // -----------------------------------------------------------------------
+ it('dedupes legacy "Unknown" rows by normalizing both the stored and incoming keys', async () => {
+ const { supabase, enqueue } = createQueueMockSupabase()
+ const raw = makeRaw({
+ date: '2024-06-15',
+ amount: -250.0,
+ description: 'Unknown', // legacy English sentinel re-imported via CSV
+ external_id: 'lunar_csvhashU',
+ import_source: 'csv_lunar',
+ })
+
+ // Booked transaction map query — none
+ enqueue({ data: [], error: null })
+ // Unbooked bank-synced row whose original_description is the legacy sentinel.
+ enqueue({
+ data: [{ date: '2024-06-15', amount: -250.0, original_description: 'Unknown', description: 'Unknown' }],
+ error: null,
+ })
+ // Supplier invoices fetch
+ enqueue({ data: [], error: null })
+ // Batch external_id dedup query — external_id differs, so no match
+ enqueue({ data: [], error: null })
+
+ const result = await ingestTransactions(supabase as never, COMPANY_ID, USER_ID, [raw])
+
+ expect(result.duplicates).toBe(1)
+ expect(result.imported).toBe(0)
+ })
+
// -----------------------------------------------------------------------
// 2c. No false positive: same date+amount but different description does
// NOT trigger content dedup — guards against the historical concern
@@ -791,6 +870,72 @@ describe('ingestTransactions', () => {
expect(result.duplicates).toBe(0)
})
+ it('bridges the external_id scheme change: a booked OLD-scheme eb_ row is caught by content dedup on re-sync', async () => {
+ // Transition scenario: an enable_banking row was imported+booked under the
+ // OLD unstable scheme (eb_{iban}_{txid}). After deploy, the re-sync derives
+ // a NEW content-based external_id that will NOT match by external_id, so
+ // layer-1 misses. Layer 1b (booked content dedup) MUST catch it, otherwise
+ // the user sees the exact duplicate the fix is meant to prevent.
+ const { supabase, enqueue } = createQueueMockSupabase()
+ const raw = makeRaw({
+ external_id: 'eb_SE123_2024-06-15_-25000_0', // new scheme
+ date: '2024-06-15',
+ amount: -250,
+ description: 'ICA Maxi Solna',
+ import_source: 'enable_banking',
+ })
+
+ // Booked map: the SAME transaction still carries its OLD-scheme external_id
+ // in the DB; dedup matches on content, not on external_id.
+ enqueue({
+ data: [{ date: '2024-06-15', amount: -250, description: 'ICA Maxi Solna' }],
+ error: null,
+ })
+ // Unbooked enable_banking map — none
+ enqueue({ data: [], error: null })
+ // Supplier invoices fetch
+ enqueue({ data: [], error: null })
+ // Batch external_id dedup query — DB still holds eb_SE123_{old_txid}, so the
+ // new external_id finds NO match here.
+ enqueue({ data: [], error: null })
+
+ const result = await ingestTransactions(supabase as never, COMPANY_ID, USER_ID, [raw])
+
+ expect(result.duplicates).toBe(1)
+ expect(result.imported).toBe(0)
+ })
+
+ it('dedupes against a booked row whose amount is a numeric string (PostgREST), not a number', async () => {
+ // Regression: PostgREST can serialize a `numeric` column as a string
+ // ("-250.00") while the incoming raw amount is a JS number (-250). Before
+ // the öre-normalized dedup key these never compared equal, so content dedup
+ // silently missed and the row was re-imported as a duplicate.
+ const { supabase, enqueue } = createQueueMockSupabase()
+ const raw = makeRaw({
+ external_id: 'eb_acc_2024-06-15_-25000_0',
+ date: '2024-06-15',
+ amount: -250,
+ description: 'ICA Maxi Solna',
+ })
+
+ // Booked map: same transaction, amount as a STRING with trailing zeros.
+ enqueue({
+ data: [{ date: '2024-06-15', amount: '-250.00', description: 'ICA Maxi Solna' }],
+ error: null,
+ })
+ // Unbooked bank-synced transaction map query
+ enqueue({ data: [], error: null })
+ // Supplier invoices fetch
+ enqueue({ data: [], error: null })
+ // Batch external_id dedup query (no match by external_id — the id scheme changed)
+ enqueue({ data: [], error: null })
+
+ const result = await ingestTransactions(supabase as never, COMPANY_ID, USER_ID, [raw])
+
+ expect(result.duplicates).toBe(1)
+ expect(result.imported).toBe(0)
+ })
+
it('handles multiple booked transactions with same date+amount correctly', async () => {
const { supabase, enqueue } = createQueueMockSupabase()
diff --git a/lib/transactions/external-id.ts b/lib/transactions/external-id.ts
new file mode 100644
index 00000000..ba3b96aa
--- /dev/null
+++ b/lib/transactions/external-id.ts
@@ -0,0 +1,139 @@
+/**
+ * Shared helpers for deriving stable bank-transaction `external_id`s and for
+ * normalizing monetary amounts used in dedup keys.
+ *
+ * Why this exists
+ * ---------------
+ * The transactions table is deduplicated on `(company_id, external_id)` (a
+ * partial unique index, see migration 20260330130000). The dedup is therefore
+ * only as good as the stability of `external_id` across re-syncs.
+ *
+ * For Enable Banking (PSD2 / Berlin Group) the previous scheme keyed
+ * `external_id` off the bank's `entry_reference` / `transaction_id`
+ * (`eb_{account}_{tx.id}`). Many Swedish ASPSPs do NOT return those fields
+ * stably across requests — a later "synka nu" can return the same underlying
+ * transaction with a different id, which produced a *new* `external_id` and
+ * therefore a duplicate row (including for transactions the user had already
+ * booked). See `buildStableExternalIds` for the content-derived replacement.
+ */
+
+/**
+ * Normalize a monetary amount to integer öre (hundredths) for stable,
+ * representation-agnostic comparison.
+ *
+ * PostgREST may return a `numeric` column as a JS number OR as a string
+ * (preserving precision), so `1234.5` and the string `"1234.50"` can describe
+ * the same amount. Interpolating either directly into a dedup key yields
+ * different strings (`"1234.5"` vs `"1234.50"`), silently breaking content
+ * dedup. Rounding to integer öre collapses both to `123450`.
+ *
+ * Uses the project-standard `Math.round(x * 100)` (never `toFixed`).
+ */
+export function amountToOre(amount: number | string): number {
+ return Math.round(Number(amount) * 100)
+}
+
+/**
+ * Swedish-first placeholder for transactions a bank/import source gives no
+ * usable title. Centralized so every import path and the tests agree.
+ */
+export const FALLBACK_DESCRIPTION = 'Okänd transaktion'
+
+/**
+ * Normalize an imported transaction title for storage and display.
+ *
+ * Maps both an empty/whitespace title AND the legacy English 'Unknown'
+ * sentinel — still emitted by the bank-file format parsers and as the Enable
+ * Banking converter's last resort — to a Swedish-first neutral. Applied once at
+ * the ingest boundary so every source (PSD2 sync + CSV/CAMT import) inherits
+ * it; the bank's verbatim text is preserved separately in
+ * `transactions.original_description`. Match on the exact 'unknown' sentinel
+ * (case-insensitive) so a real description that merely contains the word is
+ * never clobbered.
+ */
+export function normalizeImportedDescription(raw: string | null | undefined): string {
+ const trimmed = (raw ?? '').trim()
+ if (!trimmed || trimmed.toLowerCase() === 'unknown') return FALLBACK_DESCRIPTION
+ return trimmed
+}
+
+/**
+ * Build stable, collision-safe `external_id`s for a batch of bank transactions
+ * whose provider does not supply a reliable stable id (e.g. Enable Banking).
+ *
+ * The id is derived from content — `{prefix}_{accountScope}_{date}_{öre}_{n}`
+ * — where `n` is an occurrence index that disambiguates genuinely identical
+ * transactions (same account, date and amount) within the batch.
+ *
+ * Properties this guarantees:
+ * - **Re-sync dedupe**: the same set of transactions produces the same *set*
+ * of ids regardless of the order the ASPSP returns them in, so a repeat sync
+ * collides with the existing rows on `(company_id, external_id)` and is
+ * skipped — even after the user has booked them. (The id *set* is what the
+ * unique index enforces; which physical row maps to `..._0` vs `..._1` need
+ * not be stable, only the set.)
+ * - **No false dedupe**: two legitimately distinct transactions that share a
+ * date and amount get different ids (`..._0`, `..._1`) and are both kept.
+ * This is the safeguard the bank-file importer already relies on via its
+ * `rowIndex` component (see `lib/import/bank-file/parser.ts`).
+ *
+ * Why description is NOT an input here (but IS in `contentDedupKey`): the
+ * `external_id` must be a *stable unique key*, so it cannot depend on a field
+ * that drifts — PSD2 enriches/reorders descriptions between a transaction's
+ * pending and booked states. The occurrence index gives uniqueness without
+ * that fragility. `contentDedupKey` has the opposite job — it is a best-effort
+ * *bridge* that must avoid dropping real transactions — so it keeps the
+ * description (see that function for the trade-off).
+ *
+ * @param prefix Source tag, e.g. `'eb'` for Enable Banking.
+ * @param accountScope Stable per-account scope (prefer IBAN, fall back to the
+ * provider account uid). Keeps ids unique across accounts.
+ * Callers should pass a whitespace/case-normalized IBAN so
+ * formatting variants ("SE45 5000…" vs "SE455000…") don't
+ * produce different ids for the same account.
+ * @param txns Batch in provider order; each needs `date` + `amount`.
+ */
+export function buildStableExternalIds(
+ prefix: string,
+ accountScope: string,
+ txns: Array<{ date: string; amount: number | string }>
+): string[] {
+ const occurrences = new Map()
+ return txns.map((tx) => {
+ const fingerprint = `${tx.date}_${amountToOre(tx.amount)}`
+ const n = occurrences.get(fingerprint) ?? 0
+ occurrences.set(fingerprint, n + 1)
+ return `${prefix}_${accountScope}_${fingerprint}_${n}`
+ })
+}
+
+/**
+ * Stable content-dedup key used to bridge transactions across `external_id`
+ * schemes and import sources (PSD2 ⇄ CSV, old id scheme ⇄ new id scheme).
+ *
+ * Format: `{date}|{öre}|{normalized description prefix}`.
+ *
+ * This is a *best-effort* dedup signal, not a unique key. It is consumed with
+ * COUNTING semantics in the ingest pipeline (N existing matches consume N
+ * incoming), and its job is to skip re-imports WITHOUT ever dropping a real
+ * transaction. That asymmetry drives the two design choices here:
+ *
+ * - **öre via `amountToOre`** — a JS number (`-250`) and a PostgREST numeric
+ * string (`"-250.00"`) must collapse to the same key, otherwise dedup
+ * silently misses.
+ * - **description IS included** (unlike `external_id`) — two genuinely distinct
+ * transactions that merely share a date and amount (e.g. two SEK 250 card
+ * purchases) must NOT be collapsed into one, or a real transaction is lost.
+ * Including the description prefix biases toward keeping both. The cost is
+ * that if a description drifts between syncs the bridge can miss a true
+ * duplicate — an acceptable trade for an accounting ledger, where a visible,
+ * user-deletable duplicate is far safer than a silently dropped row.
+ */
+export function contentDedupKey(
+ date: string,
+ amount: number | string,
+ description: string | null | undefined
+): string {
+ const descPrefix = (description || '').toLowerCase().trim().slice(0, 24)
+ return `${date}|${amountToOre(amount)}|${descPrefix}`
+}
diff --git a/lib/transactions/ingest.ts b/lib/transactions/ingest.ts
index e88d1b5e..acba5e22 100644
--- a/lib/transactions/ingest.ts
+++ b/lib/transactions/ingest.ts
@@ -7,6 +7,7 @@ import { findSupplierInvoiceMatch } from '@/lib/invoices/supplier-invoice-matchi
import { fetchExchangeRate } from '@/lib/currency/riksbanken'
import { logMatchEvent } from '@/lib/invoices/match-log'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
+import { contentDedupKey, normalizeImportedDescription } from '@/lib/transactions/external-id'
import type { Transaction, RawTransaction, IngestResult, IngestOptions, SupplierInvoice, Currency, ExchangeRate } from '@/types'
// Re-export types for backward compatibility
@@ -25,18 +26,6 @@ interface ExistingTransactionMaps {
unbookedEnableBanking: Map
}
-/**
- * Stable content-dedup key. Includes a normalized description prefix so the
- * two-tuple (date, amount) doesn't false-positive across unrelated transfers
- * that happen to share a date and amount. Lunar's CSV "Text" column and
- * PSD2's `description || counterparty_name` (see enable-banking/lib/sync.ts)
- * agree well enough in practice for the same underlying transaction.
- */
-function contentDedupKey(date: string, amount: number | string, description: string | null | undefined): string {
- const descPrefix = (description || '').toLowerCase().trim().slice(0, 24)
- return `${date}|${amount}|${descPrefix}`
-}
-
async function buildExistingTransactionMaps(
supabase: SupabaseClient,
companyId: string,
@@ -53,7 +42,7 @@ async function buildExistingTransactionMaps(
try {
const { data: bookedRows } = await supabase
.from('transactions')
- .select('date, amount, description')
+ .select('date, amount, original_description, description')
.eq('company_id', companyId)
.not('journal_entry_id', 'is', null)
.gte('date', dateFrom)
@@ -61,7 +50,15 @@ async function buildExistingTransactionMaps(
if (bookedRows) {
for (const tx of bookedRows) {
- const key = contentDedupKey(tx.date, tx.amount, tx.description)
+ // Key off the immutable bank original, not the user-editable
+ // description: a title edit must never make the dedup bridge miss a
+ // genuine re-import. Falls back to description for rows predating the
+ // original_description column.
+ const key = contentDedupKey(
+ tx.date,
+ tx.amount,
+ normalizeImportedDescription(tx.original_description ?? tx.description),
+ )
booked.set(key, (booked.get(key) || 0) + 1)
}
}
@@ -72,7 +69,7 @@ async function buildExistingTransactionMaps(
try {
const { data: unbookedBank } = await supabase
.from('transactions')
- .select('date, amount, description')
+ .select('date, amount, original_description, description')
.eq('company_id', companyId)
.is('journal_entry_id', null)
.eq('import_source', 'enable_banking')
@@ -81,7 +78,13 @@ async function buildExistingTransactionMaps(
if (unbookedBank) {
for (const tx of unbookedBank) {
- const key = contentDedupKey(tx.date, tx.amount, tx.description)
+ // See booked-map note: dedup on the immutable bank original so a
+ // user title edit cannot reopen the duplicate-import window.
+ const key = contentDedupKey(
+ tx.date,
+ tx.amount,
+ normalizeImportedDescription(tx.original_description ?? tx.description),
+ )
unbookedEnableBanking.set(key, (unbookedEnableBanking.get(key) || 0) + 1)
}
}
@@ -216,6 +219,14 @@ export async function ingestTransactions(
const matchedSupplierInvoiceIds = new Set()
for (const raw of rawTransactions) {
+ // Normalize the source title once. Guarantees a non-empty, Swedish-first
+ // label for every import path (PSD2 sync + all bank-file CSV/CAMT parsers
+ // funnel into raw.description) — catching both empty/whitespace titles and
+ // the legacy English 'Unknown' sentinel. This normalized value is stored as
+ // both description and original_description below; it's what the user sees
+ // and edits, and what the content-dedup key is built from.
+ const description = normalizeImportedDescription(raw.description)
+
// 1. Check for duplicates via external_id (batch pre-fetched)
if (existingExternalIds.has(raw.external_id)) {
result.duplicates++
@@ -223,8 +234,9 @@ export async function ingestTransactions(
}
// 1b. Content-based dedup: skip if an already-booked transaction
- // exists with the same date, amount, and description prefix.
- const contentKey = contentDedupKey(raw.date, raw.amount, raw.description)
+ // exists with the same date, amount, and description prefix. Built from the
+ // normalized description so it matches the stored original_description keys.
+ const contentKey = contentDedupKey(raw.date, raw.amount, description)
const bookedCount = existingMaps.booked.get(contentKey) || 0
if (bookedCount > 0) {
existingMaps.booked.set(contentKey, bookedCount - 1)
@@ -260,7 +272,11 @@ export async function ingestTransactions(
bank_connection_id: raw.bank_connection_id || null,
external_id: raw.external_id,
date: raw.date,
- description: raw.description,
+ description: description,
+ // Immutable bank/PSD2 original — captured once, never overwritten by a
+ // title edit. Equals description at insert; they diverge only if the
+ // user later edits the title.
+ original_description: description,
amount: raw.amount,
currency: raw.currency,
amount_sek: amountSek,
diff --git a/messages/en.json b/messages/en.json
index 1d4de9c4..1f853fa5 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -1633,7 +1633,17 @@
"match_invoice_btn": "Match invoice {number}",
"match_supplier_invoice_btn": "Match supplier invoice {number}",
"choose_template_btn": "Choose template...",
- "delete_aria": "Delete transaction"
+ "delete_aria": "Delete transaction",
+ "edit_title_aria": "Edit title",
+ "edited_badge": "edited",
+ "original_name_tooltip": "Original bank name: {name}",
+ "edit_title_dialog_title": "Edit transaction title",
+ "edit_title_warning": "Are you sure you want to edit the title of this transaction?",
+ "edit_title_label": "Title",
+ "edit_title_original_hint": "Original bank name: {name}.",
+ "edit_title_restore": "Restore",
+ "edit_title_cancel": "Cancel",
+ "edit_title_save": "Save"
},
"tx_quick_review": {
"open_attached_failed": "Could not open the receipt",
@@ -2474,6 +2484,9 @@
},
"supplier_invoice_editor": {
"page_title": "Register supplier invoice",
+ "no_period_warning": "Invoice date {date} falls outside every fiscal year you have set up.",
+ "no_period_help": "Create the fiscal year so the invoice can be booked — otherwise it can't be registered.",
+ "create_period": "Create fiscal year",
"back_aria": "Back to supplier invoices",
"back_aria_inbox": "Back to the inbox",
"loading_inbox": "Loading data from inbox…",
@@ -2907,6 +2920,9 @@
"create_correction": "Create correction entry",
"copy_entry": "Copy journal entry",
"edit_entry": "Edit",
+ "correct_menu": "Correct",
+ "correct_lines": "Correct lines",
+ "correct_date": "Correct date",
"details_title": "Journal entry details",
"field_date": "Date",
"field_posted_at": "Posted",
@@ -3297,6 +3313,8 @@
"deleted_title": "Deleted",
"deleted_description": "The transaction has been deleted",
"delete_failed_description": "The transaction could not be deleted. Please try again.",
+ "edit_title_saved": "Title updated",
+ "edit_title_failed": "Could not update the title",
"review_in_bookkeeping_description": "Review and post the journal entry in Bookkeeping.",
"bank_sync_attention_one": "1 bank connection needs renewal",
"bank_sync_attention_many": "{count} bank connections need renewal",
diff --git a/messages/sv.json b/messages/sv.json
index 73b6b45b..b898873f 100644
--- a/messages/sv.json
+++ b/messages/sv.json
@@ -1633,7 +1633,17 @@
"match_invoice_btn": "Matcha Faktura {number}",
"match_supplier_invoice_btn": "Matcha Leverantörsfaktura {number}",
"choose_template_btn": "Välj mall...",
- "delete_aria": "Ta bort transaktion"
+ "delete_aria": "Ta bort transaktion",
+ "edit_title_aria": "Ändra titel",
+ "edited_badge": "redigerad",
+ "original_name_tooltip": "Bankens originalnamn: {name}",
+ "edit_title_dialog_title": "Ändra transaktionens titel",
+ "edit_title_warning": "Är du säker på att du vill ändra titeln på den här transaktionen?",
+ "edit_title_label": "Titel",
+ "edit_title_original_hint": "Bankens originalnamn: {name}.",
+ "edit_title_restore": "Återställ",
+ "edit_title_cancel": "Avbryt",
+ "edit_title_save": "Spara"
},
"tx_quick_review": {
"open_attached_failed": "Kunde inte öppna underlaget",
@@ -2474,6 +2484,9 @@
},
"supplier_invoice_editor": {
"page_title": "Registrera leverantörsfaktura",
+ "no_period_warning": "Fakturadatumet {date} ligger utanför alla upplagda räkenskapsår.",
+ "no_period_help": "Lägg upp räkenskapsåret så att fakturan kan bokföras – annars kan den inte registreras.",
+ "create_period": "Skapa räkenskapsår",
"back_aria": "Tillbaka till leverantörsfakturor",
"back_aria_inbox": "Tillbaka till inkorgen",
"loading_inbox": "Laddar uppgifter från inkorgen…",
@@ -2907,6 +2920,9 @@
"create_correction": "Skapa ändringsverifikation",
"copy_entry": "Kopiera verifikat",
"edit_entry": "Redigera",
+ "correct_menu": "Rätta",
+ "correct_lines": "Rätta rader",
+ "correct_date": "Rätta datum",
"details_title": "Verifikationsdetaljer",
"field_date": "Datum",
"field_posted_at": "Bokförd",
@@ -3297,6 +3313,8 @@
"deleted_title": "Borttagen",
"deleted_description": "Transaktionen har tagits bort",
"delete_failed_description": "Transaktionen kunde inte tas bort. Försök igen.",
+ "edit_title_saved": "Titeln uppdaterad",
+ "edit_title_failed": "Kunde inte uppdatera titeln",
"review_in_bookkeeping_description": "Granska och bokför verifikatet i Bokföring.",
"bank_sync_attention_one": "1 bankanslutning behöver förnyas",
"bank_sync_attention_many": "{count} bankanslutningar behöver förnyas",
diff --git a/supabase/migrations/20260605120000_transactions_original_description.sql b/supabase/migrations/20260605120000_transactions_original_description.sql
new file mode 100644
index 00000000..1129375d
--- /dev/null
+++ b/supabase/migrations/20260605120000_transactions_original_description.sql
@@ -0,0 +1,47 @@
+-- Editable bank-transaction titles: preserve the original bank/PSD2 name.
+--
+-- The transactions.description column is a mutable working label on the
+-- staging/inbox row. It is NOT räkenskapsinformation until the row is booked
+-- into a verifikat (journal_entry_id IS NOT NULL); BFL immutability (5 kap 5§)
+-- and the storno-only rule attach to the verifikat, not to pre-accounting
+-- staging data. We are about to let users edit that label *before* booking.
+--
+-- Two reasons to keep the bank's original text (normalized once at ingest) in a
+-- separate, immutable column (never written by the edit endpoint):
+-- 1. Recoverability + provenance — an accidental edit is always reversible,
+-- and the bank original stays auditable as the basis for "vad
+-- affärshändelsen avser" once booked (BFL 5 kap 7§ / god redovisningssed).
+-- The raw PSD2 JSON is already archived as a document, so this column is
+-- defence-in-depth, not the sole record.
+-- 2. Dedup safety — contentDedupKey() in lib/transactions/external-id.ts
+-- bridges re-imports across sources (PSD2 re-sync ⇄ CSV overlap) using a
+-- description prefix. If that bridge read the user-editable description, an
+-- edited title could let a genuine re-import slip past as a duplicate.
+-- Ingest now keys the bridge off original_description, which never drifts.
+--
+-- The edit gate (only unbooked + unmatched rows are editable) is enforced in
+-- application code (app/api/transactions/[id] PATCH). transactions carry no
+-- description-immutability DB trigger today and none is added here; the
+-- editable predicate (journal_entry_id IS NULL AND invoice_id IS NULL AND
+-- supplier_invoice_id IS NULL) already excludes every booked/matched row.
+
+ALTER TABLE public.transactions
+ ADD COLUMN IF NOT EXISTS original_description text,
+ ADD COLUMN IF NOT EXISTS title_edited_at timestamptz;
+
+COMMENT ON COLUMN public.transactions.original_description IS
+ 'Bank/PSD2-provided description captured at ingest, normalized (empty/whitespace and the legacy "Unknown" sentinel map to the Swedish neutral "Okänd transaktion"). Never overwritten by user title edits; used as the dedup-bridge source and as the "restore original" value.';
+COMMENT ON COLUMN public.transactions.title_edited_at IS
+ 'Set when a user overrides the transaction title (description). NULL = title is still the bank original. Drives the "redigerad" tag and the restore affordance.';
+
+-- Backfill: before this feature shipped, description always held the bank
+-- original (every ingest path defaulted it from the source text), so the
+-- current description IS the original for every legacy row. This gives each
+-- existing row a recoverable original and means the dedup bridge's
+-- `original_description ?? description` fallback is only ever exercised by rows
+-- created in the brief window before this migration runs.
+UPDATE public.transactions
+ SET original_description = description
+ WHERE original_description IS NULL;
+
+NOTIFY pgrst, 'reload schema';
diff --git a/supabase/migrations/20260605130000_unlinked_gl_lines_exclude_storno_correction.sql b/supabase/migrations/20260605130000_unlinked_gl_lines_exclude_storno_correction.sql
new file mode 100644
index 00000000..fb9d7b5a
--- /dev/null
+++ b/supabase/migrations/20260605130000_unlinked_gl_lines_exclude_storno_correction.sql
@@ -0,0 +1,87 @@
+-- Migration: exclude storno/correction vouchers from the unmatched-GL-lines set.
+--
+-- Why: a correction made via the storno flow (correctEntry in
+-- lib/core/bookkeeping/storno-service.ts, reverseEntry in lib/bookkeeping/engine.ts)
+-- produces a posted 'storno' voucher (the reversal, debit/credit swapped) and, for
+-- correctEntry, a posted 'correction' voucher (the re-booking). Neither is an
+-- independent bank movement — they are pure book corrections of an existing
+-- posting. Exactly like 'opening_balance' (excluded since
+-- 20260514132534_unlinked_1930_lines_exclude_opening_balance.sql) they have no
+-- counterpart in the bank feed and can NEVER be matched to a bank transaction
+-- (the reconciliation link is one-directional: a transaction points at an entry,
+-- and storno/correction entries are never the target). Left in the set they sit
+-- in "Omatchade verifikationer" indefinitely and make a fully-reconciled period
+-- look unbalanced — the exact symptom users report when a rättelse/storno shows
+-- up as an omatchad verifikation.
+--
+-- The reversed ORIGINAL is already excluded here: this RPC only returns
+-- status='posted' lines, and a reversed entry is status='reversed'.
+--
+-- Precedent: compute_prior_opening_balances
+-- (20260421180000_opening_balances_rpc_fix_reversed_and_new_accounts.sql) already
+-- excludes source_type='storno' from its balance roll-up for the same BFL 5:5
+-- reason — a cancelled posting must not contribute to a computed figure.
+--
+-- IS DISTINCT FROM (not NOT IN) is used so a NULL source_type line — a legitimate
+-- bank line — is kept, matching the existing opening_balance guard.
+
+CREATE OR REPLACE FUNCTION public.get_unlinked_gl_lines(
+ p_company_id UUID,
+ p_account_number TEXT DEFAULT '1930',
+ p_date_from DATE DEFAULT NULL,
+ p_date_to DATE DEFAULT NULL
+)
+RETURNS TABLE (
+ line_id UUID,
+ journal_entry_id UUID,
+ debit_amount NUMERIC,
+ credit_amount NUMERIC,
+ line_description TEXT,
+ entry_date DATE,
+ voucher_number INT,
+ voucher_series TEXT,
+ entry_description TEXT,
+ source_type TEXT
+)
+LANGUAGE sql
+STABLE
+SECURITY DEFINER
+SET search_path = public
+AS $$
+ SELECT
+ jel.id AS line_id,
+ je.id AS journal_entry_id,
+ jel.debit_amount,
+ jel.credit_amount,
+ jel.line_description,
+ je.entry_date,
+ je.voucher_number,
+ je.voucher_series,
+ je.description AS entry_description,
+ je.source_type
+ FROM public.journal_entry_lines jel
+ JOIN public.journal_entries je ON je.id = jel.journal_entry_id
+ WHERE jel.account_number = p_account_number
+ AND je.company_id = p_company_id
+ AND je.status = 'posted'
+ -- IB lines never have a counterpart in the bank feed — the bank statement
+ -- starts at IB and accumulates from there. Keep them excluded so
+ -- reconciliation doesn't surface a phantom voucher.
+ AND je.source_type IS DISTINCT FROM 'opening_balance'
+ -- Storno/correction are book-only corrections of an existing posting; they
+ -- have no independent bank movement and can never be matched. Exclude them
+ -- so a corrected/reversed period doesn't look unbalanced.
+ AND je.source_type IS DISTINCT FROM 'storno'
+ AND je.source_type IS DISTINCT FROM 'correction'
+ AND (p_date_from IS NULL OR je.entry_date >= p_date_from)
+ AND (p_date_to IS NULL OR je.entry_date <= p_date_to)
+ AND NOT EXISTS (
+ SELECT 1
+ FROM public.transactions t
+ WHERE t.journal_entry_id = je.id
+ AND t.company_id = p_company_id
+ )
+ ORDER BY je.entry_date, je.voucher_number;
+$$;
+
+NOTIFY pgrst, 'reload schema';
diff --git a/tests/helpers.ts b/tests/helpers.ts
index e6509c85..f238f0c1 100644
--- a/tests/helpers.ts
+++ b/tests/helpers.ts
@@ -175,6 +175,8 @@ export function makeTransaction(overrides: Partial = {}): Transacti
external_id: null,
date: '2024-06-15',
description: 'ICA MAXI STOCKHOLM',
+ original_description: 'ICA MAXI STOCKHOLM',
+ title_edited_at: null,
amount: -299.0,
currency: 'SEK',
amount_sek: null,
diff --git a/tests/pg/get_unlinked_1930_lines.pg.test.ts b/tests/pg/get_unlinked_1930_lines.pg.test.ts
index 5a02483f..8d76ed50 100644
--- a/tests/pg/get_unlinked_1930_lines.pg.test.ts
+++ b/tests/pg/get_unlinked_1930_lines.pg.test.ts
@@ -17,7 +17,7 @@ async function insertPostedJournalEntry(params: {
companyId: string
fiscalPeriodId: string
entryDate: string
- sourceType: 'opening_balance' | 'manual' | 'bank_transaction' | 'import'
+ sourceType: 'opening_balance' | 'manual' | 'bank_transaction' | 'import' | 'storno' | 'correction'
voucherNumber: number
amount?: number
}): Promise {
@@ -104,6 +104,44 @@ describe('get_unlinked_gl_lines RPC — opening_balance exclusion', () => {
expect(rows.find((r) => r.source_type === 'opening_balance')).toBeUndefined()
})
+ it('excludes storno and correction vouchers from the unmatched-1930 set', async () => {
+ const userId = await insertAuthUser()
+ const companyId = await insertCompany({ createdBy: userId })
+ const fiscalPeriodId = await insertFiscalPeriod({
+ userId,
+ companyId,
+ periodStart: '2026-01-01',
+ periodEnd: '2026-12-31',
+ })
+
+ // A storno and a correction voucher on 1930 (the products of the correctEntry
+ // flow), plus a normal bank voucher. Stornos/corrections are book-only
+ // reversals with no bank-feed counterpart — they must be EXCLUDED so a
+ // reconciled period doesn't show them as omatchade verifikationer.
+ await insertPostedJournalEntry({
+ userId, companyId, fiscalPeriodId,
+ entryDate: '2026-05-02', sourceType: 'storno', voucherNumber: 20, amount: 25000,
+ })
+ await insertPostedJournalEntry({
+ userId, companyId, fiscalPeriodId,
+ entryDate: '2026-05-02', sourceType: 'correction', voucherNumber: 21, amount: 25000,
+ })
+ const bankEntryId = await insertPostedJournalEntry({
+ userId, companyId, fiscalPeriodId,
+ entryDate: '2026-05-03', sourceType: 'bank_transaction', voucherNumber: 22, amount: 1500,
+ })
+
+ const { rows } = await getPool().query(
+ `SELECT journal_entry_id, source_type FROM public.get_unlinked_gl_lines($1)`,
+ [companyId],
+ )
+
+ const returnedIds = new Set(rows.map((r) => r.journal_entry_id))
+ expect(returnedIds.has(bankEntryId)).toBe(true)
+ expect(rows.find((r) => r.source_type === 'storno')).toBeUndefined()
+ expect(rows.find((r) => r.source_type === 'correction')).toBeUndefined()
+ })
+
it('still applies date_from / date_to filtering', async () => {
const userId = await insertAuthUser()
const companyId = await insertCompany({ createdBy: userId })
diff --git a/types/index.ts b/types/index.ts
index e86a7a10..9b730922 100644
--- a/types/index.ts
+++ b/types/index.ts
@@ -404,7 +404,14 @@ export interface Transaction {
// Details
date: string
- description: string
+ description: string // Mutable working title — user-editable while unbooked (see PATCH /api/transactions/[id])
+ // Bank/PSD2 description captured at ingest, normalized (empty/whitespace and
+ // the legacy "Unknown" sentinel map to the Swedish neutral). Never overwritten
+ // by user title edits; source for the dedup bridge and the "restore original"
+ // action. Null only for rows predating the column.
+ original_description: string | null
+ // Set when the user has overridden the title; null = still the bank original.
+ title_edited_at: string | null
amount: number // Positive = income, negative = expense
currency: Currency