diff --git a/app/(dashboard)/bookkeeping/[id]/page.tsx b/app/(dashboard)/bookkeeping/[id]/page.tsx index 11d298d2..d83a9c43 100644 --- a/app/(dashboard)/bookkeeping/[id]/page.tsx +++ b/app/(dashboard)/bookkeeping/[id]/page.tsx @@ -8,13 +8,20 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Button } from '@/components/ui/button' import { AccountNumber } from '@/components/ui/account-number' import { Textarea } from '@/components/ui/textarea' -import { Loader2, ArrowLeft, Paperclip, AlertTriangle, Lock, MessageSquare, Pencil, Check, X, Copy } from 'lucide-react' +import { Loader2, ArrowLeft, Paperclip, AlertTriangle, Lock, MessageSquare, Pencil, Check, X, Copy, ChevronDown, CalendarClock } from 'lucide-react' +import { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, +} from '@/components/ui/dropdown-menu' import { useCanWrite } from '@/lib/hooks/use-can-write' import { formatDate } from '@/lib/utils' import { formatVoucher } from '@/lib/bookkeeping/voucher-series-resolver' import JournalEntryAttachments from '@/components/bookkeeping/JournalEntryAttachments' import JournalEntryStatusBadge, { useSourceTypeLabels } from '@/components/bookkeeping/JournalEntryStatusBadge' import CorrectionEntryDialog from '@/components/bookkeeping/CorrectionEntryDialog' +import RecordateEntryDialog from '@/components/bookkeeping/RecordateEntryDialog' import CorrectionChain from '@/components/bookkeeping/CorrectionChain' import { ConfirmationDialog } from '@/components/ui/confirmation-dialog' import { useToast } from '@/components/ui/use-toast' @@ -33,6 +40,7 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i const [isLoading, setIsLoading] = useState(true) const [error, setError] = useState(null) const [showCorrection, setShowCorrection] = useState(false) + const [showRecordate, setShowRecordate] = useState(false) const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) const [isDeleting, setIsDeleting] = useState(false) const [isCommitting, setIsCommitting] = useState(false) @@ -238,17 +246,31 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i )} {canCorrect && ( - + + + + + + setShowCorrection(true)}> + + {t('correct_lines')} + + setShowRecordate(true)}> + + {t('correct_date')} + + + )} {entry.status === 'posted' && ( + + )} @@ -1678,6 +1733,14 @@ export default function NewSupplierInvoicePage() { + + ) } diff --git a/app/(dashboard)/transactions/page.tsx b/app/(dashboard)/transactions/page.tsx index af8afff2..8bfd6c00 100644 --- a/app/(dashboard)/transactions/page.tsx +++ b/app/(dashboard)/transactions/page.tsx @@ -39,6 +39,7 @@ import MatchAllocationDialog from '@/components/transactions/MatchAllocationDial import BulkBookDialog from '@/components/transactions/BulkBookDialog' import TransactionBookingDialog from '@/components/transactions/TransactionBookingDialog' import QuickReviewDialog from '@/components/transactions/QuickReviewDialog' +import EditTransactionTitleDialog from '@/components/transactions/EditTransactionTitleDialog' import TemplatePicker from '@/components/transactions/TemplatePicker' import { getDefaultAccountForCategory, getDefaultVatTreatmentForCategory } from '@/lib/bookkeeping/category-mapping' @@ -196,6 +197,8 @@ export default function TransactionsPage() { const { toast } = useToast() const { dialogProps: deleteDialogProps, confirm: confirmDelete } = useDestructiveConfirm() + // Bank transaction whose title is being edited (null = dialog closed). + const [editTitleTarget, setEditTitleTarget] = useState(null) const supabase = createClient() const searchParams = useSearchParams() const highlightId = searchParams.get('highlight') @@ -1236,6 +1239,46 @@ export default function TransactionsPage() { } } + function openEditTitleDialog(transaction: TransactionWithInvoice) { + setEditTitleTarget(transaction) + } + + // Persist a new title via PATCH. Returns true on success so the dialog can + // close; updates the local list optimistically (description + edited tag). + async function handleSaveTitle(description: string): Promise { + const target = editTitleTarget + if (!target) return false + try { + const response = await fetch(`/api/transactions/${target.id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ description }), + }) + const result = await response.json() + if (!response.ok) { + toast({ + title: t('edit_title_failed'), + description: getErrorMessage(result, { context: 'transaction' }), + variant: 'destructive', + }) + return false + } + const updated = result.data as { description: string; title_edited_at: string | null } + setTransactions((prev) => + prev.map((tx) => + tx.id === target.id + ? { ...tx, description: updated.description, title_edited_at: updated.title_edited_at } + : tx, + ), + ) + toast({ title: t('edit_title_saved') }) + return true + } catch { + toast({ title: t('edit_title_failed'), variant: 'destructive' }) + return false + } + } + async function handleSkvBokfor(row: StoredSkattekontoTransaction) { setSkvProcessingId(row.id) try { @@ -1702,6 +1745,7 @@ export default function TransactionsPage() { onOpenSplitMatch={openSplitMatchDialog} onOpenCategoryDialog={openCategoryDialog} onDelete={handleDeleteTransaction} + onEditTitle={openEditTitleDialog} onToggleSelect={toggleBatchSelect} /> ) : ( @@ -1985,6 +2029,16 @@ export default function TransactionsPage() { + { + if (!v) setEditTitleTarget(null) + }} + currentTitle={editTitleTarget?.description ?? ''} + originalTitle={editTitleTarget?.original_description ?? null} + onSave={handleSaveTitle} + /> + " before a + * write is attempted. Mirrors resolvePeriodStatusForDate / the DB triggers. + */ +export async function GET(request: Request) { + const supabase = await createClient() + const { data: { user } } = await supabase.auth.getUser() + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const date = new URL(request.url).searchParams.get('date') + if (!date || !/^\d{4}-\d{2}-\d{2}$/.test(date)) { + return NextResponse.json({ error: 'Ogiltigt datum (förväntat ÅÅÅÅ-MM-DD)' }, { status: 400 }) + } + + const companyId = await requireCompanyId(supabase, user.id) + + try { + const status = await resolvePeriodStatusForDate(supabase, companyId, date) + + let period_name: string | null = null + if (status.period_id) { + const { data: period } = await supabase + .from('fiscal_periods') + .select('name') + .eq('id', status.period_id) + .eq('company_id', companyId) + .maybeSingle() + period_name = period?.name ?? null + } + + return NextResponse.json({ + data: { + status: status.status, + period_id: status.period_id, + lock_date: status.lock_date, + period_name, + }, + }) + } catch (err) { + return NextResponse.json( + { + error: { + code: 'PERIOD_STATUS_ERROR', + message: err instanceof Error ? err.message : 'Kunde inte hämta periodstatus', + }, + }, + { status: 500 } + ) + } +} diff --git a/app/api/bookkeeping/journal-entries/[id]/recordate/__tests__/route.test.ts b/app/api/bookkeeping/journal-entries/[id]/recordate/__tests__/route.test.ts new file mode 100644 index 00000000..d9716ce8 --- /dev/null +++ b/app/api/bookkeeping/journal-entries/[id]/recordate/__tests__/route.test.ts @@ -0,0 +1,142 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { + createMockRequest, + parseJsonResponse, + createMockRouteParams, + makeJournalEntry, +} from '@/tests/helpers' +import { TargetPeriodLockedError, MeaninglessCorrectionError } from '@/lib/bookkeeping/errors' + +const mockCreateClient = vi.fn() +vi.mock('@/lib/supabase/server', () => ({ + createClient: () => mockCreateClient(), +})) + +vi.mock('@/lib/init', () => ({ + ensureInitialized: vi.fn(), +})) + +vi.mock('@/lib/company/context', () => ({ + requireCompanyId: vi.fn().mockResolvedValue('company-1'), + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), +})) + +vi.mock('@/lib/auth/require-write', () => ({ + requireWritePermission: vi.fn().mockResolvedValue({ ok: true }), +})) + +const mockRecordateEntry = vi.fn() +vi.mock('@/lib/core/bookkeeping/storno-service', () => ({ + recordateEntry: (...args: unknown[]) => mockRecordateEntry(...args), +})) + +import { POST } from '../route' + +describe('POST /api/bookkeeping/journal-entries/[id]/recordate', () => { + const mockUser = { id: 'user-1', email: 'test@test.se' } + + beforeEach(() => { + vi.clearAllMocks() + mockCreateClient.mockResolvedValue({ + auth: { getUser: vi.fn().mockResolvedValue({ data: { user: mockUser } }) }, + }) + }) + + it('returns 401 when not authenticated', async () => { + mockCreateClient.mockResolvedValue({ + auth: { getUser: vi.fn().mockResolvedValue({ data: { user: null } }) }, + }) + + const request = createMockRequest('/api/bookkeeping/journal-entries/entry-1/recordate', { + method: 'POST', + body: { new_entry_date: '2025-07-03' }, + }) + const response = await POST(request, createMockRouteParams({ id: 'entry-1' })) + const { status, body } = await parseJsonResponse(response) + + expect(status).toBe(401) + expect(body).toEqual({ error: 'Unauthorized' }) + }) + + it('returns 400 when new_entry_date is missing', async () => { + const request = createMockRequest('/api/bookkeeping/journal-entries/entry-1/recordate', { + method: 'POST', + body: {}, + }) + const response = await POST(request, createMockRouteParams({ id: 'entry-1' })) + const { status, body } = await parseJsonResponse<{ error: string }>(response) + + expect(status).toBe(400) + expect(body.error).toBe('Validation failed') + }) + + it('returns 400 when new_entry_date is not an ISO date', async () => { + const request = createMockRequest('/api/bookkeeping/journal-entries/entry-1/recordate', { + method: 'POST', + body: { new_entry_date: '03/07/2025' }, + }) + const response = await POST(request, createMockRouteParams({ id: 'entry-1' })) + const { status, body } = await parseJsonResponse<{ error: string }>(response) + + expect(status).toBe(400) + expect(body.error).toBe('Validation failed') + }) + + it('returns reversal and corrected entries on success', async () => { + const reversal = makeJournalEntry({ id: 'reversal-1', reverses_id: 'entry-1', source_type: 'storno' }) + const corrected = makeJournalEntry({ + id: 'corrected-1', + correction_of_id: 'entry-1', + source_type: 'correction', + entry_date: '2025-07-03', + }) + mockRecordateEntry.mockResolvedValue({ reversal, corrected }) + + const request = createMockRequest('/api/bookkeeping/journal-entries/entry-1/recordate', { + method: 'POST', + body: { new_entry_date: '2025-07-03' }, + }) + const response = await POST(request, createMockRouteParams({ id: 'entry-1' })) + const { status, body } = await parseJsonResponse<{ data: { reversal: unknown; corrected: unknown } }>(response) + + expect(status).toBe(200) + expect(body.data.corrected).toEqual(corrected) + expect(mockRecordateEntry).toHaveBeenCalledWith( + expect.anything(), + 'company-1', + 'user-1', + 'entry-1', + '2025-07-03' + ) + }) + + it('maps a no-op move (same date) to a 400 with the typed reason', async () => { + mockRecordateEntry.mockRejectedValue(new MeaninglessCorrectionError('no_date_change')) + + const request = createMockRequest('/api/bookkeeping/journal-entries/entry-1/recordate', { + method: 'POST', + body: { new_entry_date: '2026-07-03' }, + }) + const response = await POST(request, createMockRouteParams({ id: 'entry-1' })) + const { status, body } = await parseJsonResponse<{ error: { code: string; details: { reason: string } } }>(response) + + expect(status).toBe(400) + expect(body.error.code).toBe('MEANINGLESS_CORRECTION') + expect(body.error.details.reason).toBe('no_date_change') + }) + + it('maps a locked target period to a 409 with the typed code', async () => { + mockRecordateEntry.mockRejectedValue(new TargetPeriodLockedError('2025-07-03', '2025-12-31')) + + const request = createMockRequest('/api/bookkeeping/journal-entries/entry-1/recordate', { + method: 'POST', + body: { new_entry_date: '2025-07-03' }, + }) + const response = await POST(request, createMockRouteParams({ id: 'entry-1' })) + const { status, body } = await parseJsonResponse<{ error: { code: string; details: { lockDate: string } } }>(response) + + expect(status).toBe(409) + expect(body.error.code).toBe('TARGET_PERIOD_LOCKED') + expect(body.error.details.lockDate).toBe('2025-12-31') + }) +}) diff --git a/app/api/bookkeeping/journal-entries/[id]/recordate/route.ts b/app/api/bookkeeping/journal-entries/[id]/recordate/route.ts new file mode 100644 index 00000000..fc7d3a89 --- /dev/null +++ b/app/api/bookkeeping/journal-entries/[id]/recordate/route.ts @@ -0,0 +1,47 @@ +import { createClient } from '@/lib/supabase/server' +import { NextResponse } from 'next/server' +import { recordateEntry } from '@/lib/core/bookkeeping/storno-service' +import { bookkeepingErrorResponse } from '@/lib/bookkeeping/errors' +import { ensureInitialized } from '@/lib/init' +import { validateBody } from '@/lib/api/validate' +import { RecordateJournalEntrySchema } from '@/lib/api/schemas' +import { requireCompanyId } from '@/lib/company/context' +import { requireWritePermission } from '@/lib/auth/require-write' + +ensureInitialized() + +export async function POST( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const { id } = await params + const supabase = await createClient() + const { data: { user } } = await supabase.auth.getUser() + + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const writeCheck = await requireWritePermission(supabase, user.id) + if (!writeCheck.ok) return writeCheck.response + + const companyId = await requireCompanyId(supabase, user.id) + + const validation = await validateBody(request, RecordateJournalEntrySchema) + if (!validation.success) return validation.response + const body = validation.data + + try { + const result = await recordateEntry(supabase, companyId, user.id, id, body.new_entry_date) + return NextResponse.json({ data: result }) + } catch (err) { + const typed = bookkeepingErrorResponse(err) + if (typed) return typed + // Not a recognized domain error — an unexpected server fault, not a client + // error, so surface it as 500. + return NextResponse.json( + { error: err instanceof Error ? err.message : 'Failed to move entry' }, + { status: 500 } + ) + } +} diff --git a/app/api/settings/booking-templates/route.ts b/app/api/settings/booking-templates/route.ts index 3fa5814c..c8c68aa2 100644 --- a/app/api/settings/booking-templates/route.ts +++ b/app/api/settings/booking-templates/route.ts @@ -5,6 +5,12 @@ import { requireWritePermission } from '@/lib/auth/require-write' import { z } from 'zod' import { validateBody } from '@/lib/api/validate' +// The GET scope below builds a PostgREST .or() filter by string interpolation. +// Guard every interpolated id against a strict UUID shape so a tainted value +// can never inject filter syntax. Both ids are server-derived (companyId from +// membership, teamId from a DB column), so this is defense-in-depth. +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i + const BookingTemplateLineSchema = z.object({ account: z.string().regex(/^\d{4}$/), label: z.string().min(1), @@ -43,12 +49,39 @@ export async function GET() { const companyId = await requireCompanyId(supabase, user.id) - // RLS handles scoping (system OR company OR team) + // Resolve the team this company belongs to (if any) so team-shared + // templates stay visible while this company is selected. + const { data: company } = await supabase + .from('companies') + .select('team_id') + .eq('id', companyId) + .maybeSingle() + const teamId = company?.team_id ?? null + + // requireCompanyId only ever returns a real membership UUID, but assert the + // shape before interpolating it into the .or() filter. + if (!UUID_RE.test(companyId)) { + return NextResponse.json({ error: 'Invalid company context' }, { status: 400 }) + } + + // Scope to the SELECTED company: system + this company + this company's team. + // RLS (btl_select) is membership-wide — it returns templates from *every* + // company the user belongs to — so the active-company narrowing must happen + // here in the API layer (mirrors counterparty-templates). Without this, a + // user who owns several companies sees all of their templates merged. + // Only interpolate a team id that passes the strict UUID guard. + const scope = [ + 'is_system.eq.true', + `company_id.eq.${companyId}`, + ...(teamId && UUID_RE.test(teamId) ? [`team_id.eq.${teamId}`] : []), + ].join(',') + const [templatesRes, usageRes] = await Promise.all([ supabase .from('booking_template_library') .select('*') .eq('is_active', true) + .or(scope) .order('category') .order('name'), supabase diff --git a/app/api/supplier-invoices/__tests__/route.test.ts b/app/api/supplier-invoices/__tests__/route.test.ts index 20231d37..020f7d31 100644 --- a/app/api/supplier-invoices/__tests__/route.test.ts +++ b/app/api/supplier-invoices/__tests__/route.test.ts @@ -305,6 +305,45 @@ describe('POST /api/supplier-invoices', () => { expect((body.error as unknown as { code: string }).code).toBe('SI_CREATE_FAILED') }) + it('rolls back and returns SI_CREATE_NO_FISCAL_PERIOD when invoice_date is outside every fiscal period', async () => { + const supplier = makeSupplier({ id: VALID_UUID }) + const createdInvoice = makeSupplierInvoice({ id: 'si-1', invoice_date: '2099-06-01' }) + + // Fetch supplier + enqueue({ data: supplier, error: null }) + // RPC get_next_arrival_number + enqueue({ data: 9 }) + // Insert invoice + enqueue({ data: createdInvoice, error: null }) + // Insert items + enqueue({ data: null, error: null }) + // Fetch company settings → accrual, so a registration JE is attempted + enqueue({ data: { accounting_method: 'accrual' }, error: null }) + // Engine returns null because no fiscal period covers 2099-06-01 + mockCreateSupplierInvoiceRegistrationEntry.mockResolvedValue(null) + // Rollback: delete the orphan invoice (items cascade) + enqueue({ data: null, error: null }) + + const request = createMockRequest('/api/supplier-invoices', { + method: 'POST', + body: { + supplier_id: VALID_UUID, + supplier_invoice_number: 'LF-NOFY', + invoice_date: '2099-06-01', + due_date: '2099-07-01', + items: [{ description: 'Material', quantity: 1, unit_price: 8000, account_number: '4010' }], + }, + }) + const response = await POST(request) + const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response) + + expect(status).toBe(400) + expect(body.error.code).toBe('SI_CREATE_NO_FISCAL_PERIOD') + expect(mockCreateSupplierInvoiceRegistrationEntry).toHaveBeenCalled() + // The orphan must be rolled back — the delete is the 6th queued call. + expect(mockSupabase.from).toHaveBeenCalledWith('supplier_invoices') + }) + it('returns 409 with credit chain on duplicate supplier_invoice_number for credited original', async () => { const supplier = makeSupplier({ id: VALID_UUID }) diff --git a/app/api/supplier-invoices/route.ts b/app/api/supplier-invoices/route.ts index 7dc867d5..c55bc149 100644 --- a/app/api/supplier-invoices/route.ts +++ b/app/api/supplier-invoices/route.ts @@ -329,6 +329,17 @@ export const POST = withRouteContext( journal_entry_id: journalEntry.id, notes: 'Eget utlägg — betalat privat', }) + } else { + // createSupplierInvoicePrivatelyPaidEntry returns null ONLY when no + // fiscal period covers invoice_date (every other failure throws and + // lands in the catch below). Without this branch the invoice would be + // saved as status='paid' with no verifikat — a silent orphan. Roll + // back and surface an actionable error, per the fatal-orphan note above. + await supabase.from('supplier_invoices').delete().eq('id', invoice.id).eq('company_id', companyId) + return errorResponseFromCode('SI_CREATE_NO_FISCAL_PERIOD', log, { + requestId, + details: { invoiceDate: invoice.invoice_date }, + }) } } catch (err) { await supabase.from('supplier_invoices').delete().eq('id', invoice.id).eq('company_id', companyId) @@ -363,6 +374,19 @@ export const POST = withRouteContext( .from('supplier_invoices') .update({ registration_journal_entry_id: journalEntry.id }) .eq('id', invoice.id) + } else { + // createSupplierInvoiceRegistrationEntry returns null ONLY when no + // fiscal period covers invoice_date (every other failure throws and + // lands in the catch below). An orphan supplier_invoices row without a + // registration JE silently understates leverantörsskuld (2440) and + // ingående moms (2641) for the momsdeklaration — exactly the fatal + // case the note above warns about. Roll back and surface an + // actionable error instead of returning 200. + await supabase.from('supplier_invoices').delete().eq('id', invoice.id).eq('company_id', companyId) + return errorResponseFromCode('SI_CREATE_NO_FISCAL_PERIOD', log, { + requestId, + details: { invoiceDate: invoice.invoice_date }, + }) } } catch (err) { await supabase.from('supplier_invoices').delete().eq('id', invoice.id).eq('company_id', companyId) diff --git a/app/api/transactions/[id]/__tests__/route.test.ts b/app/api/transactions/[id]/__tests__/route.test.ts index 421569c1..1dbef665 100644 --- a/app/api/transactions/[id]/__tests__/route.test.ts +++ b/app/api/transactions/[id]/__tests__/route.test.ts @@ -20,7 +20,19 @@ vi.mock('@/lib/auth/require-write', () => ({ requireWritePermission: vi.fn().mockResolvedValue({ ok: true }), })) -import { DELETE } from '../route' +// PATCH (edit title) goes through withRouteContext → requireAuth. +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: vi.fn(), +})) + +vi.mock('@/lib/sandbox/guard', () => ({ + guardSandbox: vi.fn(), +})) + +import { DELETE, PATCH } from '../route' +import { requireAuth } from '@/lib/auth/require-auth' +import { guardSandbox } from '@/lib/sandbox/guard' +import { NextResponse } from 'next/server' describe('DELETE /api/transactions/[id]', () => { const mockUser = { id: 'user-1', email: 'test@test.se' } @@ -117,3 +129,158 @@ describe('DELETE /api/transactions/[id]', () => { expect(body).toEqual({ error: 'Failed to delete transaction' }) }) }) + +describe('PATCH /api/transactions/[id] (edit title)', () => { + const mockUser = { id: 'user-1', email: 'test@test.se' } + + function patchReq(body: unknown) { + return new Request('http://localhost/api/transactions/tx-1', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + } + + beforeEach(() => { + vi.clearAllMocks() + reset() + vi.mocked(requireAuth).mockResolvedValue({ + user: mockUser as never, + supabase: mockSupabase as never, + error: null, + }) + vi.mocked(guardSandbox).mockResolvedValue(null) + }) + + it('returns 401 when not authenticated', async () => { + vi.mocked(requireAuth).mockResolvedValue({ + user: null as never, + supabase: mockSupabase as never, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + + const res = await PATCH(patchReq({ description: 'Ny titel' }), createMockRouteParams({ id: 'tx-1' })) + const { status } = await parseJsonResponse(res) + expect(status).toBe(401) + }) + + it('returns 400 when the title is empty / whitespace-only', async () => { + const res = await PATCH(patchReq({ description: ' ' }), createMockRouteParams({ id: 'tx-1' })) + const { status } = await parseJsonResponse(res) + expect(status).toBe(400) + }) + + it('returns 404 when the transaction is not found', async () => { + enqueue({ data: null, error: { message: 'Not found' } }) + + const res = await PATCH(patchReq({ description: 'Ny titel' }), createMockRouteParams({ id: 'tx-1' })) + const { status } = await parseJsonResponse(res) + expect(status).toBe(404) + }) + + it('returns 409 when the transaction is booked', async () => { + enqueue({ + data: { + id: 'tx-1', + description: 'X', + original_description: 'X', + journal_entry_id: 'je-1', + invoice_id: null, + supplier_invoice_id: null, + }, + error: null, + }) + + const res = await PATCH(patchReq({ description: 'Ny titel' }), createMockRouteParams({ id: 'tx-1' })) + const { status, body } = await parseJsonResponse<{ error: { code: string } }>(res) + expect(status).toBe(409) + expect(body.error.code).toBe('TRANSACTION_TITLE_LOCKED') + }) + + it('returns 409 when matched to an invoice even if journal_entry_id is null', async () => { + enqueue({ + data: { + id: 'tx-1', + description: 'X', + original_description: 'X', + journal_entry_id: null, + invoice_id: 'inv-1', + supplier_invoice_id: null, + }, + error: null, + }) + + const res = await PATCH(patchReq({ description: 'Ny titel' }), createMockRouteParams({ id: 'tx-1' })) + const { status } = await parseJsonResponse(res) + expect(status).toBe(409) + }) + + it('updates the title for an editable (unbooked, unmatched) transaction', async () => { + enqueue({ + data: { + id: 'tx-1', + description: 'ICA', + original_description: 'ICA', + journal_entry_id: null, + invoice_id: null, + supplier_invoice_id: null, + }, + error: null, + }) // fetch + enqueue({ + data: { id: 'tx-1', description: 'Lunch med kund', title_edited_at: '2026-06-01T10:00:00Z' }, + error: null, + }) // update + + const res = await PATCH( + patchReq({ description: 'Lunch med kund' }), + createMockRouteParams({ id: 'tx-1' }), + ) + const { status, body } = await parseJsonResponse<{ data: { description: string } }>(res) + expect(status).toBe(200) + expect(body.data.description).toBe('Lunch med kund') + }) + + it('restores the original title (200) when the new title equals original_description', async () => { + enqueue({ + data: { + id: 'tx-1', + description: 'Lunch med kund', + original_description: 'ICA MAXI', + journal_entry_id: null, + invoice_id: null, + supplier_invoice_id: null, + }, + error: null, + }) // fetch + enqueue({ + data: { id: 'tx-1', description: 'ICA MAXI', title_edited_at: null }, + error: null, + }) // update + + const res = await PATCH(patchReq({ description: 'ICA MAXI' }), createMockRouteParams({ id: 'tx-1' })) + const { status, body } = await parseJsonResponse<{ data: { title_edited_at: string | null } }>(res) + expect(status).toBe(200) + expect(body.data.title_edited_at).toBeNull() + }) + + it('returns 409 when the row is matched/booked between read and write (optimistic-lock miss)', async () => { + enqueue({ + data: { + id: 'tx-1', + description: 'ICA', + original_description: 'ICA', + journal_entry_id: null, + invoice_id: null, + supplier_invoice_id: null, + }, + error: null, + }) // fetch passes the read gate + enqueue({ data: null, error: null }) // UPDATE affects 0 rows (gate re-assert failed) + + const res = await PATCH(patchReq({ description: 'Ny titel' }), createMockRouteParams({ id: 'tx-1' })) + const { status, body } = await parseJsonResponse<{ error: { code: string } }>(res) + expect(status).toBe(409) + expect(body.error.code).toBe('TRANSACTION_TITLE_LOCKED') + }) +}) diff --git a/app/api/transactions/[id]/route.ts b/app/api/transactions/[id]/route.ts index 408b773c..f1b608c7 100644 --- a/app/api/transactions/[id]/route.ts +++ b/app/api/transactions/[id]/route.ts @@ -2,6 +2,12 @@ import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { requireCompanyId } from '@/lib/company/context' import { requireWritePermission } from '@/lib/auth/require-write' +import { withRouteContext } from '@/lib/api/with-route-context' +import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error' +import { validateBody } from '@/lib/api/validate' +import { UpdateTransactionTitleSchema } from '@/lib/api/schemas' +import { guardSandbox } from '@/lib/sandbox/guard' +import type { Transaction } from '@/types' export async function DELETE( _request: Request, @@ -52,3 +58,101 @@ export async function DELETE( return NextResponse.json({ success: true }) } + +/** + * Edit a bank transaction's title (description). + * + * Legal under BFL only while the row is a mutable staging label — i.e. NOT yet + * booked into a verifikat and NOT confirmed-matched to an invoice. Once booked + * the description is räkenskapsinformation and corrections go through storno + * (reverseEntry/correctEntry), so this route hard-blocks those rows. The bank's + * original title is preserved immutably in original_description (set at ingest) + * and is never written here; passing it back restores the "not edited" tag. + */ +export const PATCH = withRouteContext( + 'transaction.updateTitle', + async (request, ctx, { params }: { params: Promise<{ id: string }> }) => { + const { id } = await params + const { supabase, companyId, log, requestId, user } = ctx + + const blocked = await guardSandbox(supabase, companyId) + if (blocked) return blocked + + const validation = await validateBody(request, UpdateTransactionTitleSchema, { + log, + operation: 'transaction.updateTitle', + }) + if (!validation.success) return validation.response + const { description } = validation.data + + const { data: transaction, error: fetchError } = await supabase + .from('transactions') + .select('id, description, original_description, journal_entry_id, invoice_id, supplier_invoice_id') + .eq('id', id) + .eq('company_id', companyId) + .single() + + if (fetchError || !transaction) { + return errorResponseFromCode('TX_CATEGORIZE_TX_NOT_FOUND', log, { requestId }) + } + + // Gate: editable only when neither booked nor confirmed-matched. (A + // confirmed invoice/supplier-invoice match also sets journal_entry_id, but + // we check all three for defense-in-depth.) An unbooked row has no fiscal + // period, so the period-lock requirement is satisfied implicitly. + if (transaction.journal_entry_id || transaction.invoice_id || transaction.supplier_invoice_id) { + return errorResponseFromCode('TRANSACTION_TITLE_LOCKED', log, { requestId }) + } + + // Restoring to the bank original clears the "edited" tag; any other value + // marks the title as user-edited. Compare against the TRIMMED original (the + // incoming description is already trimmed by the schema) so a legacy + // original carrying surrounding whitespace still restores cleanly. + const isRestore = + transaction.original_description != null && + description === transaction.original_description.trim() + const titleEditedAt = isRestore ? null : new Date().toISOString() + + const { data: updated, error: updateError } = await supabase + .from('transactions') + .update({ description, title_edited_at: titleEditedAt }) + .eq('id', id) + .eq('company_id', companyId) + // Re-assert the FULL editable gate atomically against a concurrent book + // or auto-match. Ingest's supplier auto-match can set supplier_invoice_id + // WITHOUT journal_entry_id, so guarding journal_entry_id alone leaves a + // narrow TOCTOU window — mirror the read-time gate here. + .is('journal_entry_id', null) + .is('invoice_id', null) + .is('supplier_invoice_id', null) + // Return only what the client renders (data minimisation — the row also + // carries company_id and other internal fields the caller doesn't need). + .select('id, description, title_edited_at') + .maybeSingle>() + + if (updateError) { + return errorResponse(updateError, log, { requestId }) + } + if (!updated) { + // 0 rows updated → the row was booked/matched between read and write. + return errorResponseFromCode('TRANSACTION_TITLE_LOCKED', log, { requestId }) + } + + // Behandlingshistorik (BFNAR 2013:2 kap 8) — light-touch for a pre-verifikat + // working label; updated_at (trigger) captures "when". We deliberately do + // NOT log the description text: a bank label can carry PII (payee names, + // reference numbers). The before-value stays recoverable in + // original_description and the after-value is the row's current + // description, so the log only needs to record that/which way it changed. + log.info('transaction title edited', { + transactionId: id, + actor: user.id, + restored: isRestore, + previousLength: transaction.description?.length ?? 0, + newLength: description.length, + }) + + return NextResponse.json({ data: updated }) + }, + { requireWrite: true }, +) diff --git a/app/api/v1/companies/[companyId]/supplier-invoices/__tests__/route.test.ts b/app/api/v1/companies/[companyId]/supplier-invoices/__tests__/route.test.ts index b2ceafbe..66939a1c 100644 --- a/app/api/v1/companies/[companyId]/supplier-invoices/__tests__/route.test.ts +++ b/app/api/v1/companies/[companyId]/supplier-invoices/__tests__/route.test.ts @@ -365,6 +365,32 @@ describe('POST /api/v1/companies/:companyId/supplier-invoices', () => { expect(body.error.details.step).toBe('registration_journal_entry') }) + it('rolls back SI row and returns SI_CREATE_NO_FISCAL_PERIOD when no period covers invoice_date', async () => { + // Engine returns null (not a throw) when no fiscal period covers the date. + mockedReg.mockResolvedValueOnce(null) + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + suppliers: { data: SAMPLE_SUPPLIER, error: null }, + company_settings: { data: { accounting_method: 'accrual' }, error: null }, + fiscal_periods: { data: { id: 'fp-1', is_closed: false, locked_at: null }, error: null }, + supplier_invoices: { data: SAMPLE_SI, error: null }, + supplier_invoice_items: { data: null, error: null }, + idempotency_keys: { data: null, error: null }, + }), + ) + const res = await createSI( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/supplier-invoices`, { + method: 'POST', + body: JSON.stringify(validBody), + }), + companyParams(COMPANY_ID), + ) + expect(res.status).toBe(400) + const body = await res.json() + expect(body.error.code).toBe('SI_CREATE_NO_FISCAL_PERIOD') + }) + it('returns a dry-run preview when ?dry_run=true', async () => { mockServiceClient.mockReturnValue( makeFlexibleSupabase({ diff --git a/app/api/v1/companies/[companyId]/supplier-invoices/route.ts b/app/api/v1/companies/[companyId]/supplier-invoices/route.ts index 97b5c0e9..4fb9fd35 100644 --- a/app/api/v1/companies/[companyId]/supplier-invoices/route.ts +++ b/app/api/v1/companies/[companyId]/supplier-invoices/route.ts @@ -701,9 +701,9 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>( // Engine returned null (no open fiscal period). Strict-mode: roll back. // Engine returned null before posting — no JE exists. await rollbackSupplierInvoice(ctx.supabase, invoiceId, ctx.companyId!, ctx.log, 'no_fiscal_period', false) - return v1ErrorResponseFromCode('SI_CREATE_FAILED', ctx.log, { + return v1ErrorResponseFromCode('SI_CREATE_NO_FISCAL_PERIOD', ctx.log, { requestId: ctx.requestId, - details: { step: 'registration_journal_entry', reason: 'no_fiscal_period' }, + details: { step: 'registration_journal_entry', invoice_date: body.invoice_date }, }) } } catch (err) { diff --git a/app/api/v1/companies/[companyId]/transactions/ingest/route.ts b/app/api/v1/companies/[companyId]/transactions/ingest/route.ts index 9124b6f1..d584e74b 100644 --- a/app/api/v1/companies/[companyId]/transactions/ingest/route.ts +++ b/app/api/v1/companies/[companyId]/transactions/ingest/route.ts @@ -22,6 +22,7 @@ import { registerEndpoint } from '@/lib/api/v1/registry' import { withApiV1 } from '@/lib/api/v1/with-api-v1' import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors' import { ingestTransactions } from '@/lib/transactions/ingest' +import { contentDedupKey } from '@/lib/transactions/external-id' import type { RawTransaction } from '@/types' const RawTx = z.object({ @@ -152,27 +153,25 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>( const { data: bookedInRange } = await ctx.supabase .from('transactions') - .select('date, amount') + .select('date, amount, description') .eq('company_id', ctx.companyId!) .not('journal_entry_id', 'is', null) .gte('date', dateFrom) .lte('date', dateTo) - // Normalize the amount to a fixed-precision string before keying. - // Both JS number-to-string ("-349.5") and Postgres numeric round-trip - // ("-349.50") collapse to the same "-349.50" representation here, so - // a SIE amount with trailing-zero precision lines up with an already- - // booked row whose amount JSON-encodes without it. - const amountKey = (n: number): string => n.toFixed(2) + // Build the content-dedup key with the SAME helper the live pipeline uses + // (lib/transactions/ingest.ts), so the preview's content-match decision + // matches the eventual ingest exactly: öre-normalized amount (handles a + // PostgREST numeric returned as a string) plus the description prefix. const bookedKeys = new Set( (bookedInRange ?? []).map((r) => { - const row = r as { date: string; amount: number } - return `${row.date}|${amountKey(row.amount)}` + const row = r as { date: string; amount: number | string; description: string | null } + return contentDedupKey(row.date, row.amount, row.description) }), ) const previewRows = body.transactions.map((tx) => { const extIdHit = knownExtIds.has(tx.external_id) - const contentHit = bookedKeys.has(`${tx.date}|${amountKey(tx.amount)}`) + const contentHit = bookedKeys.has(contentDedupKey(tx.date, tx.amount, tx.description)) const wouldSkip = extIdHit || contentHit const reason = extIdHit ? 'external_id_match' diff --git a/components/bookkeeping/RecordateEntryDialog.tsx b/components/bookkeeping/RecordateEntryDialog.tsx new file mode 100644 index 00000000..0c1ed489 --- /dev/null +++ b/components/bookkeeping/RecordateEntryDialog.tsx @@ -0,0 +1,248 @@ +'use client' + +import { useState, useEffect } from 'react' +import { useRouter } from 'next/navigation' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Badge } from '@/components/ui/badge' +import { useToast } from '@/components/ui/use-toast' +import { getErrorMessage } from '@/lib/errors/get-error-message' +import { AlertTriangle, Lock, ArrowRight } from 'lucide-react' +import { formatDate } from '@/lib/utils' +import { formatVoucher } from '@/lib/bookkeeping/voucher-series-resolver' +import type { JournalEntry } from '@/types' + +interface Props { + entry: JournalEntry + open: boolean + onOpenChange: (open: boolean) => void + onMoved: () => void +} + +type PeriodStatus = { + status: 'open' | 'locked' | 'closed' + period_id: string | null + lock_date: string | null + period_name: string | null +} + +const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/ + +export default function RecordateEntryDialog({ entry, open, onOpenChange, onMoved }: Props) { + const { toast } = useToast() + const router = useRouter() + const [newDate, setNewDate] = useState(entry.entry_date) + const [preview, setPreview] = useState(null) + const [previewLoading, setPreviewLoading] = useState(false) + const [previewError, setPreviewError] = useState(null) + const [isSubmitting, setIsSubmitting] = useState(false) + + // Reset to the original date each time the dialog opens. + useEffect(() => { + if (open) { + setNewDate(entry.entry_date) + setPreview(null) + setPreviewError(null) + } + }, [open, entry.entry_date]) + + // Resolve the target period status whenever a valid, changed date is entered. + useEffect(() => { + if (!open) return + if (!ISO_DATE.test(newDate) || newDate === entry.entry_date) { + setPreview(null) + setPreviewError(null) + return + } + let cancelled = false + setPreviewLoading(true) + setPreviewError(null) + const handle = setTimeout(async () => { + try { + const res = await fetch( + `/api/bookkeeping/fiscal-periods/period-status?date=${encodeURIComponent(newDate)}` + ) + if (!res.ok) throw new Error('period_status_failed') + const { data } = await res.json() + if (!cancelled) { + setPreview((data as PeriodStatus) ?? null) + setPreviewError(null) + } + } catch { + if (!cancelled) { + setPreview(null) + setPreviewError('Kunde inte kontrollera perioden. Försök igen.') + } + } finally { + if (!cancelled) setPreviewLoading(false) + } + }, 250) + return () => { + cancelled = true + clearTimeout(handle) + } + }, [newDate, open, entry.entry_date]) + + const dateChanged = ISO_DATE.test(newDate) && newDate !== entry.entry_date + const targetOpen = preview?.status === 'open' && !!preview?.period_id + const noCoveringPeriod = preview?.status === 'open' && !preview?.period_id + // Soft, non-blocking advisory when moving into a past date — the moms for + // that period may already have been filed. + const today = new Date().toISOString().slice(0, 10) + const movingIntoPast = dateChanged && newDate < today + + const canSubmit = dateChanged && targetOpen && !isSubmitting + + async function handleSubmit() { + if (!canSubmit) return + setIsSubmitting(true) + try { + const res = await fetch(`/api/bookkeeping/journal-entries/${entry.id}/recordate`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ new_entry_date: newDate }), + }) + const result = await res.json() + if (!res.ok) { + const error = new Error('Failed to move entry') as Error & { body?: unknown; status?: number } + error.body = result + error.status = res.status + throw error + } + const correctedId = result.data?.corrected?.id + toast({ + title: 'Verifikationen flyttad', + description: 'En storno och en rättelse med rätt datum har bokförts.', + action: correctedId ? ( + + ) : undefined, + }) + onOpenChange(false) + onMoved() + } catch (err) { + const anyErr = err as { body?: unknown; status?: number } + toast({ + title: 'Kunde inte flytta verifikationen', + description: getErrorMessage(anyErr.body ?? err, { context: 'journal_entry', statusCode: anyErr.status }), + variant: 'destructive', + }) + } finally { + setIsSubmitting(false) + } + } + + return ( + + + + Rätta datum + + + {/* Explanation */} +
+

Flytta verifikationen till rätt datum

+

+ En bokförd verifikation kan inte ändras direkt. Raderna behålls oförändrade — istället + skapas automatiskt: +

+
    +
  1. En stornoverifikation som nollställer originalet i sin period
  2. +
  3. En ny verifikation med samma rader, bokförd på det nya datumet
  4. +
+
+ + {/* Original */} +
+
+ {formatVoucher(entry)} + {formatDate(entry.entry_date)} + Original +
+

{entry.description}

+
+ + {/* New date */} +
+ + setNewDate(e.target.value)} + className="tabular-nums" + /> + + {/* Target period feedback */} + {dateChanged && ( +
+ {previewLoading && Kontrollerar period…} + + {!previewLoading && previewError && ( + + + {previewError} + + )} + + {!previewLoading && !previewError && targetOpen && ( + + + Flyttas till {preview?.period_name ?? 'rätt räkenskapsår'} + + )} + + {!previewLoading && noCoveringPeriod && ( + + Det finns ingen räkenskapsperiod som täcker datumet. Skapa eller öppna räkenskapsåret först. + + )} + + {!previewLoading && preview?.status === 'closed' && ( + + Räkenskapsåret är stängt (bokslut) och kan inte återöppnas. Bokför rättelsen i innevarande period istället. + + )} + + {!previewLoading && preview?.status === 'locked' && ( + + + Perioden är låst{preview?.lock_date ? ` t.o.m. ${formatDate(preview.lock_date)}` : ''}. Lås upp perioden för att flytta verifikationen dit. + + )} +
+ )} + + {/* Soft advisory: moving into a past period */} + {targetOpen && movingIntoPast && ( +

+ + + Om momsen för perioden redan är inlämnad kan du behöva lämna en rättad momsdeklaration. + +

+ )} +
+ + + + + +
+
+ ) +} diff --git a/components/import/BankFileUploadStep.tsx b/components/import/BankFileUploadStep.tsx index ed1167c6..86432b7d 100644 --- a/components/import/BankFileUploadStep.tsx +++ b/components/import/BankFileUploadStep.tsx @@ -40,6 +40,7 @@ interface BankFileUploadStepProps { onFileSelect: (file: File, formatOverride?: BankFileFormatId) => void isLoading: boolean error: string | null + errorTitle?: string | null detectedFormat?: string | null detectedFormatName?: string | null } @@ -48,6 +49,7 @@ export default function BankFileUploadStep({ onFileSelect, isLoading, error, + errorTitle, detectedFormat, detectedFormatName, }: BankFileUploadStepProps) { @@ -200,7 +202,7 @@ export default function BankFileUploadStep({
-

Kunde inte läsa filen

+

{errorTitle || 'Kunde inte läsa filen'}

{error}

diff --git a/components/reports/BankReconciliationView.tsx b/components/reports/BankReconciliationView.tsx index 336a6cf7..98b0ffb5 100644 --- a/components/reports/BankReconciliationView.tsx +++ b/components/reports/BankReconciliationView.tsx @@ -50,6 +50,7 @@ interface ReconciliationStatus { gl_1930_balance: number gl_1930_period_movement: number gl_1930_opening_balance: number + gl_1930_correction_adjustment: number difference: number is_reconciled: boolean matched_count: number @@ -644,6 +645,13 @@ export function BankReconciliationView() { {' '}— räknas inte i avstämningen.

)} + {status.gl_1930_correction_adjustment !== 0 && ( +

+ Rättelser och stornon på i perioden:{' '} + {formatCurrency(status.gl_1930_correction_adjustment)} + {' '}— bokföringsmässiga rättelser utan motsvarande bankhändelse, räknas inte i avstämningen. +

+ )}
Matchade: {status.matched_count} Omatchade transaktioner: {status.unmatched_transaction_count} diff --git a/components/transactions/EditTransactionTitleDialog.tsx b/components/transactions/EditTransactionTitleDialog.tsx new file mode 100644 index 00000000..69e1bd2f --- /dev/null +++ b/components/transactions/EditTransactionTitleDialog.tsx @@ -0,0 +1,131 @@ +'use client' + +import { useEffect, useState } from 'react' +import { useTranslations } from 'next-intl' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Loader2 } from 'lucide-react' + +interface EditTransactionTitleDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + /** Current (possibly edited) title shown in the input. */ + currentTitle: string + /** Bank's original title; when it differs from the current title a restore + * affordance is offered. */ + originalTitle: string | null + /** Persist a new title. Resolves true on success (dialog closes), false to + * keep the dialog open (e.g. the request failed). */ + onSave: (description: string) => Promise +} + +/** + * Edit a bank transaction's working title. Carries the product-required warning + * ("Är du säker…") in the dialog body and offers a one-click restore back to + * the bank's original name. Gating (only unbooked/unmatched rows) is enforced + * server-side; callers only open this for editable rows. + */ +export default function EditTransactionTitleDialog({ + open, + onOpenChange, + currentTitle, + originalTitle, + onSave, +}: EditTransactionTitleDialogProps) { + const t = useTranslations('tx_inbox_card') + const [value, setValue] = useState(currentTitle) + const [isSaving, setIsSaving] = useState(false) + + // Re-seed the field each time the dialog opens for a (possibly different) row. + useEffect(() => { + if (open) setValue(currentTitle) + }, [open, currentTitle]) + + const trimmed = value.trim() + const canRestore = originalTitle != null && originalTitle !== currentTitle + const isUnchanged = trimmed === currentTitle.trim() + + async function persist(next: string) { + setIsSaving(true) + try { + const ok = await onSave(next) + if (ok) onOpenChange(false) + } finally { + setIsSaving(false) + } + } + + return ( + { + if (isSaving) return + onOpenChange(v) + }} + > + + + {t('edit_title_dialog_title')} + {t('edit_title_warning')} + +
+ + setValue(e.target.value)} + maxLength={500} + autoFocus + disabled={isSaving} + onKeyDown={(e) => { + if (e.key === 'Enter' && trimmed && !isUnchanged && !isSaving) { + e.preventDefault() + void persist(trimmed) + } + }} + /> + {canRestore && ( +

+ {t('edit_title_original_hint', { name: originalTitle as string })}{' '} + +

+ )} +
+ + + + +
+
+ ) +} diff --git a/components/transactions/TransactionInboxCard.tsx b/components/transactions/TransactionInboxCard.tsx index ab9bea9f..9139f287 100644 --- a/components/transactions/TransactionInboxCard.tsx +++ b/components/transactions/TransactionInboxCard.tsx @@ -22,6 +22,7 @@ import { FileText, Link2, Loader2, + Pencil, Split, Trash2, } from 'lucide-react' @@ -54,6 +55,8 @@ interface TransactionInboxCardProps { onOpenSplitMatch?: (transaction: TransactionWithInvoice) => void onOpenCategoryDialog: (transaction: TransactionWithInvoice) => void onDelete?: (id: string) => void + /** Open the edit-title dialog. Only wired for editable (unbooked/unmatched) rows. */ + onEditTitle?: (transaction: TransactionWithInvoice) => void onToggleSelect: (id: string) => void onAnimationComplete?: (id: string) => void } @@ -69,6 +72,7 @@ export default function TransactionInboxCard({ onOpenSplitMatch, onOpenCategoryDialog, onDelete, + onEditTitle, onToggleSelect, onAnimationComplete, }: TransactionInboxCardProps) { @@ -109,6 +113,11 @@ export default function TransactionInboxCard({ const isUncategorized = transaction.is_business === null && !transaction.journal_entry_id const showCheckbox = isBatchMode && isUncategorized const isDeletable = !transaction.journal_entry_id + // Title is editable only on a mutable staging row — not booked and not + // confirmed-matched. Mirrors the server-side gate in PATCH /api/transactions/[id]. + const isTitleEditable = + !transaction.journal_entry_id && !transaction.invoice_id && !transaction.supplier_invoice_id + const originalName = transaction.original_description // Primary action: invoice/supplier-invoice match keeps the 1-click shortcut; // otherwise the user opens the template picker. @@ -290,6 +299,22 @@ export default function TransactionInboxCard({ Per-transaction agent help has moved to Dokumentinkorgen: match the underlag to the transaction and ask from there, where the receipt/invoice is in view. */} + {isTitleEditable && onEditTitle && ( + + )} {isDeletable && onDelete && (