From c6c86cded48d29b72c5b2d2f286a3d04d7be2947 Mon Sep 17 00:00:00 2001 From: Mattsson <111893710+mattssonn@users.noreply.github.com> Date: Mon, 1 Jun 2026 14:45:49 +0200 Subject: [PATCH] Mcp/template data feedback (#617) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(booking-templates): scope template list to the active company GET /api/settings/booking-templates relied solely on the btl_select RLS policy, which is membership-wide (user_company_ids) and returns templates from every company the user belongs to. A user who owns multiple companies saw all their templates merged regardless of which company was active. Narrow the list in the API layer (mirroring counterparty-templates) to system + the active company + the active company's team. RLS stays the security backstop; this fixes the cross-company merge within a single user's own view (it was never a cross-tenant data leak). Co-Authored-By: Claude Opus 4.8 (1M context) * fix(import): show proper message for duplicate bank file upload The bank file import page mis-parsed the structured error envelope ({ error: { code, message, details } }), so a BANK_FILE_DUPLICATE (409) fell through to the generic "Kunde inte läsa filen" fallback. The upload step also hardcoded that same string as the error heading, so duplicates were doubly misreported as parse failures. - Parse the structured envelope by error.code; surface error.message for all codes instead of rendering the error object. - Add a dedicated BANK_FILE_DUPLICATE message using the importedAt / importedCount details the route already returns. - Add an optional errorTitle prop to BankFileUploadStep (defaults to the previous text) and pass "Filen är redan importerad" for dupes. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(tests): add comprehensive tests for recordateEntry, inbox-linking, and external-id handling - Implemented unit tests for recordateEntry in the bookkeeping module to validate various scenarios including date changes, non-posted entries, and fiscal period restrictions. - Created tests for inbox-linking status in pending operations to ensure correct handling of invoice inbox items and supplier invoices, addressing historical bugs related to status updates. - Added tests for external-id utilities to ensure consistent handling of monetary amounts and deduplication keys across different transaction sources. - Introduced new functions in external-id.ts for stable external ID generation and normalization of imported descriptions, enhancing transaction deduplication reliability. feat(migrations): add new database migrations for transaction handling - Created migration to exclude storno and correction vouchers from unmatched GL lines, ensuring accurate reconciliation. - Added a migration to preserve original bank transaction descriptions in a new immutable column, allowing for user edits while maintaining audit trails and deduplication integrity. * feat(migrations): add function to exclude storno/correction vouchers from unmatched GL lines * feat(transactions): enhance transaction handling with improved description normalization and preloaded original entries --------- Co-authored-by: Claude Opus 4.8 (1M context) --- app/(dashboard)/bookkeeping/[id]/page.tsx | 59 ++++- app/(dashboard)/import/page.tsx | 27 +- .../supplier-invoices/new/page.tsx | 69 ++++- app/(dashboard)/transactions/page.tsx | 54 ++++ .../fiscal-periods/period-status/route.ts | 61 +++++ .../[id]/recordate/__tests__/route.test.ts | 142 ++++++++++ .../journal-entries/[id]/recordate/route.ts | 47 ++++ app/api/settings/booking-templates/route.ts | 35 ++- .../supplier-invoices/__tests__/route.test.ts | 39 +++ app/api/supplier-invoices/route.ts | 24 ++ .../transactions/[id]/__tests__/route.test.ts | 169 +++++++++++- app/api/transactions/[id]/route.ts | 104 ++++++++ .../supplier-invoices/__tests__/route.test.ts | 26 ++ .../[companyId]/supplier-invoices/route.ts | 4 +- .../[companyId]/transactions/ingest/route.ts | 19 +- .../bookkeeping/RecordateEntryDialog.tsx | 248 ++++++++++++++++++ components/import/BankFileUploadStep.tsx | 4 +- components/reports/BankReconciliationView.tsx | 8 + .../EditTransactionTitleDialog.tsx | 131 +++++++++ .../transactions/TransactionInboxCard.tsx | 37 +++ .../lib/__tests__/api-client.test.ts | 46 ++++ .../enable-banking/lib/__tests__/sync.test.ts | 119 ++++++++- .../lib/__tests__/transaction-label.test.ts | 50 ++++ .../general/enable-banking/lib/api-client.ts | 24 +- extensions/general/enable-banking/lib/sync.ts | 26 +- .../enable-banking/lib/transaction-label.ts | 100 +++++++ extensions/general/invoice-inbox/index.ts | 14 + lib/api/schemas.ts | 18 ++ .../__tests__/own-account-detector.test.ts | 2 + lib/bookkeeping/errors.ts | 99 ++++++- .../__tests__/recordate-entry.pg.test.ts | 183 +++++++++++++ .../__tests__/recordate-entry.test.ts | 157 +++++++++++ .../__tests__/storno-service.test.ts | 89 +++++++ lib/core/bookkeeping/storno-service.ts | 221 ++++++++++++++-- lib/errors/get-error-message.ts | 18 ++ lib/errors/structured-errors.ts | 14 + lib/import/bank-file/__tests__/parser.test.ts | 69 +++++ lib/import/bank-file/formats/handelsbanken.ts | 100 ++++--- .../__tests__/inbox-link-status.pg.test.ts | 244 +++++++++++++++++ .../__tests__/voucher-executors.test.ts | 8 +- lib/pending-operations/commit.ts | 35 ++- .../__tests__/bank-reconciliation.test.ts | 87 +++++- lib/reconciliation/bank-reconciliation.ts | 94 ++++--- .../__tests__/external-id.test.ts | 144 ++++++++++ lib/transactions/__tests__/ingest.test.ts | 145 ++++++++++ lib/transactions/external-id.ts | 139 ++++++++++ lib/transactions/ingest.ts | 54 ++-- messages/en.json | 20 +- messages/sv.json | 20 +- ...0000_transactions_original_description.sql | 47 ++++ ...ked_gl_lines_exclude_storno_correction.sql | 87 ++++++ tests/helpers.ts | 2 + tests/pg/get_unlinked_1930_lines.pg.test.ts | 40 ++- types/index.ts | 9 +- 54 files changed, 3673 insertions(+), 158 deletions(-) create mode 100644 app/api/bookkeeping/fiscal-periods/period-status/route.ts create mode 100644 app/api/bookkeeping/journal-entries/[id]/recordate/__tests__/route.test.ts create mode 100644 app/api/bookkeeping/journal-entries/[id]/recordate/route.ts create mode 100644 components/bookkeeping/RecordateEntryDialog.tsx create mode 100644 components/transactions/EditTransactionTitleDialog.tsx create mode 100644 extensions/general/enable-banking/lib/__tests__/transaction-label.test.ts create mode 100644 extensions/general/enable-banking/lib/transaction-label.ts create mode 100644 lib/core/bookkeeping/__tests__/recordate-entry.pg.test.ts create mode 100644 lib/core/bookkeeping/__tests__/recordate-entry.test.ts create mode 100644 lib/pending-operations/__tests__/inbox-link-status.pg.test.ts create mode 100644 lib/transactions/__tests__/external-id.test.ts create mode 100644 lib/transactions/external-id.ts create mode 100644 supabase/migrations/20260605120000_transactions_original_description.sql create mode 100644 supabase/migrations/20260605130000_unlinked_gl_lines_exclude_storno_correction.sql 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 && (