feat(bookkeeping): Ny verifikat modal, ledger-style list, SIE no-underlag exemptions (#698)
* feat(bookkeeping): Ny verifikat modal, ledger-style list, SIE no-underlag exemptions Verifikat UX - "Ny verifikat" opens in a modal (NewJournalEntryDialog) instead of an inline tab; the review step renders inline in the dialog rather than stacking a second dialog. - JournalEntryForm: konteringsrader are the focus, with a compact pre-filled metadata bar (datum/serie/text/valuta/period) on top; verifikationstext auto-fills from the first row's account. - JournalEntryList: belopp shown on collapsed rows; expanded view is an aligned Konto/Benämning/Debet/Kredit table. SIE imports no longer flood "Att hantera: saknade underlag" - Import gains an opt-in (off by default) toggle to mark imported verifikat as "Inget underlag krävs"; a "Rekommenderas vid migrering" badge nudges it for historical years. - Multi-select batch-mark in the list for selective cleanup. - Filter-scoped bulk mark (POST /api/bookkeeping/no-doc-required/bulk-missing): marks every missing-doc verifikat matching the active filters across all pages, with a dry_run count to confirm scope — the scalable remedy for a post-import flood. - Shared helper markEntriesNoDocRequired + per-entry batch route. Tests: no-doc helper, batch route, bulk-missing route. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(bookkeeping): address PR #698 review findings - JournalEntryForm: restore the explicit "no underlag" acknowledgement in the modal's inline review. When no document is attached, the confirm button reads "Bokför utan underlag" (BFL 5 kap 6-7 §§), equivalent to the blocking dialog the non-bare flow shows — the bare path no longer posts behind only a passive banner. - batch no-doc route: guard the ownership query with source_type IN NEEDS_DOC_SOURCE_TYPES so a crafted request can't exempt non-document-requiring entries (defense in depth on top of company + posted scoping). - bulk-missing route: resolve doc/exemption status by querying only the candidate ids (chunked) instead of loading the company's full document_attachments and journal_entry_no_doc_required tables into memory — data minimisation + bounded memory for large migrations (the most-repeated reviewer finding). Triaged as non-issues (left as-is): partial-import exemption (gated on result.success == zero errors), reason write-back (sidecar row is FK-linked and carries the reason), and "bulk-exempting manual entries" (consistent with the existing per-entry NoDocRequiredToggle). No DB migration — reuses the existing journal_entry_no_doc_required table. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(bookkeeping): centralize bulk-missing date/series validation in Zod Move the ISO-date and verifikationsserie format checks into the Zod schema so malformed input is rejected with a clean 400 instead of being silently nulled (or, for a shaped-but-invalid date, throwing a 500 via fetchAllRows). The date refinement rejects values like 9999-99-99 / 2026-02-30 that a bare /^\d{4}-\d{2}-\d{2}$/ regex lets through. Addresses the PR #698 reviewer nit on split schema-vs-runtime validation. +2 route tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
64991eb3c9
commit
4dfd790de5
@@ -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<typeof vi.fn>).mockResolvedValue({ user: mockUser, supabase: mockSupabase })
|
||||
;(getActiveCompanyId as ReturnType<typeof vi.fn>).mockResolvedValue('company-1')
|
||||
;(requireWritePermission as ReturnType<typeof vi.fn>).mockResolvedValue({ ok: true })
|
||||
})
|
||||
|
||||
describe('POST /api/bookkeeping/no-doc-required/batch', () => {
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
;(requireAuth as ReturnType<typeof vi.fn>).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<typeof vi.fn>).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)
|
||||
})
|
||||
})
|
||||
@@ -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 },
|
||||
)
|
||||
@@ -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<typeof vi.fn>).mockResolvedValue({ user: mockUser, supabase: mockSupabase })
|
||||
;(getActiveCompanyId as ReturnType<typeof vi.fn>).mockResolvedValue('company-1')
|
||||
;(requireWritePermission as ReturnType<typeof vi.fn>).mockResolvedValue({ ok: true })
|
||||
})
|
||||
|
||||
describe('POST /api/bookkeeping/no-doc-required/bulk-missing', () => {
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
;(requireAuth as ReturnType<typeof vi.fn>).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<typeof vi.fn>).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)
|
||||
})
|
||||
})
|
||||
@@ -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<string>()
|
||||
const exempt = new Set<string>()
|
||||
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 },
|
||||
)
|
||||
@@ -109,6 +109,7 @@ export const POST = withRouteContext(
|
||||
importTransactions: options.importTransactions,
|
||||
voucherSeries: options.voucherSeries || companyDefaultSeries,
|
||||
updateAccountNames: options.updateAccountNames ?? true,
|
||||
markImportedNoDocRequired: options.markImportedNoDocRequired ?? false,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user