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:
co-authored by
Claude Opus 4.8
parent
679b154ad2
commit
64991eb3c9
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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' } })
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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])
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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('')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user