diff --git a/app/(dashboard)/bookkeeping/page.tsx b/app/(dashboard)/bookkeeping/page.tsx index 5f69cd47..da0b5d3f 100644 --- a/app/(dashboard)/bookkeeping/page.tsx +++ b/app/(dashboard)/bookkeeping/page.tsx @@ -7,28 +7,21 @@ import { useTranslations } from 'next-intl' import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs' import { Button } from '@/components/ui/button' import JournalEntryList from '@/components/bookkeeping/JournalEntryList' -import JournalEntryForm, { type FormLine } from '@/components/bookkeeping/JournalEntryForm' +import { type FormLine } from '@/components/bookkeeping/JournalEntryForm' +import NewJournalEntryDialog, { type CopyPrefill } from '@/components/bookkeeping/NewJournalEntryDialog' import ChartOfAccountsManager from '@/components/bookkeeping/ChartOfAccountsManager' import { useToast } from '@/components/ui/use-toast' -import { Lock, Loader2, Copy } from 'lucide-react' +import { Lock, Plus } from 'lucide-react' import { PageHeader } from '@/components/ui/page-header' import { formatVoucher } from '@/lib/bookkeeping/voucher-series-resolver' import type { JournalEntry, JournalEntryLine } from '@/types' -interface CopyPrefill { - sourceId: string - sourceVoucherLabel: string - lines: FormLine[] - description: string - notes: string -} - interface NextVoucher { next: number series: string } -type TabValue = 'journal' | 'new-entry' | 'accounts' +type TabValue = 'journal' | 'accounts' const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i @@ -43,6 +36,7 @@ export default function BookkeepingPage() { const [refreshKey, setRefreshKey] = useState(0) const [activeTab, setActiveTab] = useState('journal') + const [showNewEntry, setShowNewEntry] = useState(false) const [copyPrefill, setCopyPrefill] = useState(null) const [isLoadingCopy, setIsLoadingCopy] = useState(false) const [nextVoucher, setNextVoucher] = useState(null) @@ -56,7 +50,7 @@ export default function BookkeepingPage() { useEffect(() => { if (!copyFromId) return - setActiveTab('new-entry') + setShowNewEntry(true) setCopyPrefill(null) setIsLoadingCopy(true) @@ -136,12 +130,27 @@ export default function BookkeepingPage() { title={t('title')} action={
+ + + + {t('year_end')} + +
} /> @@ -149,14 +158,6 @@ export default function BookkeepingPage() { setActiveTab(v as TabValue)}> {t('tab_journal')} - - {t('tab_new_entry')} - {nextVoucher && ( - - ({nextVoucher.series}{nextVoucher.next}) - - )} - {t('tab_accounts')} @@ -164,45 +165,25 @@ export default function BookkeepingPage() { - - {isLoadingCopy ? ( -
- - {t('loading_source_voucher')} -
- ) : ( - <> - {copyPrefill && ( -
- -
-

- {t('copy_banner_title', { label: copyPrefill.sourceVoucherLabel || t('copy_banner_unknown_label') })} -

-

- {t('copy_banner_body')} -

-
-
- )} - { - setRefreshKey((k) => k + 1) - setCopyPrefill(null) - }} - initialLines={copyPrefill?.lines} - initialDescription={copyPrefill?.description} - initialNotes={copyPrefill?.notes} - /> - - )} -
-
+ + { + setShowNewEntry(o) + if (!o) setCopyPrefill(null) + }} + onCreated={() => { + setRefreshKey((k) => k + 1) + setShowNewEntry(false) + setCopyPrefill(null) + }} + copyPrefill={copyPrefill} + isLoading={isLoadingCopy} + /> ) } diff --git a/app/api/bookkeeping/no-doc-required/batch/__tests__/route.test.ts b/app/api/bookkeeping/no-doc-required/batch/__tests__/route.test.ts new file mode 100644 index 00000000..01e83d2f --- /dev/null +++ b/app/api/bookkeeping/no-doc-required/batch/__tests__/route.test.ts @@ -0,0 +1,84 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { parseJsonResponse, createQueuedMockSupabase } from '@/tests/helpers' +import { NextResponse } from 'next/server' + +const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase() + +vi.mock('@/lib/auth/require-auth', () => ({ requireAuth: vi.fn() })) +vi.mock('@/lib/company/context', () => ({ getActiveCompanyId: vi.fn() })) +vi.mock('@/lib/auth/require-write', () => ({ requireWritePermission: vi.fn() })) + +import { POST } from '../route' +import { requireAuth } from '@/lib/auth/require-auth' +import { getActiveCompanyId } from '@/lib/company/context' +import { requireWritePermission } from '@/lib/auth/require-write' + +const mockUser = { id: 'user-1', email: 't@t.se' } +const UUID_A = '11111111-1111-4111-8111-111111111111' +const UUID_B = '22222222-2222-4222-8222-222222222222' + +function makeReq(body: unknown) { + return new Request('http://localhost/api/bookkeeping/no-doc-required/batch', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }) +} + +beforeEach(() => { + vi.clearAllMocks() + reset() + ;(requireAuth as ReturnType).mockResolvedValue({ user: mockUser, supabase: mockSupabase }) + ;(getActiveCompanyId as ReturnType).mockResolvedValue('company-1') + ;(requireWritePermission as ReturnType).mockResolvedValue({ ok: true }) +}) + +describe('POST /api/bookkeeping/no-doc-required/batch', () => { + it('returns 401 when not authenticated', async () => { + ;(requireAuth as ReturnType).mockResolvedValue({ + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + const res = await POST(makeReq({ journal_entry_ids: [UUID_A] })) + const { status } = await parseJsonResponse(res) + expect(status).toBe(401) + }) + + it('returns 403 for read-only members', async () => { + ;(requireWritePermission as ReturnType).mockResolvedValue({ + ok: false, + response: NextResponse.json({ error: 'forbidden' }, { status: 403 }), + }) + const res = await POST(makeReq({ journal_entry_ids: [UUID_A] })) + const { status } = await parseJsonResponse(res) + expect(status).toBe(403) + }) + + it('returns 400 for an empty id list', async () => { + const res = await POST(makeReq({ journal_entry_ids: [] })) + const { status } = await parseJsonResponse(res) + expect(status).toBe(400) + }) + + it('returns 400 for non-uuid ids', async () => { + const res = await POST(makeReq({ journal_entry_ids: ['not-a-uuid'] })) + const { status } = await parseJsonResponse(res) + expect(status).toBe(400) + }) + + it('exempts only owned, posted entries (defense in depth)', async () => { + enqueue({ data: [{ id: UUID_A }], error: null }) // ownership query: only A owned + enqueue({ error: null }) // helper upsert + const res = await POST(makeReq({ journal_entry_ids: [UUID_A, UUID_B], reason: 'Importerad' })) + const { status, body } = await parseJsonResponse<{ data: { exempted: number } }>(res) + expect(status).toBe(200) + expect(body.data.exempted).toBe(1) + }) + + it('returns exempted:0 without writing when no ids are owned', async () => { + enqueue({ data: [], error: null }) // ownership query → none owned + const res = await POST(makeReq({ journal_entry_ids: [UUID_A] })) + const { status, body } = await parseJsonResponse<{ data: { exempted: number } }>(res) + expect(status).toBe(200) + expect(body.data.exempted).toBe(0) + }) +}) diff --git a/app/api/bookkeeping/no-doc-required/batch/route.ts b/app/api/bookkeeping/no-doc-required/batch/route.ts new file mode 100644 index 00000000..b73da674 --- /dev/null +++ b/app/api/bookkeeping/no-doc-required/batch/route.ts @@ -0,0 +1,63 @@ +import { NextResponse } from 'next/server' +import { z } from 'zod' +import { withRouteContext } from '@/lib/api/with-route-context' +import { validateBody } from '@/lib/api/validate' +import { markEntriesNoDocRequired } from '@/lib/bookkeeping/no-doc-required' +import { NEEDS_DOC_SOURCE_TYPES } from '@/lib/worklist/categories' + +const BatchNoDocSchema = z.object({ + journal_entry_ids: z.array(z.string().uuid()).min(1).max(500), + reason: z.string().trim().max(200).nullable().optional(), +}) + +/** + * Batch-mark posted verifikationer as "Inget underlag krävs". Lets the user + * clear many entries (e.g. historical SIE imports) out of "Att hantera: saknade + * underlag" in one action instead of toggling each one. + * + * The exemption is shared bookkeeping metadata (company-scoped, like mapping + * rules) — the audit_log trigger records the actor. + */ +export const POST = withRouteContext( + 'journal_entry.batch_no_document_required', + async (request, { supabase, companyId, user }) => { + const validation = await validateBody(request, BatchNoDocSchema) + if (!validation.success) return validation.response + + const { journal_entry_ids, reason } = validation.data + + // Defense in depth: only exempt posted entries that belong to this company. + // Validate ownership in chunks so the PostgREST `in()` URL stays bounded. + const ownedIds: string[] = [] + for (let i = 0; i < journal_entry_ids.length; i += 200) { + const chunk = journal_entry_ids.slice(i, i + 200) + const { data, error } = await supabase + .from('journal_entries') + .select('id') + .eq('company_id', companyId) + .eq('status', 'posted') + .in('source_type', [...NEEDS_DOC_SOURCE_TYPES]) + .in('id', chunk) + + if (error) { + return NextResponse.json({ error: error.message }, { status: 400 }) + } + ownedIds.push(...(data ?? []).map((r) => r.id)) + } + + if (ownedIds.length === 0) { + return NextResponse.json({ data: { exempted: 0 } }) + } + + const exempted = await markEntriesNoDocRequired( + supabase, + companyId, + user.id, + ownedIds, + reason ?? null, + ) + + return NextResponse.json({ data: { exempted } }) + }, + { requireWrite: true }, +) diff --git a/app/api/bookkeeping/no-doc-required/bulk-missing/__tests__/route.test.ts b/app/api/bookkeeping/no-doc-required/bulk-missing/__tests__/route.test.ts new file mode 100644 index 00000000..c4d08a08 --- /dev/null +++ b/app/api/bookkeeping/no-doc-required/bulk-missing/__tests__/route.test.ts @@ -0,0 +1,95 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { parseJsonResponse, createQueuedMockSupabase } from '@/tests/helpers' +import { NextResponse } from 'next/server' + +const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase() + +vi.mock('@/lib/auth/require-auth', () => ({ requireAuth: vi.fn() })) +vi.mock('@/lib/company/context', () => ({ getActiveCompanyId: vi.fn() })) +vi.mock('@/lib/auth/require-write', () => ({ requireWritePermission: vi.fn() })) + +import { POST } from '../route' +import { requireAuth } from '@/lib/auth/require-auth' +import { getActiveCompanyId } from '@/lib/company/context' +import { requireWritePermission } from '@/lib/auth/require-write' + +const mockUser = { id: 'user-1', email: 't@t.se' } + +function makeReq(body: unknown) { + return new Request('http://localhost/api/bookkeeping/no-doc-required/bulk-missing', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }) +} + +beforeEach(() => { + vi.clearAllMocks() + reset() + ;(requireAuth as ReturnType).mockResolvedValue({ user: mockUser, supabase: mockSupabase }) + ;(getActiveCompanyId as ReturnType).mockResolvedValue('company-1') + ;(requireWritePermission as ReturnType).mockResolvedValue({ ok: true }) +}) + +describe('POST /api/bookkeeping/no-doc-required/bulk-missing', () => { + it('returns 401 when not authenticated', async () => { + ;(requireAuth as ReturnType).mockResolvedValue({ + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + const res = await POST(makeReq({})) + expect((await parseJsonResponse(res)).status).toBe(401) + }) + + it('returns 403 for read-only members', async () => { + ;(requireWritePermission as ReturnType).mockResolvedValue({ + ok: false, + response: NextResponse.json({ error: 'forbidden' }, { status: 403 }), + }) + const res = await POST(makeReq({})) + expect((await parseJsonResponse(res)).status).toBe(403) + }) + + it('returns 400 for a non-uuid period_id', async () => { + const res = await POST(makeReq({ period_id: 'not-a-uuid' })) + expect((await parseJsonResponse(res)).status).toBe(400) + }) + + it('returns 400 for a shaped-but-invalid date', async () => { + const res = await POST(makeReq({ date_from: '9999-99-99' })) + expect((await parseJsonResponse(res)).status).toBe(400) + }) + + it('returns 400 for an invalid series filter', async () => { + const res = await POST(makeReq({ series: 'all' })) + expect((await parseJsonResponse(res)).status).toBe(400) + }) + + it('dry_run counts only entries that are missing AND not exempt', async () => { + enqueue({ data: [{ id: 'a' }, { id: 'b' }, { id: 'c' }], error: null }) // candidates + enqueue({ data: [{ journal_entry_id: 'a' }], error: null }) // a has a document + enqueue({ data: [{ journal_entry_id: 'b' }], error: null }) // b already exempt + const res = await POST(makeReq({ dry_run: true })) + const { status, body } = await parseJsonResponse<{ data: { count: number } }>(res) + expect(status).toBe(200) + expect(body.data.count).toBe(1) // only c + }) + + it('marks the missing entries and returns the count', async () => { + enqueue({ data: [{ id: 'a' }, { id: 'b' }, { id: 'c' }], error: null }) // candidates + enqueue({ data: [], error: null }) // no documents + enqueue({ data: [{ journal_entry_id: 'a' }], error: null }) // a already exempt + enqueue({ error: null }) // helper upsert + const res = await POST(makeReq({ period_id: null, reason: 'Importerad' })) + const { status, body } = await parseJsonResponse<{ data: { exempted: number } }>(res) + expect(status).toBe(200) + expect(body.data.exempted).toBe(2) // b and c + }) + + it('short-circuits to 0 when no candidates match the filters', async () => { + enqueue({ data: [], error: null }) // no candidates + const res = await POST(makeReq({ dry_run: true })) + const { status, body } = await parseJsonResponse<{ data: { count: number } }>(res) + expect(status).toBe(200) + expect(body.data.count).toBe(0) + }) +}) diff --git a/app/api/bookkeeping/no-doc-required/bulk-missing/route.ts b/app/api/bookkeeping/no-doc-required/bulk-missing/route.ts new file mode 100644 index 00000000..ddebedb0 --- /dev/null +++ b/app/api/bookkeeping/no-doc-required/bulk-missing/route.ts @@ -0,0 +1,143 @@ +import { NextResponse } from 'next/server' +import { z } from 'zod' +import { withRouteContext } from '@/lib/api/with-route-context' +import { validateBody } from '@/lib/api/validate' +import { fetchAllRows } from '@/lib/supabase/fetch-all' +import { markEntriesNoDocRequired } from '@/lib/bookkeeping/no-doc-required' +import { NEEDS_DOC_SOURCE_TYPES } from '@/lib/worklist/categories' +import { escapeLikePattern } from '@/lib/invoices/duplicate-payment-guard' + +// A real calendar date in YYYY-MM-DD form. Rejects shaped-but-invalid values +// (e.g. 9999-99-99 or 2026-02-30) that a bare /^\d{4}-\d{2}-\d{2}$/ regex would +// let through and that would otherwise reach the query layer. +const isoDate = z.string().refine( + (v) => { + if (!/^\d{4}-\d{2}-\d{2}$/.test(v)) return false + const [y, m, d] = v.split('-').map(Number) + const date = new Date(Date.UTC(y, m - 1, d)) + return ( + date.getUTCFullYear() === y && + date.getUTCMonth() + 1 === m && + date.getUTCDate() === d + ) + }, + { message: 'Ogiltigt datum (förväntat YYYY-MM-DD)' }, +) + +const BulkMissingSchema = z.object({ + period_id: z.string().uuid().nullable().optional(), + // Single uppercase verifikationsserie (A–Z); the list sends null for "all". + series: z.string().regex(/^[A-Z]$/).nullable().optional(), + date_from: isoDate.nullable().optional(), + date_to: isoDate.nullable().optional(), + search: z.string().max(200).nullable().optional(), + reason: z.string().trim().max(200).nullable().optional(), + // When true, only count the matching verifikat (no writes) so the UI can + // confirm the scope before the user commits. + dry_run: z.boolean().optional(), +}) + +/** + * Mark every posted, document-requiring verifikat that currently lacks an + * underlag AND matches the active list filters (period / series / date / search) + * as "Inget underlag krävs" — across all pages, in one action. This is the + * scalable remedy for the "thousands of saknade underlag after a migration" + * problem; the per-entry batch route handles selective marking. + * + * The missing-doc predicate mirrors countVerifikatMissingDocument: posted + + * NEEDS_DOC source type, no current-version document_attachment, not already + * exempt. + */ +export const POST = withRouteContext( + 'journal_entry.bulk_missing_no_document_required', + async (request, { supabase, companyId, user }) => { + const validation = await validateBody(request, BulkMissingSchema) + if (!validation.success) return validation.response + + // All formats are enforced by the schema above, so these are already valid + // (or null). No re-validation needed before they reach the query layer. + const { period_id, reason, dry_run } = validation.data + const series = validation.data.series ?? null + const dateFrom = validation.data.date_from ?? null + const dateTo = validation.data.date_to ?? null + const search = validation.data.search?.trim() || null + + // Candidate entries: posted, document-requiring, matching the active filters. + const candidates = await fetchAllRows<{ id: string }>(({ from, to }) => { + let q = supabase + .from('journal_entries') + .select('id') + .eq('company_id', companyId) + .eq('status', 'posted') + .in('source_type', [...NEEDS_DOC_SOURCE_TYPES]) + if (period_id) q = q.eq('fiscal_period_id', period_id) + if (series) q = q.eq('voucher_series', series) + if (dateFrom) q = q.gte('entry_date', dateFrom) + if (dateTo) q = q.lte('entry_date', dateTo) + if (search) q = q.ilike('description', `%${escapeLikePattern(search)}%`) + return q.order('id').range(from, to) + }) + + if (candidates.length === 0) { + return NextResponse.json({ data: dry_run ? { count: 0 } : { exempted: 0 } }) + } + + // Resolve which candidates already have a document or an exemption by + // querying ONLY for the candidate ids (chunked), rather than loading the + // company's full document_attachments + journal_entry_no_doc_required tables + // into memory. Data minimisation + bounded memory for large migrations. + const candidateIds = candidates.map((e) => e.id) + const withDoc = new Set() + const exempt = new Set() + const LOOKUP_CHUNK = 300 + for (let i = 0; i < candidateIds.length; i += LOOKUP_CHUNK) { + const chunk = candidateIds.slice(i, i + LOOKUP_CHUNK) + const [docRes, exemptRes] = await Promise.all([ + supabase + .from('document_attachments') + .select('journal_entry_id') + .eq('company_id', companyId) + .eq('is_current_version', true) + .in('journal_entry_id', chunk), + supabase + .from('journal_entry_no_doc_required') + .select('journal_entry_id') + .eq('company_id', companyId) + .in('journal_entry_id', chunk), + ]) + if (docRes.error) { + return NextResponse.json({ error: docRes.error.message }, { status: 400 }) + } + if (exemptRes.error) { + return NextResponse.json({ error: exemptRes.error.message }, { status: 400 }) + } + for (const r of (docRes.data ?? []) as { journal_entry_id: string }[]) { + withDoc.add(r.journal_entry_id) + } + for (const r of (exemptRes.data ?? []) as { journal_entry_id: string }[]) { + exempt.add(r.journal_entry_id) + } + } + + const missingIds = candidateIds.filter((id) => !withDoc.has(id) && !exempt.has(id)) + + if (dry_run) { + return NextResponse.json({ data: { count: missingIds.length } }) + } + + if (missingIds.length === 0) { + return NextResponse.json({ data: { exempted: 0 } }) + } + + const exempted = await markEntriesNoDocRequired( + supabase, + companyId, + user.id, + missingIds, + reason ?? null, + ) + + return NextResponse.json({ data: { exempted } }) + }, + { requireWrite: true }, +) diff --git a/app/api/import/sie/execute/route.ts b/app/api/import/sie/execute/route.ts index ea1af70a..9520bcd7 100644 --- a/app/api/import/sie/execute/route.ts +++ b/app/api/import/sie/execute/route.ts @@ -109,6 +109,7 @@ export const POST = withRouteContext( importTransactions: options.importTransactions, voucherSeries: options.voucherSeries || companyDefaultSeries, updateAccountNames: options.updateAccountNames ?? true, + markImportedNoDocRequired: options.markImportedNoDocRequired ?? false, }, ) diff --git a/components/bookkeeping/JournalEntryForm.tsx b/components/bookkeeping/JournalEntryForm.tsx index 88765d77..b53b91fd 100644 --- a/components/bookkeeping/JournalEntryForm.tsx +++ b/components/bookkeeping/JournalEntryForm.tsx @@ -63,6 +63,9 @@ interface Props { sourceId?: string submitUrl?: string embedded?: boolean + /** Render without the Card chrome (e.g. inside a dialog) but keep the full + * non-embedded field set (series, notes, documents, voucher hint). */ + bare?: boolean } const BLANK_LINE: FormLine = { account_number: '', debit_amount: '', credit_amount: '', line_description: '' } @@ -78,6 +81,7 @@ export default function JournalEntryForm({ sourceId, submitUrl, embedded, + bare, }: Props) { const { canWrite } = useCanWrite() const { toast } = useToast() @@ -89,6 +93,7 @@ export default function JournalEntryForm({ const [entryDate, setEntryDate] = useState(initialDate ?? new Date().toISOString().split('T')[0]) const [description, setDescription] = useState(initialDescription ?? '') const [notes, setNotes] = useState(initialNotes ?? '') + const [showNotes, setShowNotes] = useState(false) const [lines, setLines] = useState( initialLines ?? [{ ...BLANK_LINE }, { ...BLANK_LINE }] ) @@ -341,6 +346,11 @@ export default function JournalEntryForm({ const account = accounts.find((a) => a.account_number === value) if (account) { updated[index].line_description = account.account_name + // Fortnox-style: seed the verifikationstext from the first row's account + // when the user hasn't typed one yet. Non-destructive — never overwrites. + if (index === 0 && !description.trim()) { + setDescription(account.account_name) + } } } @@ -493,7 +503,7 @@ export default function JournalEntryForm({ const handleReview = () => { if (!selectedPeriod || !description || !isBalanced || periodMismatch) return const hasDocuments = uploadedFiles.some((f) => f.status === 'uploaded') - if (!embedded && !hasDocuments) { + if (!embedded && !bare && !hasDocuments) { setShowNoDocWarning(true) return } @@ -668,125 +678,190 @@ export default function JournalEntryForm({ } } - const formContent = ( + // Inline review for the modal (bare): swap the form body to a read-only + // summary instead of stacking a second dialog over the form dialog. The + // no-underlag caveat folds in here so there's a single confirm step. + const reviewPanel = (
-
-
- - -
- {!(embedded && initialDate) && ( -
- - setEntryDate(e.target.value)} - /> -
- )} -
- - setDescription(e.target.value)} - placeholder={t('description_placeholder')} - /> -
-
- -