feat: wire Zod validation into API routes, improve types and components
- Add 8 new Zod schemas (UpdateCustomer, UpdateSupplier, UpdateSupplierInvoice, UpdateAccount, BankUnlink, RunReconciliation, CorrectJournalEntry, EvaluateMappingRules) and wire validateBody() into 24 JSON-body API routes - Remove redundant manual validation checks replaced by Zod - Add comprehensive schema tests (222 tests) - Improve type definitions in types/index.ts with expanded interfaces - Refactor extension types (push-notifications, receipt-ocr) for cleaner imports - Update transaction components (BatchCategorySelector, SwipeCategorizationView, QuickReviewDialog, VatTreatmentSelect) and invoice inbox workspace - Add invoice-inbox utilities and type decoupling tests - Fix NE-bilaga, SRU export, and invoice PDF template type usage - Update CLAUDE.md with expanded architecture documentation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
cfc4cb6849
commit
acb85edf4a
@@ -25,10 +25,12 @@ vi.mock('@/lib/bookkeeping/engine', () => ({
|
||||
|
||||
import { POST } from '../route'
|
||||
|
||||
const VALID_UUID = '550e8400-e29b-41d4-a716-446655440000'
|
||||
|
||||
describe('POST /api/transactions/[id]/book', () => {
|
||||
const mockUser = { id: 'user-1', email: 'test@test.se' }
|
||||
const validBody = {
|
||||
fiscal_period_id: 'period-1',
|
||||
fiscal_period_id: VALID_UUID,
|
||||
entry_date: '2025-01-15',
|
||||
description: 'Test booking',
|
||||
lines: [
|
||||
@@ -61,13 +63,13 @@ describe('POST /api/transactions/[id]/book', () => {
|
||||
it('returns 400 when missing required fields', async () => {
|
||||
const request = createMockRequest('/api/transactions/tx-1/book', {
|
||||
method: 'POST',
|
||||
body: { fiscal_period_id: 'period-1' },
|
||||
body: { fiscal_period_id: VALID_UUID },
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(response)
|
||||
|
||||
expect(status).toBe(400)
|
||||
expect(body.error).toContain('Missing required fields')
|
||||
expect(body.error).toBe('Validation failed')
|
||||
})
|
||||
|
||||
it('returns 404 when transaction not found', async () => {
|
||||
@@ -154,7 +156,7 @@ describe('POST /api/transactions/[id]/book', () => {
|
||||
expect(body.data.id).toBe('je-new')
|
||||
|
||||
expect(mockCreateJournalEntry).toHaveBeenCalledWith('user-1', {
|
||||
fiscal_period_id: 'period-1',
|
||||
fiscal_period_id: VALID_UUID,
|
||||
entry_date: '2025-01-15',
|
||||
description: 'Test booking',
|
||||
source_type: 'bank_transaction',
|
||||
|
||||
@@ -3,17 +3,12 @@ import { NextResponse } from 'next/server'
|
||||
import { eventBus } from '@/lib/events'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { createJournalEntry } from '@/lib/bookkeeping/engine'
|
||||
import type { CreateJournalEntryLineInput, Transaction } from '@/types'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { BookTransactionSchema } from '@/lib/api/schemas'
|
||||
import type { Transaction } from '@/types'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
interface BookRequest {
|
||||
fiscal_period_id: string
|
||||
entry_date: string
|
||||
description: string
|
||||
lines: CreateJournalEntryLineInput[]
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
@@ -27,15 +22,9 @@ export async function POST(
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body: BookRequest = await request.json()
|
||||
const { fiscal_period_id, entry_date, description, lines } = body
|
||||
|
||||
if (!fiscal_period_id || !entry_date || !description || !lines?.length) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing required fields: fiscal_period_id, entry_date, description, lines' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
const validation = await validateBody(request, BookTransactionSchema)
|
||||
if (!validation.success) return validation.response
|
||||
const { fiscal_period_id, entry_date, description, lines } = validation.data
|
||||
|
||||
// Fetch transaction (validates ownership)
|
||||
const { data: transaction, error: fetchError } = await supabase
|
||||
|
||||
@@ -5,17 +5,12 @@ import { ensureInitialized } from '@/lib/init'
|
||||
import { buildMappingResultFromCategory } from '@/lib/bookkeeping/category-mapping'
|
||||
import { createTransactionJournalEntry } from '@/lib/bookkeeping/transaction-entries'
|
||||
import { saveUserMappingRule } from '@/lib/bookkeeping/mapping-engine'
|
||||
import type { Transaction, TransactionCategory, EntityType, VatTreatment } from '@/types'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { CategorizeTransactionSchema } from '@/lib/api/schemas'
|
||||
import type { Transaction, TransactionCategory, EntityType } from '@/types'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
interface CategorizeRequest {
|
||||
is_business: boolean
|
||||
category?: TransactionCategory
|
||||
vat_treatment?: VatTreatment
|
||||
account_override?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure a fiscal period exists for the given date, create one if needed
|
||||
*/
|
||||
@@ -98,8 +93,10 @@ export async function POST(
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Parse request body
|
||||
const body: CategorizeRequest = await request.json()
|
||||
// Parse and validate request body
|
||||
const validation = await validateBody(request, CategorizeTransactionSchema)
|
||||
if (!validation.success) return validation.response
|
||||
const body = validation.data
|
||||
const { is_business, category } = body
|
||||
|
||||
// Fetch the transaction (validates ownership)
|
||||
|
||||
@@ -25,6 +25,9 @@ vi.mock('@/lib/bookkeeping/invoice-entries', () => ({
|
||||
|
||||
import { POST } from '../route'
|
||||
|
||||
const VALID_UUID = '550e8400-e29b-41d4-a716-446655440000'
|
||||
const VALID_UUID_2 = '550e8400-e29b-41d4-a716-446655440001'
|
||||
|
||||
describe('POST /api/transactions/[id]/match-invoice', () => {
|
||||
const mockUser = { id: 'user-1', email: 'test@test.se' }
|
||||
|
||||
@@ -39,7 +42,7 @@ describe('POST /api/transactions/[id]/match-invoice', () => {
|
||||
|
||||
const request = createMockRequest('/api/transactions/tx-1/match-invoice', {
|
||||
method: 'POST',
|
||||
body: { invoice_id: 'inv-1' },
|
||||
body: { invoice_id: VALID_UUID },
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
|
||||
const { status, body } = await parseJsonResponse(response)
|
||||
@@ -57,7 +60,7 @@ describe('POST /api/transactions/[id]/match-invoice', () => {
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(response)
|
||||
|
||||
expect(status).toBe(400)
|
||||
expect(body.error).toBe('invoice_id is required')
|
||||
expect(body.error).toBe('Validation failed')
|
||||
})
|
||||
|
||||
it('returns 404 when transaction not found', async () => {
|
||||
@@ -65,7 +68,7 @@ describe('POST /api/transactions/[id]/match-invoice', () => {
|
||||
|
||||
const request = createMockRequest('/api/transactions/tx-999/match-invoice', {
|
||||
method: 'POST',
|
||||
body: { invoice_id: 'inv-1' },
|
||||
body: { invoice_id: VALID_UUID },
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'tx-999' }))
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(response)
|
||||
@@ -80,7 +83,7 @@ describe('POST /api/transactions/[id]/match-invoice', () => {
|
||||
|
||||
const request = createMockRequest('/api/transactions/tx-1/match-invoice', {
|
||||
method: 'POST',
|
||||
body: { invoice_id: 'inv-1' },
|
||||
body: { invoice_id: VALID_UUID },
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(response)
|
||||
@@ -95,7 +98,7 @@ describe('POST /api/transactions/[id]/match-invoice', () => {
|
||||
|
||||
const request = createMockRequest('/api/transactions/tx-1/match-invoice', {
|
||||
method: 'POST',
|
||||
body: { invoice_id: 'inv-1' },
|
||||
body: { invoice_id: VALID_UUID },
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(response)
|
||||
@@ -111,7 +114,7 @@ describe('POST /api/transactions/[id]/match-invoice', () => {
|
||||
|
||||
const request = createMockRequest('/api/transactions/tx-1/match-invoice', {
|
||||
method: 'POST',
|
||||
body: { invoice_id: 'inv-999' },
|
||||
body: { invoice_id: VALID_UUID_2 },
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(response)
|
||||
@@ -122,13 +125,13 @@ describe('POST /api/transactions/[id]/match-invoice', () => {
|
||||
|
||||
it('returns 400 when invoice is not in unpaid state', async () => {
|
||||
const tx = makeTransaction({ id: 'tx-1', amount: 12500, invoice_id: null })
|
||||
const invoice = makeInvoice({ id: 'inv-1', status: 'paid' })
|
||||
const invoice = makeInvoice({ id: VALID_UUID, status: 'paid' })
|
||||
enqueue({ data: tx, error: null })
|
||||
enqueue({ data: invoice, error: null })
|
||||
|
||||
const request = createMockRequest('/api/transactions/tx-1/match-invoice', {
|
||||
method: 'POST',
|
||||
body: { invoice_id: 'inv-1' },
|
||||
body: { invoice_id: VALID_UUID },
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(response)
|
||||
@@ -141,7 +144,7 @@ describe('POST /api/transactions/[id]/match-invoice', () => {
|
||||
const tx = makeTransaction({ id: 'tx-1', amount: 12500, invoice_id: null, date: '2024-06-15' })
|
||||
const customer = makeCustomer()
|
||||
const invoice = makeInvoice({
|
||||
id: 'inv-1',
|
||||
id: VALID_UUID,
|
||||
status: 'sent',
|
||||
total: 12500,
|
||||
subtotal: 10000,
|
||||
@@ -166,7 +169,7 @@ describe('POST /api/transactions/[id]/match-invoice', () => {
|
||||
|
||||
const request = createMockRequest('/api/transactions/tx-1/match-invoice', {
|
||||
method: 'POST',
|
||||
body: { invoice_id: 'inv-1' },
|
||||
body: { invoice_id: VALID_UUID },
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
@@ -185,14 +188,14 @@ describe('POST /api/transactions/[id]/match-invoice', () => {
|
||||
// Verify accrual payment entry was called
|
||||
expect(mockCreateInvoicePaymentJournalEntry).toHaveBeenCalledWith(
|
||||
'user-1',
|
||||
expect.objectContaining({ id: 'inv-1' }),
|
||||
expect.objectContaining({ id: VALID_UUID }),
|
||||
'2024-06-15'
|
||||
)
|
||||
})
|
||||
|
||||
it('returns success with journal_entry_error when journal entry fails (non-blocking)', async () => {
|
||||
const tx = makeTransaction({ id: 'tx-1', amount: 12500, invoice_id: null, date: '2024-06-15' })
|
||||
const invoice = makeInvoice({ id: 'inv-1', status: 'sent', total: 12500 })
|
||||
const invoice = makeInvoice({ id: VALID_UUID, status: 'sent', total: 12500 })
|
||||
|
||||
enqueue({ data: tx, error: null })
|
||||
enqueue({ data: invoice, error: null })
|
||||
@@ -207,7 +210,7 @@ describe('POST /api/transactions/[id]/match-invoice', () => {
|
||||
|
||||
const request = createMockRequest('/api/transactions/tx-1/match-invoice', {
|
||||
method: 'POST',
|
||||
body: { invoice_id: 'inv-1' },
|
||||
body: { invoice_id: VALID_UUID },
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
|
||||
@@ -4,12 +4,10 @@ import {
|
||||
createInvoicePaymentJournalEntry,
|
||||
createInvoiceCashEntry,
|
||||
} from '@/lib/bookkeeping/invoice-entries'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { MatchInvoiceSchema } from '@/lib/api/schemas'
|
||||
import type { EntityType, Invoice } from '@/types'
|
||||
|
||||
interface MatchInvoiceRequest {
|
||||
invoice_id: string
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/transactions/[id]/match-invoice
|
||||
*
|
||||
@@ -34,13 +32,10 @@ export async function POST(
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Parse request body
|
||||
const body: MatchInvoiceRequest = await request.json()
|
||||
const { invoice_id } = body
|
||||
|
||||
if (!invoice_id) {
|
||||
return NextResponse.json({ error: 'invoice_id is required' }, { status: 400 })
|
||||
}
|
||||
// Parse and validate request body
|
||||
const validation = await validateBody(request, MatchInvoiceSchema)
|
||||
if (!validation.success) return validation.response
|
||||
const { invoice_id } = validation.data
|
||||
|
||||
// Fetch the transaction (validates ownership)
|
||||
const { data: transaction, error: fetchTxError } = await supabase
|
||||
|
||||
@@ -5,12 +5,10 @@ import {
|
||||
createSupplierInvoicePaymentEntry,
|
||||
createSupplierInvoiceCashEntry,
|
||||
} from '@/lib/bookkeeping/supplier-invoice-entries'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { MatchSupplierInvoiceSchema } from '@/lib/api/schemas'
|
||||
import type { SupplierInvoice, SupplierInvoiceItem } from '@/types'
|
||||
|
||||
interface MatchRequest {
|
||||
supplier_invoice_id: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure a fiscal period exists for the given date
|
||||
*/
|
||||
@@ -64,12 +62,9 @@ export async function POST(
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body: MatchRequest = await request.json()
|
||||
const { supplier_invoice_id } = body
|
||||
|
||||
if (!supplier_invoice_id) {
|
||||
return NextResponse.json({ error: 'supplier_invoice_id is required' }, { status: 400 })
|
||||
}
|
||||
const validation = await validateBody(request, MatchSupplierInvoiceSchema)
|
||||
if (!validation.success) return validation.response
|
||||
const { supplier_invoice_id } = validation.data
|
||||
|
||||
// Fetch the transaction
|
||||
const { data: transaction, error: fetchTxError } = await supabase
|
||||
|
||||
Reference in New Issue
Block a user