Mcp/template data feedback (#617)
* fix(booking-templates): scope template list to the active company GET /api/settings/booking-templates relied solely on the btl_select RLS policy, which is membership-wide (user_company_ids) and returns templates from every company the user belongs to. A user who owns multiple companies saw all their templates merged regardless of which company was active. Narrow the list in the API layer (mirroring counterparty-templates) to system + the active company + the active company's team. RLS stays the security backstop; this fixes the cross-company merge within a single user's own view (it was never a cross-tenant data leak). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(import): show proper message for duplicate bank file upload The bank file import page mis-parsed the structured error envelope ({ error: { code, message, details } }), so a BANK_FILE_DUPLICATE (409) fell through to the generic "Kunde inte läsa filen" fallback. The upload step also hardcoded that same string as the error heading, so duplicates were doubly misreported as parse failures. - Parse the structured envelope by error.code; surface error.message for all codes instead of rendering the error object. - Add a dedicated BANK_FILE_DUPLICATE message using the importedAt / importedCount details the route already returns. - Add an optional errorTitle prop to BankFileUploadStep (defaults to the previous text) and pass "Filen är redan importerad" for dupes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(tests): add comprehensive tests for recordateEntry, inbox-linking, and external-id handling - Implemented unit tests for recordateEntry in the bookkeeping module to validate various scenarios including date changes, non-posted entries, and fiscal period restrictions. - Created tests for inbox-linking status in pending operations to ensure correct handling of invoice inbox items and supplier invoices, addressing historical bugs related to status updates. - Added tests for external-id utilities to ensure consistent handling of monetary amounts and deduplication keys across different transaction sources. - Introduced new functions in external-id.ts for stable external ID generation and normalization of imported descriptions, enhancing transaction deduplication reliability. feat(migrations): add new database migrations for transaction handling - Created migration to exclude storno and correction vouchers from unmatched GL lines, ensuring accurate reconciliation. - Added a migration to preserve original bank transaction descriptions in a new immutable column, allowing for user edits while maintaining audit trails and deduplication integrity. * feat(migrations): add function to exclude storno/correction vouchers from unmatched GL lines * feat(transactions): enhance transaction handling with improved description normalization and preloaded original entries --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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<string | null>(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
|
||||
</Button>
|
||||
)}
|
||||
{canCorrect && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full sm:w-auto"
|
||||
onClick={() => setShowCorrection(true)}
|
||||
disabled={!canWrite}
|
||||
title={!canWrite ? t('read_only_tooltip') : undefined}
|
||||
>
|
||||
{!canWrite ? <Lock className="mr-2 h-4 w-4" /> : <Pencil className="mr-2 h-4 w-4" />}
|
||||
{t('edit_entry')}
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full sm:w-auto"
|
||||
disabled={!canWrite}
|
||||
title={!canWrite ? t('read_only_tooltip') : undefined}
|
||||
>
|
||||
{!canWrite ? <Lock className="mr-2 h-4 w-4" /> : <Pencil className="mr-2 h-4 w-4" />}
|
||||
{t('correct_menu')}
|
||||
<ChevronDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => setShowCorrection(true)}>
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
{t('correct_lines')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setShowRecordate(true)}>
|
||||
<CalendarClock className="mr-2 h-4 w-4" />
|
||||
{t('correct_date')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
{entry.status === 'posted' && (
|
||||
<Button
|
||||
@@ -579,6 +601,19 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Recordate (move to correct date) dialog */}
|
||||
{showRecordate && entry && (
|
||||
<RecordateEntryDialog
|
||||
entry={entry}
|
||||
open={showRecordate}
|
||||
onOpenChange={setShowRecordate}
|
||||
onMoved={() => {
|
||||
setShowRecordate(false)
|
||||
fetchData()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Delete confirmation dialog */}
|
||||
<ConfirmationDialog
|
||||
open={showDeleteConfirm}
|
||||
|
||||
@@ -11,7 +11,7 @@ import { useToast } from '@/components/ui/use-toast'
|
||||
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import { ArrowLeftRight, ArrowRightLeft, FileText, ArrowLeft, Landmark, Loader2, Info, ChevronRight, FileSpreadsheet, Download, AlertTriangle } from 'lucide-react'
|
||||
import { motion } from 'framer-motion'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { cn, formatDate } from '@/lib/utils'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
import { useCompany } from '@/contexts/CompanyContext'
|
||||
import { BankSelector, type Bank } from '@/extensions/general/enable-banking/components/BankSelector'
|
||||
@@ -105,6 +105,7 @@ function BankFileImportWizard() {
|
||||
const [bankStep, setBankStep] = useState<BankFileStep>('upload')
|
||||
const [bankIsLoading, setBankIsLoading] = useState(false)
|
||||
const [bankError, setBankError] = useState<string | null>(null)
|
||||
const [bankErrorTitle, setBankErrorTitle] = useState<string | null>(null)
|
||||
|
||||
// Parse results
|
||||
const [parseResult, setParseResult] = useState<BankFileParseResult | null>(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}
|
||||
/>
|
||||
|
||||
@@ -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<BASAccount[]>([])
|
||||
const [entityType, setEntityType] = useState<EntityType>('enskild_firma')
|
||||
const [accountingMethod, setAccountingMethod] = useState<'accrual' | 'cash'>('accrual')
|
||||
const [periods, setPeriods] = useState<FiscalPeriod[]>([])
|
||||
const [periodsLoaded, setPeriodsLoaded] = useState(false)
|
||||
const [showCreatePeriod, setShowCreatePeriod] = useState(false)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [showReview, setShowReview] = useState(false)
|
||||
const [pendingData, setPendingData] = useState<FormData | null>(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() {
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showNoPeriodWarning && (
|
||||
<div className="flex items-start gap-3 rounded-lg border border-warning/30 bg-warning/10 p-3">
|
||||
<AlertTriangle className="h-5 w-5 text-warning-foreground mt-0.5 shrink-0" />
|
||||
<div className="flex-1 text-sm text-warning-foreground">
|
||||
<p className="font-medium">{t('no_period_warning', { date: watchedInvoiceDate })}</p>
|
||||
<p className="mt-0.5">{t('no_period_help')}</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowCreatePeriod(true)}
|
||||
className="shrink-0"
|
||||
>
|
||||
<CalendarPlus className="h-3.5 w-3.5 mr-1.5" />
|
||||
{t('create_period')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -1678,6 +1733,14 @@ export default function NewSupplierInvoicePage() {
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<CreatePeriodDialog
|
||||
open={showCreatePeriod}
|
||||
onOpenChange={setShowCreatePeriod}
|
||||
entryDate={watchedInvoiceDate}
|
||||
periods={periods}
|
||||
onCreated={fetchPeriods}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<TransactionWithInvoice | null>(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<boolean> {
|
||||
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() {
|
||||
|
||||
<DestructiveConfirmDialog {...deleteDialogProps} />
|
||||
|
||||
<EditTransactionTitleDialog
|
||||
open={editTitleTarget !== null}
|
||||
onOpenChange={(v) => {
|
||||
if (!v) setEditTitleTarget(null)
|
||||
}}
|
||||
currentTitle={editTitleTarget?.description ?? ''}
|
||||
originalTitle={editTitleTarget?.original_description ?? null}
|
||||
onSave={handleSaveTitle}
|
||||
/>
|
||||
|
||||
<SkattekontoMatchDialog
|
||||
row={skvMatchTarget}
|
||||
open={!!skvMatchTarget}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { resolvePeriodStatusForDate } from '@/lib/core/bookkeeping/period-service'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
|
||||
/**
|
||||
* GET /api/bookkeeping/fiscal-periods/period-status?date=YYYY-MM-DD
|
||||
*
|
||||
* Read-only preview of whether a verifikation with the given entry_date could
|
||||
* be posted right now (company lock date + period is_closed/locked_at), plus
|
||||
* the covering period's label so the UI can show "flyttas till <år>" 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 })
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<Pick<Transaction, 'id' | 'description' | 'title_edited_at'>>()
|
||||
|
||||
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 },
|
||||
)
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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<PeriodStatus | null>(null)
|
||||
const [previewLoading, setPreviewLoading] = useState(false)
|
||||
const [previewError, setPreviewError] = useState<string | null>(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 ? (
|
||||
<Button variant="outline" size="sm" onClick={() => router.push(`/bookkeeping/${correctedId}`)}>
|
||||
Visa rättelsen
|
||||
</Button>
|
||||
) : 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 (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Rätta datum</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Explanation */}
|
||||
<div className="rounded-lg bg-muted/50 border p-3 text-sm text-muted-foreground">
|
||||
<p className="font-medium text-foreground mb-1">Flytta verifikationen till rätt datum</p>
|
||||
<p>
|
||||
En bokförd verifikation kan inte ändras direkt. Raderna behålls oförändrade — istället
|
||||
skapas automatiskt:
|
||||
</p>
|
||||
<ol className="list-decimal list-inside mt-1 space-y-0.5">
|
||||
<li>En <strong>stornoverifikation</strong> som nollställer originalet i sin period</li>
|
||||
<li>En ny verifikation med samma rader, bokförd på det nya datumet</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
{/* Original */}
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground flex-wrap">
|
||||
<span className="font-mono">{formatVoucher(entry)}</span>
|
||||
<span className="tabular-nums">{formatDate(entry.entry_date)}</span>
|
||||
<Badge variant="outline" className="text-xs">Original</Badge>
|
||||
</div>
|
||||
<p className="text-sm">{entry.description}</p>
|
||||
</div>
|
||||
|
||||
{/* New date */}
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="recordate-date" className="text-sm font-medium">
|
||||
Nytt datum
|
||||
</label>
|
||||
<Input
|
||||
id="recordate-date"
|
||||
type="date"
|
||||
value={newDate}
|
||||
onChange={(e) => setNewDate(e.target.value)}
|
||||
className="tabular-nums"
|
||||
/>
|
||||
|
||||
{/* Target period feedback */}
|
||||
{dateChanged && (
|
||||
<div className="text-sm" aria-live="polite">
|
||||
{previewLoading && <span className="text-muted-foreground">Kontrollerar period…</span>}
|
||||
|
||||
{!previewLoading && previewError && (
|
||||
<span className="inline-flex items-center gap-1.5 text-destructive">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
{previewError}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{!previewLoading && !previewError && targetOpen && (
|
||||
<span className="inline-flex items-center gap-1.5 text-muted-foreground">
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
Flyttas till {preview?.period_name ?? 'rätt räkenskapsår'}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{!previewLoading && noCoveringPeriod && (
|
||||
<span className="text-destructive">
|
||||
Det finns ingen räkenskapsperiod som täcker datumet. Skapa eller öppna räkenskapsåret först.
|
||||
</span>
|
||||
)}
|
||||
|
||||
{!previewLoading && preview?.status === 'closed' && (
|
||||
<span className="text-destructive">
|
||||
Räkenskapsåret är stängt (bokslut) och kan inte återöppnas. Bokför rättelsen i innevarande period istället.
|
||||
</span>
|
||||
)}
|
||||
|
||||
{!previewLoading && preview?.status === 'locked' && (
|
||||
<span className="inline-flex items-center gap-1.5 text-destructive">
|
||||
<Lock className="h-4 w-4" />
|
||||
Perioden är låst{preview?.lock_date ? ` t.o.m. ${formatDate(preview.lock_date)}` : ''}. Lås upp perioden för att flytta verifikationen dit.
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Soft advisory: moving into a past period */}
|
||||
{targetOpen && movingIntoPast && (
|
||||
<p className="inline-flex items-start gap-1.5 text-sm text-muted-foreground">
|
||||
<AlertTriangle className="h-4 w-4 mt-0.5 shrink-0" />
|
||||
<span>
|
||||
Om momsen för perioden redan är inlämnad kan du behöva lämna en rättad momsdeklaration.
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isSubmitting}>
|
||||
Avbryt
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={!canSubmit}>
|
||||
{isSubmitting ? 'Flyttar…' : 'Flytta verifikationen'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -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({
|
||||
<div className="p-4 bg-destructive/10 border border-destructive/20 rounded-lg flex gap-3">
|
||||
<AlertCircle className="h-5 w-5 text-destructive flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="font-medium text-destructive">Kunde inte läsa filen</p>
|
||||
<p className="font-medium text-destructive">{errorTitle || 'Kunde inte läsa filen'}</p>
|
||||
<p className="text-sm text-muted-foreground">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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.
|
||||
</p>
|
||||
)}
|
||||
{status.gl_1930_correction_adjustment !== 0 && (
|
||||
<p className="pt-2 text-xs text-muted-foreground">
|
||||
Rättelser och stornon på <AccountNumber number={accountNumber} /> i perioden:{' '}
|
||||
<span className="font-mono">{formatCurrency(status.gl_1930_correction_adjustment)}</span>
|
||||
{' '}— bokföringsmässiga rättelser utan motsvarande bankhändelse, räknas inte i avstämningen.
|
||||
</p>
|
||||
)}
|
||||
<div className="flex gap-4 pt-2 text-xs text-muted-foreground">
|
||||
<span>Matchade: {status.matched_count}</span>
|
||||
<span>Omatchade transaktioner: {status.unmatched_transaction_count}</span>
|
||||
|
||||
@@ -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<boolean>
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(v) => {
|
||||
if (isSaving) return
|
||||
onOpenChange(v)
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('edit_title_dialog_title')}</DialogTitle>
|
||||
<DialogDescription>{t('edit_title_warning')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="tx-title-input">{t('edit_title_label')}</Label>
|
||||
<Input
|
||||
id="tx-title-input"
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
maxLength={500}
|
||||
autoFocus
|
||||
disabled={isSaving}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && trimmed && !isUnchanged && !isSaving) {
|
||||
e.preventDefault()
|
||||
void persist(trimmed)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{canRestore && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('edit_title_original_hint', { name: originalTitle as string })}{' '}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void persist(originalTitle as string)}
|
||||
disabled={isSaving}
|
||||
className="underline underline-offset-2 hover:text-foreground transition-colors disabled:opacity-50"
|
||||
>
|
||||
{t('edit_title_restore')}
|
||||
</button>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={isSaving}
|
||||
className="min-h-11 w-full sm:w-auto"
|
||||
>
|
||||
{t('edit_title_cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => void persist(trimmed)}
|
||||
disabled={isSaving || !trimmed || isUnchanged}
|
||||
className="min-h-11 w-full sm:w-auto"
|
||||
>
|
||||
{isSaving ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
|
||||
{t('edit_title_save')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -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 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-9 w-9 text-muted-foreground hover:text-foreground"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onEditTitle(transaction)
|
||||
}}
|
||||
aria-label={t('edit_title_aria')}
|
||||
title={t('edit_title_aria')}
|
||||
disabled={isProcessing || isDisabled}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
{isDeletable && onDelete && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -316,6 +341,18 @@ export default function TransactionInboxCard({
|
||||
</div>
|
||||
<DataListMeta className="mt-1">
|
||||
<span className="tabular-nums">{formatDate(transaction.date)}</span>
|
||||
{transaction.title_edited_at && (
|
||||
<>
|
||||
<DataListMetaSeparator />
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="h-4 px-1.5 py-0 text-[10px]"
|
||||
title={originalName ? t('original_name_tooltip', { name: originalName }) : undefined}
|
||||
>
|
||||
{t('edited_badge')}
|
||||
</Badge>
|
||||
</>
|
||||
)}
|
||||
{skvCounterpartDate && (
|
||||
<>
|
||||
<DataListMetaSeparator />
|
||||
|
||||
@@ -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> = {}): 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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string, string> = {
|
||||
'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<string, string> = {
|
||||
'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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -12,6 +12,8 @@ function makeTx(overrides: Partial<Transaction> = {}): 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,
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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<string> {
|
||||
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<number> {
|
||||
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/)
|
||||
})
|
||||
})
|
||||
@@ -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<string, unknown> = {}
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -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' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<v
|
||||
}
|
||||
}
|
||||
|
||||
/** Journal entry row fetched together with its lines (the embedded select). */
|
||||
type OriginalWithLines = JournalEntry & { lines?: JournalEntryLine[] | null }
|
||||
|
||||
/**
|
||||
* Correct an existing posted journal entry using the storno method.
|
||||
*
|
||||
* The storno (reversal) is always created in the original entry's period and
|
||||
* date, so the original nets to zero where it was booked. The corrected entry
|
||||
* defaults to the original's date/period too, but `options.newEntryDate` /
|
||||
* `options.newFiscalPeriodId` let a caller re-book it elsewhere — used to move
|
||||
* a verifikation booked on the wrong year to its correct period (see
|
||||
* recordateEntry). When the date/period is the correction, identical lines are
|
||||
* allowed (the move itself is the meaningful change).
|
||||
*
|
||||
* Returns: { reversal, corrected } - the two new entries created
|
||||
*/
|
||||
export async function correctEntry(
|
||||
@@ -100,7 +117,18 @@ export async function correctEntry(
|
||||
companyId: string,
|
||||
userId: string,
|
||||
originalEntryId: string,
|
||||
correctedLines: CreateJournalEntryLineInput[]
|
||||
correctedLines: CreateJournalEntryLineInput[],
|
||||
options?: {
|
||||
newEntryDate?: string
|
||||
newFiscalPeriodId?: string
|
||||
/**
|
||||
* The original entry (with lines) already loaded by the caller. When
|
||||
* provided, we skip the redundant re-fetch — recordateEntry reads the
|
||||
* original to copy its lines and hands it through here. This also closes
|
||||
* the small TOCTOU window a second independent read would open.
|
||||
*/
|
||||
preloadedOriginal?: OriginalWithLines
|
||||
}
|
||||
): Promise<{ reversal: JournalEntry; corrected: JournalEntry }> {
|
||||
// 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<void> {
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.'
|
||||
}
|
||||
|
||||
@@ -259,6 +259,13 @@ const TRANSACTIONS: Record<string, StructuredErrorEntry> = {
|
||||
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<string, StructuredErrorEntry> = {
|
||||
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.',
|
||||
|
||||
@@ -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 = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Document xmlns="urn:iso:std:iso:20022:tech:xsd:camt.053.001.02">
|
||||
<BkToCstmrStmt>
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string> {
|
||||
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<string> {
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -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' })
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<string>(
|
||||
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,
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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<string, number>()
|
||||
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}`
|
||||
}
|
||||
+35
-19
@@ -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<string, number>
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<string>()
|
||||
|
||||
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,
|
||||
|
||||
+19
-1
@@ -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",
|
||||
|
||||
+19
-1
@@ -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",
|
||||
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
@@ -175,6 +175,8 @@ export function makeTransaction(overrides: Partial<Transaction> = {}): 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,
|
||||
|
||||
@@ -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<string> {
|
||||
@@ -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 })
|
||||
|
||||
+8
-1
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user