* 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>
96 lines
4.0 KiB
TypeScript
96 lines
4.0 KiB
TypeScript
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)
|
|
})
|
|
})
|