Add/transaction deletion (#695)

* feat(salary): add remove-employee button to draft salary runs

The DELETE /api/salary/runs/{id}/employees/{employeeId} endpoint already
existed (draft-only, cascades to the employee's line items) but had no UI
trigger, so a mistakenly added employee could only be cleared by deleting
the whole draft. Add a trash-icon action column to the "Anställda" table,
gated on draft status + write permission to match the endpoint's guard,
with a confirm prompt and success/error toast.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(settings): prevent horizontal overflow on mobile

The company settings invite form was a non-wrapping fixed-width flex row that overflowed narrow viewports, forcing the full-screen settings modal to scroll on the x-axis. Stack the form vertically on mobile (sm:flex-row at and above the sm breakpoint) and add the missing min-w-0 guard to the modal content pane.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(bookkeeping): move journal entry filters into a filter dialog

The ledger toolbar showed every filter inline (fiscal year, sort, series,
date range, missing-documents toggle), which felt cluttered. Keep only the
search field visible and move the rest into a "Filtrera" dialog with an
active-filter count badge.

- JournalEntryList now owns the fiscal-year scope, restored from the same
  localStorage key FiscalYearSelector writes, so the page no longer renders
  the selector separately.
- Filters apply live and the dialog stays open; "Rensa alla filter" clears them.
- Export STORAGE_KEY_PREFIX / ALL_YEARS_VALUE from FiscalYearSelector so the
  list reuses the persisted selection without duplicating the key.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(transactions): implement imported transaction guard for deletion

- Added a guard to prevent deletion of transactions that are imported via bank sync or file uploads.
- Introduced `isImportedTransaction` utility to determine if a transaction is user-created or imported.
- Updated DELETE endpoint to return a 409 status for attempts to delete imported transactions.
- Enhanced transaction history and inbox components to reflect the new deletion rules.
- Added tests for transaction origin determination and deletion behavior.
- Updated UI components to include a confirmation dialog for clearing journal entry forms.
- Localized new strings for clearing form functionality in English and Swedish.

* feat(transactions): enhance transaction deletion guard and improve fiscal year visibility

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-06-08 15:25:27 +02:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 679b154ad2
commit 64991eb3c9
21 changed files with 1223 additions and 348 deletions
+1 -4
View File
@@ -9,7 +9,6 @@ import { Button } from '@/components/ui/button'
import JournalEntryList from '@/components/bookkeeping/JournalEntryList'
import JournalEntryForm, { type FormLine } from '@/components/bookkeeping/JournalEntryForm'
import ChartOfAccountsManager from '@/components/bookkeeping/ChartOfAccountsManager'
import { FiscalYearSelector } from '@/components/common/FiscalYearSelector'
import { useToast } from '@/components/ui/use-toast'
import { Lock, Loader2, Copy } from 'lucide-react'
import { PageHeader } from '@/components/ui/page-header'
@@ -44,7 +43,6 @@ export default function BookkeepingPage() {
const [refreshKey, setRefreshKey] = useState(0)
const [activeTab, setActiveTab] = useState<TabValue>('journal')
const [periodId, setPeriodId] = useState<string | null>(null)
const [copyPrefill, setCopyPrefill] = useState<CopyPrefill | null>(null)
const [isLoadingCopy, setIsLoadingCopy] = useState(false)
const [nextVoucher, setNextVoucher] = useState<NextVoucher | null>(null)
@@ -163,8 +161,7 @@ export default function BookkeepingPage() {
</TabsList>
<TabsContent value="journal" forceMount className="space-y-4">
<FiscalYearSelector value={periodId} onChange={setPeriodId} />
<JournalEntryList key={`${refreshKey}-${periodId ?? 'all'}`} periodId={periodId ?? undefined} />
<JournalEntryList key={refreshKey} />
</TabsContent>
<TabsContent value="new-entry" forceMount>
+92 -26
View File
@@ -50,8 +50,8 @@ interface EntryPreview {
}
interface PreviewData {
salaryEntry: EntryPreview
avgifterEntry: EntryPreview
salaryEntry: EntryPreview | null
avgifterEntry: EntryPreview | null
vacationEntry: EntryPreview | null
}
@@ -167,6 +167,29 @@ export default function SalaryRunDetailPage({ params }: { params: Promise<{ id:
setActionLoading(null)
}
// Remove an employee from a draft run. The DELETE endpoint is draft-only and
// cascades to the employee's line items; the button is only rendered while the
// run is a draft, matching that guard.
async function handleRemoveEmployee(employeeId: string, name: string) {
if (!confirm(`Ta bort ${name} från lönekörningen?`)) return
setActionLoading(`remove-${employeeId}`)
const res = await fetch(`/api/salary/runs/${id}/employees/${employeeId}`, {
method: 'DELETE',
})
if (res.ok) {
await loadRun()
toast({ title: 'Anställd borttagen' })
} else {
const result = await res.json()
toast({
title: 'Kunde inte ta bort anställd',
description: getErrorMessage(result, { context: 'salary', statusCode: res.status }),
variant: 'destructive',
})
}
setActionLoading(null)
}
// Edit this month's monthly salary for one employee (draft only). The engine
// reads this per-run value at calc time, so each month's gross can differ
// without changing the employee's standard pay. Saved on blur; the user then
@@ -317,6 +340,9 @@ export default function SalaryRunDetailPage({ params }: { params: Promise<{ id:
const employees = (run.employees || []) as SalaryRunEmployee[]
const addedEmployeeIds = new Set(employees.map(e => e.employee_id))
const notAdded = availableEmployees.filter(e => !addedEmployeeIds.has(e.id))
// Employees can only be removed while the run is a draft (matches the DELETE
// endpoint's guard); gate the row action column on the same condition.
const canRemoveEmployee = run.status === 'draft' && canWrite
// calculation_params is frozen only when the run has been calculated, so it
// distinguishes "not yet calculated" from "calculated to 0" (a nollkörning).
@@ -464,6 +490,7 @@ export default function SalaryRunDetailPage({ params }: { params: Promise<{ id:
<TableHead className="hidden lg:table-cell text-right">Avgifter</TableHead>
<TableHead className="hidden md:table-cell text-right">Semester</TableHead>
<TableHead className="text-right w-[80px]">Lönespec</TableHead>
{canRemoveEmployee && <TableHead className="w-[48px]"><span className="sr-only">Ta bort</span></TableHead>}
</TableRow>
</TableHeader>
<TableBody>
@@ -530,6 +557,28 @@ export default function SalaryRunDetailPage({ params }: { params: Promise<{ id:
Visa PDF
</a>
</TableCell>
{canRemoveEmployee && (
<TableCell className="text-right">
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-destructive"
onClick={(e) => {
e.stopPropagation()
handleRemoveEmployee(sre.employee_id, name)
}}
disabled={actionLoading === `remove-${sre.employee_id}`}
aria-label={`Ta bort ${name} från lönekörningen`}
title="Ta bort från lönekörningen"
>
{actionLoading === `remove-${sre.employee_id}` ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Trash2 className="h-4 w-4" />
)}
</Button>
</TableCell>
)}
</TableRow>
)
})}
@@ -582,31 +631,48 @@ export default function SalaryRunDetailPage({ params }: { params: Promise<{ id:
<CardTitle className="text-base">Förhandsgranskning — verifikationer</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
{[preview.salaryEntry, preview.avgifterEntry, preview.vacationEntry, (preview as unknown as Record<string, EntryPreview | null>).pensionEntry].filter(Boolean).map((entry, idx) => (
<div key={idx} className="space-y-2">
<h4 className="text-sm font-medium">{entry!.description}</h4>
<table className="w-full text-xs">
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground">
<tr className="border-b">
<th className="text-left py-1">Konto</th>
<th className="text-left py-1">Beskrivning</th>
<th className="text-right py-1">Debet</th>
<th className="text-right py-1">Kredit</th>
</tr>
</thead>
<tbody>
{entry!.lines.map((line, li) => (
<tr key={li} className="border-t border-border/30">
<td className="py-1.5 tabular-nums font-mono">{line.account_number}</td>
<td className="py-1.5 text-muted-foreground">{line.line_description}</td>
<td className="py-1.5 text-right tabular-nums">{line.debit_amount ? formatCurrency(line.debit_amount) : ''}</td>
<td className="py-1.5 text-right tabular-nums">{line.credit_amount ? formatCurrency(line.credit_amount) : ''}</td>
{(() => {
const entries = [
preview.salaryEntry,
preview.avgifterEntry,
preview.vacationEntry,
(preview as unknown as Record<string, EntryPreview | null>).pensionEntry,
].filter(Boolean) as EntryPreview[]
if (entries.length === 0) {
return (
<p className="text-sm text-muted-foreground">
Nollkörning — inga verifikat bokförs för den här körningen.
Kontrollera att övriga lönekörningar för perioden täcker
arbetsgivardeklarationen till Skatteverket.
</p>
)
}
return entries.map((entry, idx) => (
<div key={idx} className="space-y-2">
<h4 className="text-sm font-medium">{entry.description}</h4>
<table className="w-full text-xs">
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground">
<tr className="border-b">
<th className="text-left py-1">Konto</th>
<th className="text-left py-1">Beskrivning</th>
<th className="text-right py-1">Debet</th>
<th className="text-right py-1">Kredit</th>
</tr>
))}
</tbody>
</table>
</div>
))}
</thead>
<tbody>
{entry.lines.map((line, li) => (
<tr key={li} className="border-t border-border/30">
<td className="py-1.5 tabular-nums font-mono">{line.account_number}</td>
<td className="py-1.5 text-muted-foreground">{line.line_description}</td>
<td className="py-1.5 text-right tabular-nums">{line.debit_amount ? formatCurrency(line.debit_amount) : ''}</td>
<td className="py-1.5 text-right tabular-nums">{line.credit_amount ? formatCurrency(line.credit_amount) : ''}</td>
</tr>
))}
</tbody>
</table>
</div>
))
})()}
</CardContent>
</Card>
)}
+36 -6
View File
@@ -58,6 +58,7 @@ import { getErrorMessage } from '@/lib/errors/get-error-message'
import { formatCurrency, formatDate } from '@/lib/utils'
import type { TransactionCategory, CreateTransactionInput, Invoice, Customer, SupplierInvoice, Supplier, VatTreatment, EntityType, LinePatternEntry, BookingTemplateLibrary } from '@/types'
import type { SuggestedTemplate } from '@/lib/transactions/category-suggestions'
import { isImportedTransaction } from '@/lib/transactions/origin'
type InvoiceWithCustomer = Invoice & { customer?: Customer }
type SupplierInvoiceWithSupplier = SupplierInvoice & { supplier?: Supplier }
@@ -1472,15 +1473,40 @@ export default function TransactionsPage() {
}
async function handleBatchDelete() {
const ids = Array.from(selectedIds)
// Only user-created rows can be deleted; imported (bank sync / CSV) rows are
// ignore-only. Split the selection so we never fire a delete the server
// would 409, and tell the user how many were skipped. Mirrors the server
// guard in DELETE /api/transactions/[id].
const selected = Array.from(selectedIds)
const ids: string[] = []
let skippedImported = 0
for (const id of selected) {
const tx = transactions.find((t) => t.id === id)
if (tx && isImportedTransaction(tx)) skippedImported++
else ids.push(id)
}
if (ids.length === 0) {
toast({
title: 'Inget att ta bort',
description: 'De valda transaktionerna är importerade och kan endast ignoreras, inte raderas.',
variant: 'destructive',
})
return
}
const ok = await confirm({
title: `Ta bort ${ids.length} transaktioner?`,
description: 'Åtgärden kan inte ångras.',
description:
skippedImported > 0
? `${skippedImported} importerade transaktioner hoppas över (kan endast ignoreras). Åtgärden kan inte ångras.`
: 'Åtgärden kan inte ångras.',
confirmLabel: 'Ta bort',
variant: 'destructive',
})
if (!ok) return
const deletedIds = new Set<string>()
setBatchProgress({ done: 0, total: ids.length })
let successes = 0
const failures: string[] = []
@@ -1489,6 +1515,7 @@ export default function TransactionsPage() {
const response = await fetch(`/api/transactions/${ids[i]}`, { method: 'DELETE' })
if (response.ok) {
successes++
deletedIds.add(ids[i])
} else {
const tx = transactions.find((t) => t.id === ids[i])
failures.push(tx?.description || ids[i])
@@ -1498,16 +1525,19 @@ export default function TransactionsPage() {
}
setBatchProgress({ done: i + 1, total: ids.length })
}
if (successes > 0) {
setTransactions((prev) => prev.filter((t) => !selectedIds.has(t.id) || failures.includes(t.description)))
if (deletedIds.size > 0) {
setTransactions((prev) => prev.filter((t) => !deletedIds.has(t.id)))
}
setBatchProgress(null)
if (failures.length === 0) {
if (failures.length === 0 && skippedImported === 0) {
toast({ title: 'Klart', description: `${successes} transaktioner borttagna` })
} else {
const parts = [`${successes} borttagna`]
if (failures.length > 0) parts.push(`${failures.length} misslyckades`)
if (skippedImported > 0) parts.push(`${skippedImported} importerade kunde inte raderas`)
toast({
title: 'Delvis klart',
description: `${successes} borttagna, ${failures.length} misslyckades`,
description: parts.join(', '),
variant: 'destructive',
})
}
@@ -140,6 +140,22 @@ describe('GET /api/bookkeeping/journal-entries', () => {
)
})
it('uses the direct query path (not the RPC) when a search term is set', async () => {
enqueue({ data: [], error: null, count: 0 })
const request = createMockRequest('/api/bookkeeping/journal-entries', {
searchParams: { period_id: 'period-1', search: 'luftfyllning' },
})
const response = await GET(request)
const { status } = await parseJsonResponse(response)
expect(status).toBe(200)
// Free-text search needs an ILIKE the include_related RPC can't express, so
// the route must fall through to the direct PostgREST query.
expect(mockSupabase.from).toHaveBeenCalledWith('journal_entries')
expect(mockSupabase.rpc).not.toHaveBeenCalled()
})
it('returns 500 on database error', async () => {
enqueue({ data: null, error: { message: 'DB error' } })
+19 -1
View File
@@ -7,6 +7,7 @@ import { validateBody } from '@/lib/api/validate'
import { CreateJournalEntrySchema } from '@/lib/api/schemas'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
import { escapeLikePattern } from '@/lib/invoices/duplicate-payment-guard'
ensureInitialized()
@@ -32,6 +33,14 @@ export async function GET(request: Request) {
// other value is passed (defense against trivial injection / typos).
const seriesRaw = searchParams.get('series')
const seriesFilter = seriesRaw && /^[A-Z]$/.test(seriesRaw) ? seriesRaw : null
// Free-text search over the voucher description (verifikationstext). When set,
// we take the direct-query path below (the include_related RPC can't search),
// which filters strictly by fiscal_period_id. So search is scoped to the
// selected fiscal period / company and — like voucher sort — does NOT surface
// cross-period follow-up entries: every result stays inside the selected
// year's series (the BFL-compliant per-year view). It narrows the period, it
// never widens it.
const search = searchParams.get('search')?.trim() || null
// 'date_desc' (default) | 'date_asc' | 'voucher_asc' | 'voucher_desc'
// sort_by overrides sort_date when present. sort_date is kept for backwards
// compatibility with older clients.
@@ -55,7 +64,7 @@ export async function GET(request: Request) {
// belonging to a different year's series would be misleading. The trade-off
// is that the visible row count may differ between sort modes for the same
// period; the strict count is the BFL-compliant view of that year.
if (periodId && includeRelated && !isVoucherSort) {
if (periodId && includeRelated && !isVoucherSort && !search) {
const { data, error } = await supabase.rpc('list_fiscal_period_entries_with_related', {
p_company_id: companyId,
p_period_id: periodId,
@@ -133,6 +142,15 @@ export async function GET(request: Request) {
query = query.eq('voucher_series', seriesFilter)
}
if (search) {
// Escape LIKE wildcards (\ % _) so they match literally, and cap the needle
// length (≤200 chars) — both handled by the shared escapeLikePattern helper.
// The cap bounds DB work against oversized/pathological inputs (compliance
// A.8.28 / ASVS V1.2.5); escaping prevents silent over-matching on values
// like "50%". Supabase parameterises the value, so this is not about SQLi.
query = query.ilike('description', `%${escapeLikePattern(search)}%`)
}
const { data, error, count } = await query
if (error) {
+28 -19
View File
@@ -88,22 +88,28 @@ export async function GET(
})
}
// Build avgifter entry preview
// Build avgifter entry preview — skipped for a nollkörning (0 avgifter),
// mirroring the vacation/pension guards below. The bookkeeping engine never
// posts an all-zero 7510/2731 voucher (see book/route.ts nollkörning path),
// so previewing one would falsely imply a verifikat that is never created.
const totalAvgifter = employees.reduce((sum, e) => sum + e.avgifter_amount, 0)
const avgifterLines: CreateJournalEntryLineInput[] = [
{
account_number: SALARY_ACCOUNTS.AVGIFTER_EXPENSE,
debit_amount: Math.round(totalAvgifter * 100) / 100,
credit_amount: 0,
line_description: `${desc} — Arbetsgivaravgifter`,
},
{
account_number: SALARY_ACCOUNTS.AVGIFTER_LIABILITY,
debit_amount: 0,
credit_amount: Math.round(totalAvgifter * 100) / 100,
line_description: `${desc} — Arbetsgivaravgifter`,
},
]
const roundedAvgifter = Math.round(totalAvgifter * 100) / 100
const avgifterLines: CreateJournalEntryLineInput[] = roundedAvgifter !== 0
? [
{
account_number: SALARY_ACCOUNTS.AVGIFTER_EXPENSE,
debit_amount: roundedAvgifter,
credit_amount: 0,
line_description: `${desc} — Arbetsgivaravgifter`,
},
{
account_number: SALARY_ACCOUNTS.AVGIFTER_LIABILITY,
debit_amount: 0,
credit_amount: roundedAvgifter,
line_description: `${desc} — Arbetsgivaravgifter`,
},
]
: []
// Build vacation entry preview
const totalVacation = employees.reduce((sum, e) => sum + e.vacation_accrual, 0)
@@ -170,14 +176,17 @@ export async function GET(
return NextResponse.json({
data: {
salaryEntry: {
// Each entry is null when it has no lines — a nollkörning posts nothing,
// so the salary and avgifter entries fall away just like vacation/pension
// already do, and the UI can simply skip the null ones.
salaryEntry: salaryLines.length > 0 ? {
description: desc,
lines: salaryLines,
},
avgifterEntry: {
} : null,
avgifterEntry: avgifterLines.length > 0 ? {
description: `${desc} — Arbetsgivaravgifter`,
lines: avgifterLines,
},
} : null,
vacationEntry: vacationLines.length > 0 ? {
description: `${desc} — Semesteravsättning`,
lines: vacationLines,
@@ -79,33 +79,46 @@ describe('DELETE /api/transactions/[id]', () => {
expect(body.error.message).toMatch(/Bankavstämning|storna/)
})
it('allows deleting unbooked bank-synced transactions', async () => {
it('blocks deleting an unbooked bank-synced transaction (ignore-only)', async () => {
// A live bank connection (PSD2) makes this an imported row — not the user's
// to delete, only to ignore.
const tx = makeTransaction({ bank_connection_id: 'bc-1', journal_entry_id: null, import_source: null })
enqueue({ data: tx, error: null }) // fetch
enqueue({ data: null, error: null }) // delete
enqueue({ data: tx, error: null }) // fetch (no delete should follow)
const request = new Request('http://localhost/api/transactions/tx-1', { method: 'DELETE' })
const response = await DELETE(request, createMockRouteParams({ id: 'tx-1' }))
const { status, body } = await parseJsonResponse(response)
const { status, body } = await parseJsonResponse<{ error: { code: string; message: string } }>(response)
expect(status).toBe(200)
expect(body).toEqual({ success: true })
expect(status).toBe(409)
expect(body.error.code).toBe('TRANSACTION_DELETE_IMPORTED')
expect(body.error.message).toMatch(/banken|ignorera/i)
})
it('allows deleting unbooked imported transactions', async () => {
it('blocks deleting an unbooked CSV-imported transaction (ignore-only)', async () => {
const tx = makeTransaction({ import_source: 'csv_nordea', journal_entry_id: null, bank_connection_id: null })
enqueue({ data: tx, error: null }) // fetch
enqueue({ data: null, error: null }) // delete
enqueue({ data: tx, error: null }) // fetch (no delete should follow)
const request = new Request('http://localhost/api/transactions/tx-1', { method: 'DELETE' })
const response = await DELETE(request, createMockRouteParams({ id: 'tx-1' }))
const { status, body } = await parseJsonResponse(response)
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
expect(status).toBe(200)
expect(body).toEqual({ success: true })
expect(status).toBe(409)
expect(body.error.code).toBe('TRANSACTION_DELETE_IMPORTED')
})
it('deletes a manually added unbooked transaction', async () => {
it('blocks deleting an unbooked Enable Banking transaction (ignore-only)', async () => {
const tx = makeTransaction({ import_source: 'enable_banking', journal_entry_id: null, bank_connection_id: null })
enqueue({ data: tx, error: null }) // fetch (no delete should follow)
const request = new Request('http://localhost/api/transactions/tx-1', { method: 'DELETE' })
const response = await DELETE(request, createMockRouteParams({ id: 'tx-1' }))
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
expect(status).toBe(409)
expect(body.error.code).toBe('TRANSACTION_DELETE_IMPORTED')
})
it('deletes a manually added unbooked transaction (null source)', async () => {
const tx = makeTransaction({ journal_entry_id: null, bank_connection_id: null, import_source: null })
enqueue({ data: tx, error: null }) // fetch
enqueue({ data: null, error: null }) // delete
@@ -118,6 +131,22 @@ describe('DELETE /api/transactions/[id]', () => {
expect(body).toEqual({ success: true })
})
it.each(['manual', 'mcp'])(
'deletes an unbooked in-app transaction created via %s',
async (source) => {
const tx = makeTransaction({ journal_entry_id: null, bank_connection_id: null, import_source: source })
enqueue({ data: tx, error: null }) // fetch
enqueue({ data: null, error: null }) // delete
const request = new Request('http://localhost/api/transactions/tx-1', { method: 'DELETE' })
const response = await DELETE(request, createMockRouteParams({ id: 'tx-1' }))
const { status, body } = await parseJsonResponse(response)
expect(status).toBe(200)
expect(body).toEqual({ success: true })
},
)
it('returns 500 with a structured code when deletion fails', async () => {
const tx = makeTransaction({ journal_entry_id: null, bank_connection_id: null, import_source: null })
enqueue({ data: tx, error: null }) // fetch
@@ -132,9 +161,12 @@ describe('DELETE /api/transactions/[id]', () => {
})
it('returns 409 with an audit-trail code when the immutability trigger blocks the delete', async () => {
// An unbooked row with payment_match_log rows: the cascade hits the
// audit_log_immutable trigger (P0001), not a clean FK error.
const tx = makeTransaction({ journal_entry_id: null, bank_connection_id: 'bc-1', import_source: null })
// A user-created (deletable) row that still carries payment_match_log rows
// — e.g. it was auto-suggested a match. The cascade on delete hits the
// audit_log_immutable trigger (P0001), not a clean FK error. Fixture must be
// user-created (no bank link / import source) so it passes the imported
// guard and actually reaches the delete.
const tx = makeTransaction({ journal_entry_id: null, bank_connection_id: null, import_source: 'manual' })
enqueue({ data: tx, error: null }) // fetch
enqueue({
data: null,
+25 -2
View File
@@ -7,6 +7,7 @@ import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structure
import { validateBody } from '@/lib/api/validate'
import { UpdateTransactionTitleSchema } from '@/lib/api/schemas'
import { guardSandbox } from '@/lib/sandbox/guard'
import { isImportedTransaction } from '@/lib/transactions/origin'
import type { Transaction } from '@/types'
export async function DELETE(
@@ -26,10 +27,11 @@ export async function DELETE(
const companyId = await requireCompanyId(supabase, user.id)
// Fetch the transaction with ownership check
// Fetch the transaction with ownership check. `bank_connection_id` +
// `import_source` tell us where the row came from (see the imported guard below).
const { data: transaction, error: fetchError } = await supabase
.from('transactions')
.select('id, journal_entry_id')
.select('id, journal_entry_id, bank_connection_id, import_source')
.eq('id', id)
.eq('company_id', companyId)
.single()
@@ -66,6 +68,27 @@ export async function DELETE(
)
}
// Guard: only transactions the user created in the app can be deleted. Rows
// fetched via bank sync or uploaded via a bank-file import are an external
// record of money that moved — deleting one would silently drop a real bank
// line (and a re-sync would just bring it back). The user can *ignore* such a
// row (POST /api/transactions/[id]/ignore) to take it off the to-book list,
// but never delete it. See lib/transactions/origin.ts for the origin rule.
if (isImportedTransaction(transaction)) {
return NextResponse.json(
{
error: {
code: 'TRANSACTION_DELETE_IMPORTED',
message:
'Transaktionen har hämtats från banken eller importerats via fil och kan inte raderas. Du kan ignorera den så att den döljs från listan över transaktioner att bokföra.',
message_en:
'This transaction was fetched from your bank or imported from a file and cannot be deleted. You can ignore it to hide it from the list of transactions to book.',
},
},
{ status: 409 }
)
}
const { error: deleteError } = await supabase
.from('transactions')
.delete()
+239 -138
View File
@@ -40,6 +40,78 @@ function isMobile(): boolean {
return /iPhone|iPad|iPod|Android/i.test(navigator.userAgent)
}
/**
* sessionStorage key holding an in-flight BankID session across the mobile
* return-redirect. On iOS the BankID app returns to the SAME Safari tab by
* reloading it (see launchBankIdApp), which wipes React state — so we stash the
* session here and resume polling on the next mount.
*/
const PENDING_KEY = 'bankid:pending'
/** Ignore a stashed session older than this (BankID orders expire in ~3 min). */
const PENDING_TTL_MS = 5 * 60 * 1000
interface PendingBankId {
session: BankIdSession
mode: string
ts: number
}
function persistPending(session: BankIdSession, mode: string): void {
try {
sessionStorage.setItem(
PENDING_KEY,
JSON.stringify({ session, mode, ts: Date.now() } satisfies PendingBankId)
)
} catch {
// sessionStorage unavailable (private mode / quota) — auto-resume just won't
// fire; the user can still switch back to the tab manually as before.
}
}
function readPending(mode: string): PendingBankId | null {
try {
const raw = sessionStorage.getItem(PENDING_KEY)
if (!raw) return null
const parsed = JSON.parse(raw) as PendingBankId
if (parsed?.mode !== mode) return null
if (typeof parsed.ts !== 'number' || Date.now() - parsed.ts > PENDING_TTL_MS) return null
if (!parsed.session?.sessionId) return null
return parsed
} catch {
return null
}
}
function clearPending(): void {
try {
sessionStorage.removeItem(PENDING_KEY)
} catch {
// ignore
}
}
/**
* Launch the BankID app on the same (mobile) device.
*
* Uses the universal link https://app.bankid.com/ — NOT the bankid:/// custom
* scheme. A custom-scheme launch has no association with the originating Safari
* tab, so on iOS the post-auth redirect opens in a NEW tab (git history: commit
* 3bc652cc reverted a redirect for exactly that reason). The universal link is
* tied to the originating tab, so BankID returns the user to it.
*
* redirect:
* • iOS → current URL, so the app navigates this tab back here on success
* (the resume effect then completes the flow).
* • Android → "null": the BankID app returns via the task stack, and a real
* redirect URL would spawn a new tab / Chrome instance instead.
*/
function launchBankIdApp(autoStartToken: string): void {
const ua = typeof navigator !== 'undefined' ? navigator.userAgent : ''
const isIOS = /iPad|iPhone|iPod/.test(ua)
const redirect = isIOS ? encodeURIComponent(window.location.href) : 'null'
window.location.href = `https://app.bankid.com/?autostarttoken=${autoStartToken}&redirect=${redirect}`
}
/**
* BankID authentication flow component.
* Handles QR code display (desktop) or app deep link (mobile),
@@ -72,6 +144,149 @@ export function BankIdAuth({ mode, onComplete }: BankIdAuthProps) {
useEffect(() => cleanup, [cleanup])
// Poll an in-flight BankID session until it completes, fails, or the service
// gives up. Extracted from startSession so the resume effect (mobile return)
// can re-attach to a session that was started before the tab reloaded.
const beginPolling = useCallback((session: BankIdSession) => {
abortRef.current = new AbortController()
pollRef.current = setInterval(async () => {
try {
const pollRes = await fetch(`${API_BASE}/poll`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionId: session.sessionId }),
signal: abortRef.current?.signal,
})
if (!pollRes.ok) {
const pollErr = await pollRes.json().catch(() => ({}))
if (pollErr.error === 'service_unavailable' || pollRes.status === 502 || pollRes.status === 503) {
pollFailureCount.current++
if (pollFailureCount.current >= MAX_POLL_FAILURES) {
cleanup()
clearPending()
setStatus('service_unavailable')
setErrorMessage('BankID-tjänsten är inte tillgänglig just nu')
onCompleteRef.current({ error: 'service_unavailable' })
}
}
return
}
// Reset failure counter on successful poll
pollFailureCount.current = 0
const pollJson = await pollRes.json()
const pollData = pollJson.data
if (!pollData) {
console.warn('[bankid] poll returned no data:', pollJson)
return
}
// Update hint message from TIC API
if (pollData.message) {
setHintMessage(pollData.message)
}
// Handle token refresh (order regeneration ~25s)
if (pollData.qrStartToken && pollData.qrStartSecret) {
setSession((prev) =>
prev
? { ...prev, qrStartToken: pollData.qrStartToken, qrStartSecret: pollData.qrStartSecret }
: prev
)
}
if (pollData.status === 'complete') {
cleanup()
clearPending()
setStatus('complete')
if (mode === 'login') {
// For login, call /complete to exchange for Supabase session
try {
const completeRes = await fetch(`${API_BASE}/complete`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
sessionId: session.sessionId,
mode: 'login',
}),
})
const completeJson = await completeRes.json()
if (!completeRes.ok) {
const errorCode = completeJson.error === 'service_unavailable' || completeRes.status === 502 || completeRes.status === 503
? 'service_unavailable' as const
: completeJson.error
if (errorCode === 'service_unavailable') {
setStatus('service_unavailable')
setErrorMessage('BankID-tjänsten är inte tillgänglig just nu')
}
onCompleteRef.current({
error: errorCode,
givenName: completeJson.givenName,
surname: completeJson.surname,
})
return
}
onCompleteRef.current({
tokenHash: completeJson.data.tokenHash,
type: completeJson.data.type,
isNewUser: completeJson.data.isNewUser,
})
} catch {
onCompleteRef.current({ error: 'session_invalid' })
}
} else if (mode === 'link') {
// For link, call /link to associate BankID with current user
try {
const linkRes = await fetch(`${API_BASE}/link`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionId: session.sessionId }),
})
const linkJson = await linkRes.json()
if (!linkRes.ok) {
onCompleteRef.current({ error: linkJson.error })
return
}
onCompleteRef.current({})
} catch {
onCompleteRef.current({ error: 'session_invalid' })
}
} else {
// For signup, return user data + sessionId so parent can collect email
onCompleteRef.current({
givenName: pollData.user?.givenName,
surname: pollData.user?.surname,
sessionId: session.sessionId,
})
}
} else if (pollData.status === 'failed' || pollData.status === 'cancelled') {
cleanup()
clearPending()
setStatus('failed')
setErrorMessage(pollData.message || 'BankID-identifieringen misslyckades')
}
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') return
pollFailureCount.current++
if (pollFailureCount.current >= MAX_POLL_FAILURES) {
cleanup()
clearPending()
setStatus('service_unavailable')
setErrorMessage('BankID-tjänsten är inte tillgänglig just nu')
onCompleteRef.current({ error: 'service_unavailable' })
}
}
}, 2000)
}, [cleanup, mode])
const startSession = useCallback(async () => {
// Prevent rapid restarts (each start = billable TIC session)
const now = Date.now()
@@ -79,6 +294,7 @@ export function BankIdAuth({ mode, onComplete }: BankIdAuthProps) {
lastStartRef.current = now
cleanup()
clearPending()
setStatus('scanning')
setHintMessage('Starta BankID-appen')
setErrorMessage('')
@@ -101,157 +317,42 @@ export function BankIdAuth({ mode, onComplete }: BankIdAuthProps) {
const newSession: BankIdSession = data
setSession(newSession)
// On mobile, open BankID app — redirect=null so it doesn't open a new tab;
// the polling in this tab detects completion when the user switches back.
// On mobile, open the BankID app on this device.
if (isMobile()) {
window.location.href = `bankid:///?autostarttoken=${newSession.autoStartToken}&redirect=null`
// Persist BEFORE launching: on iOS the BankID app returns by reloading
// THIS tab (universal link → same tab), which wipes in-memory state.
// The resume effect re-attaches polling on load. See launchBankIdApp.
persistPending(newSession, mode)
launchBankIdApp(newSession.autoStartToken)
}
// Start polling
abortRef.current = new AbortController()
pollRef.current = setInterval(async () => {
try {
const pollRes = await fetch(`${API_BASE}/poll`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionId: newSession.sessionId }),
signal: abortRef.current?.signal,
})
if (!pollRes.ok) {
const pollErr = await pollRes.json().catch(() => ({}))
if (pollErr.error === 'service_unavailable' || pollRes.status === 502 || pollRes.status === 503) {
pollFailureCount.current++
if (pollFailureCount.current >= MAX_POLL_FAILURES) {
cleanup()
setStatus('service_unavailable')
setErrorMessage('BankID-tjänsten är inte tillgänglig just nu')
onCompleteRef.current({ error: 'service_unavailable' })
}
}
return
}
// Reset failure counter on successful poll
pollFailureCount.current = 0
const pollJson = await pollRes.json()
const pollData = pollJson.data
if (!pollData) {
console.warn('[bankid] poll returned no data:', pollJson)
return
}
// Update hint message from TIC API
if (pollData.message) {
setHintMessage(pollData.message)
}
// Handle token refresh (order regeneration ~25s)
if (pollData.qrStartToken && pollData.qrStartSecret) {
setSession((prev) =>
prev
? { ...prev, qrStartToken: pollData.qrStartToken, qrStartSecret: pollData.qrStartSecret }
: prev
)
}
if (pollData.status === 'complete') {
cleanup()
setStatus('complete')
if (mode === 'login') {
// For login, call /complete to exchange for Supabase session
try {
const completeRes = await fetch(`${API_BASE}/complete`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
sessionId: newSession.sessionId,
mode: 'login',
}),
})
const completeJson = await completeRes.json()
if (!completeRes.ok) {
const errorCode = completeJson.error === 'service_unavailable' || completeRes.status === 502 || completeRes.status === 503
? 'service_unavailable' as const
: completeJson.error
if (errorCode === 'service_unavailable') {
setStatus('service_unavailable')
setErrorMessage('BankID-tjänsten är inte tillgänglig just nu')
}
onCompleteRef.current({
error: errorCode,
givenName: completeJson.givenName,
surname: completeJson.surname,
})
return
}
onCompleteRef.current({
tokenHash: completeJson.data.tokenHash,
type: completeJson.data.type,
isNewUser: completeJson.data.isNewUser,
})
} catch {
onCompleteRef.current({ error: 'session_invalid' })
}
} else if (mode === 'link') {
// For link, call /link to associate BankID with current user
try {
const linkRes = await fetch(`${API_BASE}/link`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionId: newSession.sessionId }),
})
const linkJson = await linkRes.json()
if (!linkRes.ok) {
onCompleteRef.current({ error: linkJson.error })
return
}
onCompleteRef.current({})
} catch {
onCompleteRef.current({ error: 'session_invalid' })
}
} else {
// For signup, return user data + sessionId so parent can collect email
onCompleteRef.current({
givenName: pollData.user?.givenName,
surname: pollData.user?.surname,
sessionId: newSession.sessionId,
})
}
} else if (pollData.status === 'failed' || pollData.status === 'cancelled') {
cleanup()
setStatus('failed')
setErrorMessage(pollData.message || 'BankID-identifieringen misslyckades')
}
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') return
pollFailureCount.current++
if (pollFailureCount.current >= MAX_POLL_FAILURES) {
cleanup()
setStatus('service_unavailable')
setErrorMessage('BankID-tjänsten är inte tillgänglig just nu')
onCompleteRef.current({ error: 'service_unavailable' })
}
}
}, 2000)
beginPolling(newSession)
} catch (error) {
setStatus('failed')
setErrorMessage(error instanceof Error ? error.message : 'Ett oväntat fel uppstod')
}
}, [cleanup, mode])
}, [cleanup, mode, beginPolling])
// After returning from the BankID app on mobile, iOS reloads this tab. Pick up
// the session we persisted before launching and resume polling so the flow
// completes without the user having to tap "Logga in med BankID" again.
useEffect(() => {
const pending = readPending(mode)
if (!pending) return
setSession(pending.session)
setStatus('scanning')
setHintMessage('Slutför BankID-verifieringen...')
beginPolling(pending.session)
// Run once on mount: we're recovering state that the return-reload destroyed.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const handleCancel = useCallback(async () => {
if (session) {
fetch(`${API_BASE}/${session.sessionId}`, { method: 'DELETE' }).catch(() => {})
}
cleanup()
clearPending()
setStatus('idle')
setSession(null)
}, [session, cleanup])
+53 -1
View File
@@ -8,8 +8,9 @@ import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog'
import { useToast } from '@/components/ui/use-toast'
import { Plus, Trash2, AlertTriangle, Loader2, Lock, CalendarPlus } from 'lucide-react'
import { Plus, Trash2, AlertTriangle, Loader2, Lock, CalendarPlus, Eraser } from 'lucide-react'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import { ConfirmationDialog } from '@/components/ui/confirmation-dialog'
import { JournalEntryReviewContent } from '@/components/bookkeeping/JournalEntryReviewContent'
@@ -106,6 +107,7 @@ export default function JournalEntryForm({
const [foreignAmount, setForeignAmount] = useState('')
const [periodMismatch, setPeriodMismatch] = useState<'no_period' | 'wrong_period' | null>(null)
const [showCreatePeriod, setShowCreatePeriod] = useState(false)
const [showClearConfirm, setShowClearConfirm] = useState(false)
// Month (YYYY-MM) of the most recently posted voucher this session. Used to
// flag, at the review step, when the user is about to book into a different
// month — guards against accidentally posting to the wrong month.
@@ -457,6 +459,20 @@ export default function JournalEntryForm({
if (!description) setDescription(templateDescription)
}
// Wipe the form back to a blank entry. Mirrors the post-submit reset: it
// clears the data the user typed (lines, description, note, attachments,
// currency) but keeps the contextual defaults (period, date, series) so the
// form is immediately ready for the next entry.
const handleClearAll = () => {
setDescription('')
setNotes('')
setUploadedFiles([])
setLines([{ ...BLANK_LINE }, { ...BLANK_LINE }])
setEntryCurrency('SEK')
setExchangeRate('')
setForeignAmount('')
}
const handleOpenCreateAccount = (lineIndex: number, prefill: string) => {
setCreatingAccountForLine(lineIndex)
setCreateAccountPrefill(prefill)
@@ -1067,6 +1083,17 @@ export default function JournalEntryForm({
<div className="flex flex-col items-end gap-1">
<div className="flex gap-2">
{!embedded && (
<Button
variant="ghost"
onClick={() => setShowClearConfirm(true)}
disabled={!hasContent || isSubmitting || isSavingDraft}
title={t('clear_all_tooltip')}
>
<Eraser className="mr-2 h-4 w-4" />
{t('clear_all')}
</Button>
)}
<Button
variant="outline"
onClick={handleSaveDraft}
@@ -1195,6 +1222,31 @@ export default function JournalEntryForm({
periods={periods}
onCreated={fetchPeriods}
/>
{/* Clear-all confirmation */}
<Dialog open={showClearConfirm} onOpenChange={setShowClearConfirm}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t('clear_all_confirm_title')}</DialogTitle>
<DialogDescription>{t('clear_all_confirm_body')}</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setShowClearConfirm(false)}>
{t('clear_all_cancel')}
</Button>
<Button
variant="destructive"
onClick={() => {
handleClearAll()
setShowClearConfirm(false)
}}
>
<Eraser className="mr-2 h-4 w-4" />
{t('clear_all_confirm')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
+434 -124
View File
@@ -4,13 +4,27 @@ import { useState, useEffect, useCallback } from 'react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { useTranslations } from 'next-intl'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Switch } from '@/components/ui/switch'
import { ChevronDown, ChevronRight, Paperclip, AlertTriangle, CircleSlash, Loader2, BookOpen, X, Copy, Lock } from 'lucide-react'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
DialogFooter,
DialogClose,
} from '@/components/ui/dialog'
import {
FiscalYearSelector,
STORAGE_KEY_PREFIX as FISCAL_YEAR_STORAGE_KEY_PREFIX,
ALL_YEARS_VALUE as FISCAL_YEAR_ALL_VALUE,
} from '@/components/common/FiscalYearSelector'
import { ChevronDown, ChevronRight, Paperclip, AlertTriangle, CircleSlash, Loader2, BookOpen, X, Copy, Lock, Search, SlidersHorizontal } from 'lucide-react'
import { formatDate } from '@/lib/utils'
import { formatVoucher } from '@/lib/bookkeeping/voucher-series-resolver'
import { Input } from '@/components/ui/input'
@@ -24,7 +38,8 @@ import AttachmentPreviewSheet from '@/components/bookkeeping/AttachmentPreviewSh
import { useToast } from '@/components/ui/use-toast'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import type { JournalEntry, JournalEntryLine } from '@/types'
import { useCompanyOptional } from '@/contexts/CompanyContext'
import type { FiscalPeriod, JournalEntry, JournalEntryLine } from '@/types'
const NEEDS_ATTACHMENT = new Set([
'manual',
@@ -35,14 +50,18 @@ const NEEDS_ATTACHMENT = new Set([
'import',
])
interface Props {
periodId?: string
}
type SortBy = 'date_desc' | 'date_asc' | 'voucher_asc' | 'voucher_desc'
export default function JournalEntryList({ periodId }: Props) {
// Per-company persistence of the sort dropdown. Mirrors the localStorage
// convention used by FiscalYearSelector ('Accounted:fiscal-year:<companyId>').
const SORT_STORAGE_KEY_PREFIX = 'Accounted:journal-sort:'
const SORT_VALUES = new Set<SortBy>(['date_desc', 'date_asc', 'voucher_asc', 'voucher_desc'])
export default function JournalEntryList() {
const router = useRouter()
const { toast } = useToast()
const { canWrite } = useCanWrite()
const company = useCompanyOptional()?.company ?? null
const t = useTranslations('journal_list')
const [entries, setEntries] = useState<JournalEntry[]>([])
const [committingId, setCommittingId] = useState<string | null>(null)
@@ -55,12 +74,19 @@ export default function JournalEntryList({ periodId }: Props) {
const [showMissingOnly, setShowMissingOnly] = useState(false)
const [correctionEntry, setCorrectionEntry] = useState<JournalEntry | null>(null)
const [previewEntryId, setPreviewEntryId] = useState<string | null>(null)
const [sortBy, setSortBy] = useState<'date_desc' | 'date_asc' | 'voucher_asc' | 'voucher_desc'>('date_desc')
const [sortBy, setSortBy] = useState<SortBy>('date_desc')
const [sortHydrated, setSortHydrated] = useState(false)
const [periodId, setPeriodId] = useState<string | null>(null)
const [periodHydrated, setPeriodHydrated] = useState(false)
const [periods, setPeriods] = useState<FiscalPeriod[]>([])
const [filterOpen, setFilterOpen] = useState(false)
const [dateFrom, setDateFrom] = useState('')
const [dateTo, setDateTo] = useState('')
const [dateFromInput, setDateFromInput] = useState('')
const [dateToInput, setDateToInput] = useState('')
const [seriesFilter, setSeriesFilter] = useState<string>('all')
const [searchInput, setSearchInput] = useState('')
const [search, setSearch] = useState('')
const pageSize = 20
const normalizeDate = (v: string): string | null => {
@@ -136,6 +162,72 @@ export default function JournalEntryList({ periodId }: Props) {
fetchNoDocRequired()
}, [fetchNoDocRequired])
// Restore the persisted sort order (per company). Read in an effect rather
// than the useState initializer to avoid an SSR/client hydration mismatch.
// sortHydrated gates the first fetch so the list is fetched once, already in
// the saved order — no flash of the default sort.
useEffect(() => {
if (typeof window !== 'undefined') {
const stored = window.localStorage.getItem(SORT_STORAGE_KEY_PREFIX + (company?.id ?? 'default'))
if (stored && SORT_VALUES.has(stored as SortBy)) setSortBy(stored as SortBy)
}
setSortHydrated(true)
}, [company?.id])
// Restore the persisted fiscal-year selection (per company), reading the same
// localStorage key FiscalYearSelector writes. The selector lives inside the
// filter dialog and only mounts when opened, so we resolve the saved scope
// here — independent of the dialog — to keep the initial fetch correct.
// periodHydrated gates the first fetch so the list loads already scoped.
useEffect(() => {
if (company?.id && typeof window !== 'undefined') {
const stored = window.localStorage.getItem(FISCAL_YEAR_STORAGE_KEY_PREFIX + company.id)
setPeriodId(stored && stored !== FISCAL_YEAR_ALL_VALUE ? stored : null)
} else {
setPeriodId(null)
}
setPeriodHydrated(true)
}, [company?.id])
// Fetch fiscal periods so the active räkenskapsår can be labelled on the
// filter bar without opening the dialog (BFL period-orientation: the user
// should always see which year the ledger is scoped to). Read-only — the
// dialog's FiscalYearSelector still owns selection; this copy resolves the
// name for display.
useEffect(() => {
if (!company?.id) {
setPeriods([])
return
}
let cancelled = false
;(async () => {
try {
const res = await fetch('/api/bookkeeping/fiscal-periods')
if (!res.ok) return
const { data } = await res.json()
if (!cancelled) setPeriods((data || []) as FiscalPeriod[])
} catch {
// Non-critical — the chip falls back to the active-filter count badge.
}
})()
return () => {
cancelled = true
}
}, [company?.id])
// Debounce the free-text search before it reaches the API. Require ≥2 chars:
// a single character matches almost every verifikationstext and isn't a useful
// filter, so 0–1 chars are treated as "no search" instead of firing a query on
// every keystroke (ASVS V2.4).
useEffect(() => {
const handle = setTimeout(() => {
const trimmed = searchInput.trim()
setSearch(trimmed.length >= 2 ? trimmed : '')
setPage(0)
}, 300)
return () => clearTimeout(handle)
}, [searchInput])
async function fetchEntries() {
setLoading(true)
const params = new URLSearchParams({
@@ -147,6 +239,7 @@ export default function JournalEntryList({ periodId }: Props) {
if (dateFrom) params.set('date_from', dateFrom)
if (dateTo) params.set('date_to', dateTo)
if (seriesFilter !== 'all') params.set('series', seriesFilter)
if (search) params.set('search', search)
const res = await fetch(`/api/bookkeeping/journal-entries?${params}`)
if (!res.ok) {
@@ -165,8 +258,9 @@ export default function JournalEntryList({ periodId }: Props) {
}
useEffect(() => {
if (!sortHydrated || !periodHydrated) return
fetchEntries()
}, [periodId, page, sortBy, dateFrom, dateTo, seriesFilter])
}, [periodId, page, sortBy, dateFrom, dateTo, seriesFilter, search, sortHydrated, periodHydrated])
const handleAttachmentCountChange = useCallback((entryId: string, count: number) => {
setAttachmentCounts((prev) => ({ ...prev, [entryId]: count }))
@@ -198,18 +292,63 @@ export default function JournalEntryList({ periodId }: Props) {
}
}
if (loading) {
return (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground mb-3" />
<p className="text-sm text-muted-foreground">{t('loading')}</p>
</CardContent>
</Card>
)
const filteredEntries = showMissingOnly
? entries.filter(
(e) =>
NEEDS_ATTACHMENT.has(e.source_type) &&
!attachmentCounts[e.id] &&
e.status === 'posted' &&
!noDocRequired.has(e.id)
)
: entries
// Count of active dialog filters, shown as a badge on the Filtrera button so
// the user can tell the list is scoped without opening the dialog. Sort order
// is a view preference (always set), not a filter, so it is excluded.
const activeFilterCount =
(periodId ? 1 : 0) +
(seriesFilter !== 'all' ? 1 : 0) +
(dateFrom || dateTo ? 1 : 0) +
(showMissingOnly ? 1 : 0)
// When any filter or search is active we keep the filter bar mounted even
// with zero results, so the user can edit or clear their query. The pristine
// "no entries yet" state below only applies to an untouched, empty ledger.
const hasActiveFilters = Boolean(search) || activeFilterCount > 0
// Resolve the active fiscal-year scope for the bar chip. "All years" (periodId
// null) renders immediately; a specific period waits until its name resolves
// from the fetched list (scopeLabel stays null meanwhile, so the chip never
// flashes the wrong scope). Surfacing this keeps the period visible per BFL
// without the user having to open the filter dialog.
const activePeriod = periodId ? periods.find((p) => p.id === periodId) ?? null : null
const scopeLabel = periodId ? activePeriod?.name ?? null : t('scope_all_years')
// Apply a fiscal-year selection from the dialog. The FiscalYearSelector
// persists the choice to localStorage itself; here we only mirror it into
// local state and reset pagination.
const handlePeriodChange = (next: string | null) => {
setPeriodId(next)
setPage(0)
}
if (entries.length === 0) {
const clearAllFilters = () => {
setPeriodId(null)
// Mirror the selector's "Alla räkenskapsår" write so the cleared scope
// survives a remount/reload instead of being restored from a stale value.
if (company?.id && typeof window !== 'undefined') {
window.localStorage.setItem(FISCAL_YEAR_STORAGE_KEY_PREFIX + company.id, FISCAL_YEAR_ALL_VALUE)
}
setSeriesFilter('all')
setShowMissingOnly(false)
setDateFrom('')
setDateTo('')
setDateFromInput('')
setDateToInput('')
setPage(0)
}
if (!loading && entries.length === 0 && !hasActiveFilters) {
return (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
@@ -225,124 +364,240 @@ export default function JournalEntryList({ periodId }: Props) {
)
}
const filteredEntries = showMissingOnly
? entries.filter(
(e) =>
NEEDS_ATTACHMENT.has(e.source_type) &&
!attachmentCounts[e.id] &&
e.status === 'posted' &&
!noDocRequired.has(e.id)
)
: entries
return (
<div className="space-y-4">
{/* Filters and sorting */}
<div className="space-y-3 sm:space-y-0 sm:flex sm:items-center sm:gap-4 sm:flex-wrap">
<div className="flex items-center justify-between sm:justify-start gap-2">
<div className="flex items-center gap-2">
<Switch
id="missing-attachments"
checked={showMissingOnly}
onCheckedChange={setShowMissingOnly}
/>
<Label htmlFor="missing-attachments" className="text-sm cursor-pointer">
{t('show_missing')}
</Label>
{showMissingOnly && (
<Badge variant="secondary" className="text-xs">
{filteredEntries.length}
</Badge>
)}
</div>
</div>
<Select value={sortBy} onValueChange={(v) => { setSortBy(v as typeof sortBy); setPage(0) }}>
<SelectTrigger className="h-8 w-auto gap-1.5 text-xs sm:w-[200px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="date_desc">{t('sort_date_desc')}</SelectItem>
<SelectItem value="date_asc">{t('sort_date_asc')}</SelectItem>
<SelectItem value="voucher_asc">{t('sort_voucher_asc')}</SelectItem>
<SelectItem value="voucher_desc">{t('sort_voucher_desc')}</SelectItem>
</SelectContent>
</Select>
<Select value={seriesFilter} onValueChange={(v) => { setSeriesFilter(v); setPage(0) }}>
<SelectTrigger
className="h-8 w-auto gap-1.5 text-xs sm:w-[120px] font-mono"
aria-label="Verifikationsserie"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all" className="text-xs">Alla serier</SelectItem>
{'ABCDEFG'.split('').map((letter) => (
<SelectItem key={letter} value={letter} className="font-mono text-xs">
Serie {letter}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="flex items-center gap-1.5">
{/* Search (always visible) + filter dialog for everything else */}
<div className="flex items-center gap-2">
<div className="relative flex-1 sm:flex-none sm:w-[280px]">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground pointer-events-none" />
<Input
type="text"
placeholder={t('date_from_placeholder')}
value={dateFromInput}
onChange={(e) => setDateFromInput(e.target.value)}
onBlur={() => {
const v = dateFromInput.trim()
if (v === '') return
const normalized = normalizeDate(v)
if (normalized) setDateFromInput(normalized)
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
applyDateFilter()
}
}}
className="h-8 flex-1 sm:flex-none sm:w-[145px] text-xs"
inputMode="search"
placeholder={t('search_placeholder')}
aria-label={t('search_placeholder')}
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
className="h-8 pl-8 pr-7 text-xs"
/>
<Input
type="text"
placeholder={t('date_to_placeholder')}
value={dateToInput}
onChange={(e) => setDateToInput(e.target.value)}
onBlur={() => {
const v = dateToInput.trim()
if (v === '') return
const normalized = normalizeDate(v)
if (normalized) setDateToInput(normalized)
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
applyDateFilter()
}
}}
className="h-8 flex-1 sm:flex-none sm:w-[145px] text-xs"
/>
<Button
variant="outline"
size="sm"
className="h-8 text-xs shrink-0"
onClick={applyDateFilter}
>
{t('filter')}
</Button>
{(dateFrom || dateTo) && (
{searchInput && (
<button
type="button"
onClick={() => { setDateFrom(''); setDateTo(''); setDateFromInput(''); setDateToInput(''); setPage(0) }}
className="p-1 rounded-sm hover:bg-muted text-muted-foreground shrink-0"
title={t('clear_date_filter')}
onClick={() => setSearchInput('')}
className="absolute right-1.5 top-1/2 -translate-y-1/2 p-0.5 rounded-sm hover:bg-muted text-muted-foreground"
title={t('clear_search')}
aria-label={t('clear_search')}
>
<X className="h-4 w-4" />
<X className="h-3.5 w-3.5" />
</button>
)}
</div>
<Dialog open={filterOpen} onOpenChange={setFilterOpen}>
<DialogTrigger asChild>
<Button
variant="outline"
size="sm"
className="h-8 gap-2 text-xs shrink-0"
aria-label={
activeFilterCount > 0
? t('filter_with_count', { count: activeFilterCount })
: t('filter')
}
>
<SlidersHorizontal className="h-3.5 w-3.5" />
{t('filter')}
{activeFilterCount > 0 && (
<Badge
variant="secondary"
className="h-4 min-w-4 justify-center px-1 text-[10px] tabular-nums"
>
{activeFilterCount}
</Badge>
)}
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('filter_dialog_title')}</DialogTitle>
</DialogHeader>
<div className="space-y-4">
{/* Räkenskapsår */}
<div className="space-y-2">
<Label className="text-sm font-medium">{t('filter_section_period')}</Label>
<FiscalYearSelector
value={periodId}
onChange={handlePeriodChange}
label={null}
className="w-full"
/>
</div>
{/* Sortering */}
<div className="space-y-2">
<Label className="text-sm font-medium">{t('filter_section_sort')}</Label>
<Select
value={sortBy}
onValueChange={(v) => {
const next = v as SortBy
setSortBy(next)
setPage(0)
if (typeof window !== 'undefined') {
window.localStorage.setItem(SORT_STORAGE_KEY_PREFIX + (company?.id ?? 'default'), next)
}
}}
>
<SelectTrigger className="h-9 w-full text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="date_desc">{t('sort_date_desc')}</SelectItem>
<SelectItem value="date_asc">{t('sort_date_asc')}</SelectItem>
<SelectItem value="voucher_asc">{t('sort_voucher_asc')}</SelectItem>
<SelectItem value="voucher_desc">{t('sort_voucher_desc')}</SelectItem>
</SelectContent>
</Select>
</div>
{/* Verifikationsserie */}
<div className="space-y-2">
<Label className="text-sm font-medium">{t('filter_section_series')}</Label>
<Select value={seriesFilter} onValueChange={(v) => { setSeriesFilter(v); setPage(0) }}>
<SelectTrigger className="h-9 w-full text-sm font-mono" aria-label={t('filter_section_series')}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Alla serier</SelectItem>
{'ABCDEFG'.split('').map((letter) => (
<SelectItem key={letter} value={letter} className="font-mono">
Serie {letter}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Datumintervall */}
<div className="space-y-2">
<Label className="text-sm font-medium">{t('filter_section_date')}</Label>
<div className="flex items-center gap-2">
<Input
type="text"
placeholder={t('date_from_placeholder')}
value={dateFromInput}
onChange={(e) => setDateFromInput(e.target.value)}
onBlur={applyDateFilter}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
applyDateFilter()
}
}}
className="h-9 flex-1 text-sm"
/>
<span className="text-sm text-muted-foreground">–</span>
<Input
type="text"
placeholder={t('date_to_placeholder')}
value={dateToInput}
onChange={(e) => setDateToInput(e.target.value)}
onBlur={applyDateFilter}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
applyDateFilter()
}
}}
className="h-9 flex-1 text-sm"
/>
{(dateFrom || dateTo) && (
<button
type="button"
onClick={() => { setDateFrom(''); setDateTo(''); setDateFromInput(''); setDateToInput(''); setPage(0) }}
className="p-1 rounded-sm hover:bg-muted text-muted-foreground shrink-0"
title={t('clear_date_filter')}
aria-label={t('clear_date_filter')}
>
<X className="h-4 w-4" />
</button>
)}
</div>
</div>
{/* Visa saknade underlag */}
<div className="flex items-center gap-2">
<Switch
id="missing-attachments"
checked={showMissingOnly}
onCheckedChange={setShowMissingOnly}
/>
<Label htmlFor="missing-attachments" className="text-sm cursor-pointer">
{t('show_missing')}
</Label>
{showMissingOnly && (
<Badge variant="secondary" className="text-xs tabular-nums">
{filteredEntries.length}
</Badge>
)}
</div>
</div>
<DialogFooter className="sm:justify-between">
<Button
variant="ghost"
size="sm"
onClick={clearAllFilters}
disabled={activeFilterCount === 0}
>
{t('filter_clear_all')}
</Button>
<DialogClose asChild>
<Button variant="outline" size="sm">{t('filter_done')}</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
{/* Active fiscal-year scope — visible without opening the filter dialog so
the user always sees which räkenskapsår the ledger is scoped to (BFL
period-correctness). Clicking it opens the dialog to change the scope. */}
{periodHydrated && scopeLabel && (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span>{t('scope_label')}</span>
<button
type="button"
onClick={() => setFilterOpen(true)}
className="inline-flex h-8 items-center gap-1 rounded-md border border-border px-2 font-medium text-foreground transition-colors duration-150 hover:bg-secondary/60"
>
{scopeLabel}
{(activePeriod?.locked_at || activePeriod?.is_closed) && (
<Lock className="h-3 w-3 text-muted-foreground" />
)}
</button>
</div>
)}
{loading ? (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground mb-3" />
<p className="text-sm text-muted-foreground">{t('loading')}</p>
</CardContent>
</Card>
) : filteredEntries.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<div className="p-4 rounded-full bg-muted mb-4">
<Search className="h-6 w-6 text-muted-foreground" />
</div>
<h3 className="text-lg font-medium mb-1">{t('no_results_title')}</h3>
<p className="text-sm text-muted-foreground text-center max-w-sm">
{t('no_results_description')}
</p>
</CardContent>
</Card>
) : (
<div className="space-y-2">
{filteredEntries.map((entry) => {
const isExpanded = expandedId === entry.id
@@ -385,6 +640,33 @@ export default function JournalEntryList({ periodId }: Props) {
<JournalEntryStatusBadge entry={entry} showStatus={entry.status === 'reversed' || entry.status === 'draft'} />
)}
<span className="flex-1 truncate">{entry.description}</span>
<Button
asChild
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground transition-colors duration-150 hover:bg-secondary"
>
<span
role="button"
tabIndex={0}
aria-label={t('copy_voucher_tooltip')}
title={t('copy_voucher_tooltip')}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
router.push(`/bookkeeping?copy_from=${entry.id}`)
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
e.stopPropagation()
router.push(`/bookkeeping?copy_from=${entry.id}`)
}
}}
>
<Copy className="h-3.5 w-3.5" />
</span>
</Button>
{attachmentCounts[entry.id] ? (
<Button
asChild
@@ -461,6 +743,33 @@ export default function JournalEntryList({ periodId }: Props) {
<JournalEntryStatusBadge entry={entry} showStatus={entry.status === 'reversed' || entry.status === 'draft'} />
)}
<span className="ml-auto flex items-center gap-1">
<Button
asChild
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground transition-colors duration-150 hover:bg-secondary"
>
<span
role="button"
tabIndex={0}
aria-label={t('copy_voucher_tooltip')}
title={t('copy_voucher_tooltip')}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
router.push(`/bookkeeping?copy_from=${entry.id}`)
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
e.stopPropagation()
router.push(`/bookkeeping?copy_from=${entry.id}`)
}
}}
>
<Copy className="h-3.5 w-3.5" />
</span>
</Button>
{attachmentCounts[entry.id] ? (
<Button
asChild
@@ -638,6 +947,7 @@ export default function JournalEntryList({ periodId }: Props) {
)
})}
</div>
)}
{/* Correction dialog */}
{correctionEntry && (
+4 -2
View File
@@ -15,8 +15,10 @@ import { Lock } from 'lucide-react'
import { useCompany } from '@/contexts/CompanyContext'
import type { FiscalPeriod } from '@/types'
const STORAGE_KEY_PREFIX = 'Accounted:fiscal-year:'
const ALL_YEARS_VALUE = '__all__'
// Exported so other surfaces (e.g. JournalEntryList's filter dialog) can read
// and write the same persisted selection without duplicating the magic string.
export const STORAGE_KEY_PREFIX = 'Accounted:fiscal-year:'
export const ALL_YEARS_VALUE = '__all__'
interface Props {
/**
@@ -171,7 +171,7 @@ export function CompanyMembersSection() {
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleInvite} className="flex gap-3">
<form onSubmit={handleInvite} className="flex flex-col gap-3 sm:flex-row">
<div className="flex-1">
<Label htmlFor="company-invite-email" className="sr-only">{t('members_invite_email_label')}</Label>
<Input
@@ -185,7 +185,7 @@ export function CompanyMembersSection() {
/>
</div>
<Select value={inviteRole} onValueChange={setInviteRole}>
<SelectTrigger className="w-[140px]">
<SelectTrigger className="w-full sm:w-[140px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
+1 -1
View File
@@ -31,7 +31,7 @@ export function SettingsShell({ variant, activeSection, children }: SettingsShel
<aside className="hidden w-56 shrink-0 overflow-y-auto border-r border-border p-3 md:block">
<SettingsRail variant="modal" display="rail" activeId={activeSection} />
</aside>
<div className="min-h-0 flex-1 overflow-y-auto p-6">
<div className="min-h-0 min-w-0 flex-1 overflow-y-auto p-6">
<div className="mb-6 md:hidden">
<SettingsRail variant="modal" display="select" activeId={activeSection} />
</div>
@@ -25,6 +25,7 @@ import {
DropdownMenuSeparator,
} from '@/components/ui/dropdown-menu'
import { cn, formatCurrency, formatDate } from '@/lib/utils'
import { isImportedTransaction } from '@/lib/transactions/origin'
import { getCategoryDisplayName } from '@/lib/tax/expense-warnings'
import {
ArrowUpRight,
@@ -229,6 +230,9 @@ function BankHistoryRow({
const { canWrite } = useCanWrite()
const isIncome = transaction.amount > 0
const isBooked = !!transaction.journal_entry_id
// Only user-created rows are deletable; imported (bank sync / CSV) rows are
// ignore-only. Mirrors the server guard in DELETE /api/transactions/[id].
const canDelete = !isBooked && !isImportedTransaction(transaction)
const isLinkedToInvoice = !!transaction.invoice_id
const hasInvoiceMatch =
!isLinkedToInvoice && !!transaction.potential_invoice && !isBooked
@@ -315,7 +319,7 @@ function BankHistoryRow({
</Link>
</Button>
)}
{(hasInvoiceMatch || (!isBooked && onDelete) || (isBooked && canWrite)) && (
{(hasInvoiceMatch || (canDelete && onDelete) || (isBooked && canWrite)) && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
@@ -345,7 +349,7 @@ function BankHistoryRow({
)}
</CorrectionAffordance>
)}
{!isBooked && onDelete && (
{canDelete && onDelete && (
<>
{hasInvoiceMatch && <DropdownMenuSeparator />}
<DropdownMenuItem
@@ -15,6 +15,7 @@ import {
DataListMetaSeparator,
} from '@/components/ui/data-list'
import { cn, formatCurrency, formatDate } from '@/lib/utils'
import { isImportedTransaction } from '@/lib/transactions/origin'
import {
AlertCircle,
ArrowUpRight,
@@ -125,7 +126,13 @@ export default function TransactionInboxCard({
!!transaction.potential_supplier_invoice && !transaction.supplier_invoice_id
const isUncategorized = transaction.is_business === null && !transaction.journal_entry_id
const showCheckbox = isBatchMode && isUncategorized
const isDeletable = !transaction.journal_entry_id
// Unbooked rows are still actionable (match, split, edit, categorize) — that
// includes imported bank rows, which are the whole point of the inbox.
const isUnbooked = !transaction.journal_entry_id
// ...but only rows the USER created in the app may be deleted. Imported rows
// (bank sync / CSV) are ignore-only — mirrors the server guard in
// DELETE /api/transactions/[id]. See lib/transactions/origin.ts.
const canDelete = isUnbooked && !isImportedTransaction(transaction)
// 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 =
@@ -200,7 +207,7 @@ export default function TransactionInboxCard({
// Manual invoice-match affordance. Hidden once an auto-detected match is
// already shown as the primary button — having both makes the row noisy.
const showInvoiceMatchButton =
isDeletable && !hasInvoiceMatch && !hasSupplierInvoiceMatch
isUnbooked && !hasInvoiceMatch && !hasSupplierInvoiceMatch
const invoiceMatchLabel = isIncome
? 'Matcha mot kundfaktura'
@@ -216,10 +223,10 @@ export default function TransactionInboxCard({
// Available on any unbooked row (income or expense), independent of whether an
// invoice match was auto-detected: the user may want to point the bank line at
// an existing salary/Fortnox/manual voucher instead of confirming a payment.
const showMatchVoucherItem = isDeletable && !!onOpenMatchVoucher
const showMatchVoucherItem = isUnbooked && !!onOpenMatchVoucher
const showSplitItem = showInvoiceMatchButton && !!onOpenSplitMatch
const showEditItem = isTitleEditable && !!onEditTitle
const showDeleteItem = isDeletable && !!onDelete
const showDeleteItem = canDelete && !!onDelete
const showOverflowMenu = showMatchVoucherItem || showSplitItem || showEditItem || showDeleteItem
return (
@@ -0,0 +1,53 @@
import { describe, it, expect } from 'vitest'
import { escapeLikePattern, normalizeOcrReference } from '../duplicate-payment-guard'
describe('escapeLikePattern', () => {
// These cases lock in that a user-supplied needle reaches an ILIKE pattern with
// its LIKE metacharacters neutralised — each of `%`, `_`, `\` must match only
// itself and never expand as a wildcard (compliance A.8.28 / ASVS V1.2.5).
it('escapes a literal percent so it matches only itself', () => {
expect(escapeLikePattern('50% rabatt')).toBe('50\\% rabatt')
})
it('escapes a literal underscore so it is not a single-char wildcard', () => {
expect(escapeLikePattern('konto_1930')).toBe('konto\\_1930')
})
it('escapes a literal backslash so it does not consume the next char', () => {
expect(escapeLikePattern('a\\b')).toBe('a\\\\b')
})
it('escapes backslash, percent and underscore together without double-escaping', () => {
// Backslash is escaped FIRST, so the escapes added for % and _ are not
// themselves re-escaped. Each special char maps to exactly "\\" + itself.
expect(escapeLikePattern('a\\b%c_d')).toBe('a\\\\b\\%c\\_d')
})
it('leaves ordinary text untouched', () => {
expect(escapeLikePattern('Faktura 2026-0042')).toBe('Faktura 2026-0042')
})
it('caps the needle at 200 characters to bound DB work on oversized input', () => {
const escaped = escapeLikePattern('a'.repeat(250))
expect(escaped).toBe('a'.repeat(200))
expect(escaped.length).toBe(200)
})
it('truncates BEFORE escaping, so the source length is the bound', () => {
// 250 percent signs → truncated to 200 source chars, each escaped to "\%".
expect(escapeLikePattern('%'.repeat(250))).toBe('\\%'.repeat(200))
})
})
describe('normalizeOcrReference', () => {
it('keeps only digits regardless of separators', () => {
expect(normalizeOcrReference('2026-0042')).toBe('20260042')
expect(normalizeOcrReference('2026 / 0042')).toBe('20260042')
})
it('returns an empty string for nullish or empty input', () => {
expect(normalizeOcrReference(null)).toBe('')
expect(normalizeOcrReference(undefined)).toBe('')
expect(normalizeOcrReference('')).toBe('')
})
})
+52
View File
@@ -0,0 +1,52 @@
import { describe, it, expect } from 'vitest'
import { isImportedTransaction } from '@/lib/transactions/origin'
describe('isImportedTransaction', () => {
describe('imported (ignore-only, never deletable)', () => {
it('treats a row with a live bank connection as imported', () => {
expect(isImportedTransaction({ bank_connection_id: 'bc-1', import_source: null })).toBe(true)
})
it('treats a row with a bank connection as imported even if import_source looks in-app', () => {
// The bank link wins — a PSD2 row is imported regardless of the source tag.
expect(isImportedTransaction({ bank_connection_id: 'bc-1', import_source: 'manual' })).toBe(true)
})
it('treats Enable Banking sync rows as imported', () => {
expect(isImportedTransaction({ bank_connection_id: null, import_source: 'enable_banking' })).toBe(true)
})
it('treats CAMT053 bank-file imports as imported', () => {
expect(isImportedTransaction({ bank_connection_id: null, import_source: 'camt053' })).toBe(true)
})
it.each(['csv_nordea', 'csv_lunar', 'csv_seb'])(
'treats CSV bank-file import %s as imported',
(source) => {
expect(isImportedTransaction({ bank_connection_id: null, import_source: source })).toBe(true)
},
)
it('treats an unknown future import source as imported (safe default)', () => {
expect(isImportedTransaction({ bank_connection_id: null, import_source: 'some_new_feed' })).toBe(true)
})
})
describe('user-created (deletable when unbooked)', () => {
it('treats a null source with no bank link as user-created (manual add)', () => {
expect(isImportedTransaction({ bank_connection_id: null, import_source: null })).toBe(false)
})
it('treats create-from-document (manual) as user-created', () => {
expect(isImportedTransaction({ bank_connection_id: null, import_source: 'manual' })).toBe(false)
})
it('treats MCP/agent-created rows as user-created', () => {
expect(isImportedTransaction({ bank_connection_id: null, import_source: 'mcp' })).toBe(false)
})
it('tolerates omitted (undefined) origin fields', () => {
expect(isImportedTransaction({})).toBe(false)
})
})
})
+61
View File
@@ -0,0 +1,61 @@
/**
* Transaction origin helpers — distinguish rows the user created INSIDE the app
* from rows that were fetched/imported from an external feed (bank sync or a
* bank-file upload).
*
* Why this matters
* ----------------
* An imported row is an external system's record of money that actually moved.
* The user may *ignore* it (`is_ignored`) to take it off the to-book /
* reconciliation lists, but must never be able to *delete* it: deleting would
* silently drop a real bank line, and the next sync (or a re-import of the same
* file) would either bring it back as a "new" row or, worse, leave the books
* out of step with the bank. Only hand-entered rows are the user's to remove.
*
* The two import paths that populate `transactions` from outside the app:
* - Enable Banking (PSD2) live sync → sets `bank_connection_id`
* (and `import_source = 'enable_banking'`). See
* `extensions/general/enable-banking/lib/sync.ts`.
* - Bank-file import (CSV / CAMT053) → sets `import_source` to `'camt053'` or
* `'csv_<format>'` (no live connection). See
* `app/api/import/bank-file/execute/route.ts`.
*
* Everything else is user-created and therefore deletable (subject to the
* separate "booked rows are immutable" rule):
* - manual add via POST /api/transactions → `import_source = null`
* - create-from-document → `import_source = 'manual'`
* - MCP / agent create → `import_source = 'mcp'`
*
* Safe-by-default: this is an ALLOWLIST of known user-created sources. Any other
* `import_source` tag — including an import feed added in the future — is
* treated as imported (ignore-only), so a new feed can never accidentally
* become user-deletable before someone consciously adds it here.
*/
/** Minimal shape — the two columns that record where a transaction came from. */
export type TransactionOrigin = {
bank_connection_id?: string | null
import_source?: string | null
}
/**
* `import_source` values produced by in-app creation flows. A `null` source
* (with no bank connection) is also user-created — that's the plain manual-add
* path. Anything NOT in this set is considered an external import feed.
*/
const USER_CREATED_IMPORT_SOURCES: ReadonlySet<string> = new Set(['manual', 'mcp'])
/**
* True when the transaction was fetched via bank sync or uploaded via a
* bank-file import — i.e. NOT created by the user inside the app. Such rows are
* ignore-only and can never be deleted (booked or not).
*/
export function isImportedTransaction(tx: TransactionOrigin): boolean {
// A live bank connection is the unambiguous PSD2 marker (Enable Banking).
if (tx.bank_connection_id) return true
const src = tx.import_source
// No source and no bank link → a hand-entered row.
if (src == null) return false
// Known in-app sources are user-created; everything else is an import feed.
return !USER_CREATED_IMPORT_SOURCES.has(src)
}
+21
View File
@@ -2858,7 +2858,17 @@
"date_from_placeholder": "From YYYY-MM-DD",
"date_to_placeholder": "To YYYY-MM-DD",
"filter": "Filter",
"filter_with_count": "Filter ({count} active)",
"filter_dialog_title": "Filter journal entries",
"filter_clear_all": "Clear all filters",
"filter_done": "Done",
"filter_section_period": "Fiscal year",
"filter_section_sort": "Sort order",
"filter_section_series": "Voucher series",
"filter_section_date": "Date range",
"clear_date_filter": "Clear date filter",
"scope_label": "Showing:",
"scope_all_years": "All fiscal years",
"out_of_period_label": "Subsequent",
"out_of_period_tooltip": "Posted in a later fiscal year, but relates to the selected year (e.g. payment of an invoice issued in the selected year).",
"out_of_period_tooltip_mobile": "Posted in a later fiscal year, but relates to the selected year.",
@@ -2873,6 +2883,11 @@
"show_details": "Show details",
"create_correction": "Create correction entry",
"copy": "Copy",
"copy_voucher_tooltip": "Copy voucher",
"search_placeholder": "Search description...",
"clear_search": "Clear search",
"no_results_title": "No matches",
"no_results_description": "No journal entries match your filters. Adjust your search or clear the filters.",
"previous": "Previous",
"next": "Next",
"page_of": "Page {page} of {total}",
@@ -3067,6 +3082,12 @@
"save_draft": "Save as draft",
"review_and_create": "Review & create",
"save_draft_tooltip": "Saves as a draft without assigning a voucher number",
"clear_all": "Clear",
"clear_all_tooltip": "Clear all fields",
"clear_all_confirm_title": "Are you sure you want to clear everything?",
"clear_all_confirm_body": "Everything you've entered will be cleared and can't be restored.",
"clear_all_cancel": "Cancel",
"clear_all_confirm": "Clear everything",
"read_only_tooltip": "You have read-only access to this company",
"validation_description": "Enter a description",
"validation_period": "Select a fiscal period",
+21
View File
@@ -2858,7 +2858,17 @@
"date_from_placeholder": "Från YYYY-MM-DD",
"date_to_placeholder": "Till YYYY-MM-DD",
"filter": "Filtrera",
"filter_with_count": "Filtrera ({count} aktiva)",
"filter_dialog_title": "Filtrera verifikat",
"filter_clear_all": "Rensa alla filter",
"filter_done": "Klar",
"filter_section_period": "Räkenskapsår",
"filter_section_sort": "Sortering",
"filter_section_series": "Verifikationsserie",
"filter_section_date": "Datumintervall",
"clear_date_filter": "Rensa datumfilter",
"scope_label": "Visar:",
"scope_all_years": "Alla räkenskapsår",
"out_of_period_label": "Efterföljande",
"out_of_period_tooltip": "Bokförd i ett senare räkenskapsår, men avser det valda året (t.ex. betalning av en faktura utställd i det valda året).",
"out_of_period_tooltip_mobile": "Bokförd i ett senare räkenskapsår, men avser det valda året.",
@@ -2873,6 +2883,11 @@
"show_details": "Visa detaljer",
"create_correction": "Skapa ändringsverifikation",
"copy": "Kopiera",
"copy_voucher_tooltip": "Kopiera verifikat",
"search_placeholder": "Sök verifikationstext...",
"clear_search": "Rensa sökning",
"no_results_title": "Inga träffar",
"no_results_description": "Inga verifikationer matchar dina filter. Justera sökningen eller rensa filtren.",
"previous": "Föregående",
"next": "Nästa",
"page_of": "Sida {page} av {total}",
@@ -3067,6 +3082,12 @@
"save_draft": "Spara som utkast",
"review_and_create": "Granska & skapa",
"save_draft_tooltip": "Sparar som utkast utan att tilldela verifikationsnummer",
"clear_all": "Rensa",
"clear_all_tooltip": "Rensa alla fält",
"clear_all_confirm_title": "Är du säker på att du vill rensa allt?",
"clear_all_confirm_body": "Det du har fyllt i rensas och kan inte återställas.",
"clear_all_cancel": "Avbryt",
"clear_all_confirm": "Rensa allt",
"read_only_tooltip": "Du har endast läsbehörighet i detta företag",
"validation_description": "Ange en beskrivning",
"validation_period": "Välj en räkenskapsperiod",