chore: repo-wide bloat sweep, remove dead code and fold duplicate helpers (#2150)

* chore: repo-wide bloat sweep, remove dead code and fold duplicate helpers

Remove 33 dead files, ~270 unreferenced exports/types, 13 dead i18n
namespaces and 4 unused dependencies; fold byte-identical helper copies
into one canonical home each (lib/utils chunk/sleep/utcDateStamp,
lib/dates/iso, lib/invariants/uuid, lib/xml/escape, lib/reports/sru/format,
lib/pdf/number-text, lib/browser/panel-request, lib/api/v1/body +
v1ValidationError rolled out to ~55 v1 routes, booking-template schemas).

No behaviour change: v1 bodies and status codes, MCP tool schemas, DB
writes and money math are untouched. Naive ore rounding was deliberately
not swapped for roundOre; see DECISIONS.md 2026-09-02 for the full list
of things left alone on purpose.

tsc, lint, 19588 unit tests and check:guards green; antipattern baseline
ratcheted (naive-ore-round 622 -> 620, hand-rolled-invariant 115 -> 113).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* test(transactions): import RawTransaction from @/types after the ingest re-export removal

CI's type ratchet (check:types, full tsconfig) caught the one test file
that still imported the type through lib/transactions/ingest.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-09-02 11:51:16 +02:00
committed by GitHub
parent 18cbc4c30a
commit f266c386f3
359 changed files with 1798 additions and 11201 deletions
+1 -1
View File
@@ -100,7 +100,7 @@ Compact metric cards (e.g. dashboard tiles, salary KPI row) use `p-4`. Detail ca
| Data table, **page-level list** | `components/ui/dry-table.tsx` `TH_CLASS` / `TD_CLASS` on a plain `<table className="w-full border-collapse text-[13px]">`, rows `hover:bg-secondary/35` | The concept list table: borderless, straight on the panel, 13px rows, hairline heads. This is what every migrated list page uses. Add `tabular-nums` to numeric cells. Hover-revealed row controls use `HOVER_REVEAL_CLASS` from the same file, never a hand-rolled `opacity-0 group-hover:opacity-100` (coarse pointers never hover, so the control would be unreachable on touch). |
| Data table, **dialog or report view** | `components/ui/table.tsx` `Table / TableHeader / TableHead / TableRow / TableCell` | Header style is baked in: `text-[11px] font-medium uppercase tracking-wider text-muted-foreground`. Wrap in `<CardContent className="p-0">` when the table is a card's primary content. `TableCell` is `px-4 py-3` on `text-sm`, so a page-level list built from this primitive comes out ~15% taller with a different hover tint: use the dry-table row above instead. |
| Status indicator | `components/ui/badge.tsx` `<Badge variant>` | Chips mark exceptions only: normal states (Aktiv, Bokförd, Betald-i-tid) render as muted text (`text-muted-foreground text-xs`); Badge is reserved for rows that deviate (Utkast, Förfallen, Ej bokförd). A table where every row carries the same chip is wrong. Variants: `default / secondary / success / warning / destructive / outline`. **Never** use raw Tailwind colors (`bg-blue-100`, `bg-emerald-500/10`, etc.) for status. Map status → variant via a small `Record` per feature. |
| No-data state | `components/ui/empty-state.tsx` `EmptyState` | Don't hand-roll `<div className="flex flex-col items-center py-12">…</div>`. Preset variants exist (`EmptyInvoices`, `EmptyCustomers`, `EmptyTransactions`, etc.). |
| No-data state | `components/ui/empty-state.tsx` `EmptyState` | Don't hand-roll `<div className="flex flex-col items-center py-12">…</div>`. Preset variants exist (`EmptyCustomers`, `EmptyByraClients`). |
| Loading placeholder | `components/ui/skeleton.tsx` `<Skeleton>` | Don't hand-roll `bg-muted rounded animate-pulse` divs. |
| Inline help / formulas | `components/ui/info-tooltip.tsx` `InfoTooltip` | Hover-revealed; don't use always-visible info buttons. |
| View/mode switcher in a toolbar | `components/ui/segmented-control.tsx` `SegmentedControl` | Pill-in-pill tablist at the shared `h-8` toolbar height; `options` take an optional `count` for the standard count chip. Never hand-roll the `bg-muted/70` tablist div. |
+1
View File
@@ -1481,6 +1481,7 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-09-01] PR #2130 CodeRabbit P1 (org-number twin inherits a grant): the ombud path binds SKV system-credential access to an org number, and org numbers are public and tenant-editable, so while more than one live (non-archived) company claims the same 12-digit org number NO company may verify, mint a deep link, or be granted by the nightly sync on it (409 ORG_NUMBER_CONTESTED; cron counts them as contested and changes nothing). This does not re-add the company-creation org-number guard (org-number reuse stays allowed); it only fences the one feature where the org number is the authority boundary. Also: the cron honours summarizeGrants.recognized (unknown role codes = pinning problem, never a denial), mirroring probeViaOmbudsregister.
[2026-09-01] ENABLE_BANKING_SANDBOX removed from the enable-banking manifest and the index.ts header (#2131): the variable was declared as optional but never read anywhere; sandbox vs production is decided by ENABLE_BANKING_API_URL (api.tilisy.com vs api.enablebanking.com, api-client.ts derives isSandbox from the host). A dead variable declared in the manifest is what the self-hosting docs would otherwise have copied. The manifest now lists the two optional variables the code actually reads (API_URL, PSU_TYPE); the _PRODUCTION aliases stay undeclared on purpose, they are a hosted Vercel convention, not an operator contract.
[2026-09-02] Repo-wide bloat sweep (chore/bloat-sweep-2026-09): removed dead files/exports/types/i18n namespaces and deduplicated byte-identical helpers into canonical homes (lib/utils chunk/sleep/utcDateStamp, lib/dates/iso, lib/invariants/uuid, lib/xml/escape, lib/reports/sru/format, lib/pdf/number-text pdfAmount/formatDateSv, lib/browser/panel-request, lib/api/v1/body + v1ValidationError, lib/bookkeeping/booking-template-schemas). Deliberately NOT done: naive Math.round(x*100)/100 helpers were not swapped for roundOre (behaviour change at half-ore values, ratchet campaign owns it); lib/bokslut/rounding.ts shim kept because money.test.ts asserts the back-compat re-export; text-based v1 body parsers (empty body allowed) kept inline because readV1JsonBody has different empty-body semantics; the four HTTP endpoints with no first-party caller (skatteverket agi/underlag + agi/sparad DELETE, invoice-inbox items/:id/history, mail connections/backfill) stay because removing a reachable endpoint is a surface change; VacationBalanceCard deleted as unreachable since #1130, the v1/MCP vacation-year-close routes stay.
[2026-09-01] PR #2130 security-scan round: the register's djuplank is validated (https + skatteverket.se host) before it is returned or navigated to, since the settings page follows it; a contested org number now WITHDRAWS an already-recorded grant nightly (not only blocks new ones), outside the downgrade guards on purpose. NOT done: proof of org-number ownership (Bolagsverket firmatecknare / BankID) before any ombud grant; the org number is tenant-editable across the product (AGI, invoices, årsredovisning) and binding it to a verified identity is a product decision for Emil, tracked as a follow-up rather than declined.
[2026-09-02] Viewer write gate as ONE table-level trigger (enforce_company_writer_role) instead of re-emitting 15 SECURITY DEFINER bodies and ~130 policies: keyed on the JWT role claim so it fires inside definer functions too; no-op for service_role and trigger cascades. agent_conversations/agent_messages and telemetry tables deliberately excluded.
[2026-09-02] Posting-integrity guards key on current_user IN ('anon','authenticated'), not the JWT claim: inside SECURITY DEFINER RPCs current_user is the definer, so commit_journal_entry, SIE import, storno and rättelse keep working while direct PostgREST manipulation of posted vouchers is blocked. Residual: a direct draft->posted flip may still reuse an unused number below the sequence high-water mark.
+1 -1
View File
@@ -1,5 +1,6 @@
'use client'
import { UUID_RE } from '@/lib/invariants/uuid'
import { useState, useEffect, useMemo } from 'react'
import dynamic from 'next/dynamic'
import { useRouter, useSearchParams } from 'next/navigation'
@@ -44,7 +45,6 @@ interface NextVoucher {
series: string
}
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
export default function BookkeepingPage() {
const { toast } = useToast()
+1 -1
View File
@@ -58,7 +58,7 @@ import { decodeFileContent } from '@/lib/import/shared/encoding'
import type { BankFileParseResult, BankFileFormatId, BankFileDuplicateInfo, GenericCSVColumnMapping } from '@/lib/import/bank-file/types'
import type { SkattekontoFileParseResult } from '@/lib/import/skattekonto-file/types'
import type { SkattekontoFileImportResult } from '@/components/import/SkattekontoFileResultStep'
import type { IngestResult } from '@/lib/transactions/ingest'
import type { IngestResult } from '@/types'
import type {
ImportWizardStep,
ParsedSIEFile,
@@ -22,15 +22,11 @@ import { useCompany, useCapability } from '@/contexts/CompanyContext'
import { CAPABILITY } from '@/lib/entitlements/keys'
import { getCreditNoteSendMode } from '@/lib/invoices/credit-note-send-mode'
import { creditConfirmNumber } from '@/lib/invoices/display'
import type { Invoice, InvoiceItem, Customer } from '@/types'
import type { InvoiceItem } from '@/types'
import type { InvoiceWithRelations } from '@/components/invoices/types'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
import { InvoiceEditorSkeleton } from '@/components/common/DetailPageSkeleton'
interface InvoiceWithRelations extends Invoice {
customer: Customer
items: InvoiceItem[]
}
export default function CreateCreditNotePage({ params }: { params: Promise<{ id: string }> }) {
const { canWrite } = useCanWrite()
const { isSandbox } = useCompany()
+2 -10
View File
@@ -80,7 +80,8 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import type { Invoice, InvoiceItem, Customer, InvoiceStatus, InvoiceReminder, InvoiceDocumentType } from '@/types'
import type { Invoice, InvoiceItem, InvoiceStatus, InvoiceReminder, InvoiceDocumentType } from '@/types'
import type { InvoiceWithRelations } from '@/components/invoices/types'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
import { useBranding } from '@/lib/branding/brand-context'
import { DetailPageSkeleton } from '@/components/common/DetailPageSkeleton'
@@ -136,15 +137,6 @@ function daysOverdue(dueDateStr: string): number {
return Math.round((today.getTime() - dueDate.getTime()) / (1000 * 60 * 60 * 24))
}
interface InvoiceWithRelations extends Invoice {
customer: Customer
items: InvoiceItem[]
// Optional reference to the issuance verifikation. Populated by the
// backend when the invoice flow auto-books an entry on send; absent on
// older invoices and on companies where issuance is not auto-booked.
journal_entry_id?: string | null
}
export default function InvoiceDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { canWrite } = useCanWrite()
const { company, isSandbox } = useCompany()
@@ -23,7 +23,7 @@ import {
CheckCircle2,
Loader2,
} from 'lucide-react'
import { formatDate } from '@/lib/utils'
import { formatAmount, formatDate } from '@/lib/utils'
import { downloadFile } from '@/lib/browser/download-file'
import { failureDescription } from '@/lib/browser/action-failure'
import type { KassaflodesanalysReport } from '@/lib/reports/kassaflodesanalys'
@@ -32,13 +32,6 @@ import {
type ErrorLocale,
} from '@/lib/errors/get-error-message'
function formatAmount(n: number): string {
return n.toLocaleString('sv-SE', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})
}
interface CashRowProps {
label: string
amount: number
+1 -5
View File
@@ -13,17 +13,13 @@ import { useToast } from '@/components/ui/use-toast'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { ArrowLeft, Lock } from 'lucide-react'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import { formatDate } from '@/lib/utils'
import { formatAmount, formatDate } from '@/lib/utils'
import SupplierForm from '@/components/suppliers/SupplierForm'
import Link from 'next/link'
import { DestructiveConfirmDialog, useDestructiveConfirm } from '@/components/ui/destructive-confirm-dialog'
import type { Supplier, SupplierType, CreateSupplierInput, SupplierInvoice } from '@/types'
import { DetailPageSkeleton } from '@/components/common/DetailPageSkeleton'
function formatAmount(amount: number): string {
return amount.toLocaleString('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
}
// Supplier invoices carry their own currency; "kr" is only correct for SEK.
function amountWithCurrency(amount: number, currency?: string | null): string {
return `${formatAmount(amount)} ${!currency || currency === 'SEK' ? 'kr' : currency}`
+1 -47
View File
@@ -1,5 +1,6 @@
'use client'
import { UUID_RE } from '@/lib/invariants/uuid'
import { useState, useEffect, useMemo, useRef, useCallback } from 'react'
import type { SupabaseClient } from '@supabase/supabase-js'
import dynamic from 'next/dynamic'
@@ -145,7 +146,6 @@ const PERIOD_FILTER_STORAGE_PREFIX = 'Accounted:transactions-fy-scope:v1:'
// string in the underlag-badge effect below. They come from journal_entries.id
// (DB-sourced), but this guard keeps the interpolated list UUID-only, matching
// /api/documents/counts.
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
// A skattekonto row qualifies for bulk booking when the outcome is fully
// deterministic: a rule matched (booking_suggestion), it is not a likely
@@ -381,8 +381,6 @@ export default function TransactionsPage() {
// voucher (salary, Fortnox import, manual entry) with no new bokföring.
const [matchVoucherTx, setMatchVoucherTx] = useState<TransactionWithInvoice | null>(null)
const [bulkBookOpen, setBulkBookOpen] = useState(false)
const [isMatchingSupplierFromPicker, setIsMatchingSupplierFromPicker] = useState(false)
const [isMatchingFromPicker, setIsMatchingFromPicker] = useState(false)
// Quick review dialog (suggestion review before booking)
const [quickReviewOpen, setQuickReviewOpen] = useState(false)
@@ -2394,46 +2392,6 @@ export default function TransactionsPage() {
}
}, [refreshTransactions, t, toast])
async function handleMatchInvoice(transactionId: string, invoiceId: string): Promise<boolean> {
try {
const response = await fetch(`/api/transactions/${transactionId}/match-invoice`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ invoice_id: invoiceId }),
})
const result = await response.json()
if (!response.ok) {
toast({ title: 'Fakturamatchning misslyckades', description: getErrorMessage(result, { context: 'transaction' }), variant: 'destructive' })
return false
}
const transaction = transactions.find((t) => t.id === transactionId)
const invoiceNumber = transaction?.potential_invoice?.invoice_number || ''
setTransactions((prev) =>
prev.map((t) =>
t.id === transactionId
? {
...t,
invoice_id: invoiceId,
potential_invoice_id: null,
potential_invoice: undefined,
is_business: true,
category: 'income_services' as TransactionCategory,
journal_entry_id: result.journal_entry_id,
}
: t
)
)
toast({ title: 'Faktura matchad', description: `Faktura ${invoiceNumber} markerad som betald` })
return true
} catch {
toast({ title: t('match_failed_title'), description: t('match_failed_with_invoice'), variant: 'destructive' })
return false
}
}
function handleSelectInvoiceFromPicker(invoice: Invoice & { customer?: Customer }) {
if (!invoicePickerTransaction) return
// Don't POST directly from the picker. Route through the confirm dialog
@@ -4264,7 +4222,6 @@ export default function TransactionsPage() {
{invoicePickerOpen && <Dialog
open
onOpenChange={(open) => {
if (isMatchingFromPicker) return
setInvoicePickerOpen(open)
if (!open) setInvoicePickerTransaction(null)
}}
@@ -4284,7 +4241,6 @@ export default function TransactionsPage() {
<InvoicePicker
transaction={invoicePickerTransaction}
onSelect={handleSelectInvoiceFromPicker}
isProcessing={isMatchingFromPicker}
/>
</>
)}
@@ -4294,7 +4250,6 @@ export default function TransactionsPage() {
{supplierInvoicePickerOpen && <Dialog
open
onOpenChange={(open) => {
if (isMatchingSupplierFromPicker) return
setSupplierInvoicePickerOpen(open)
if (!open) setSupplierInvoicePickerTransaction(null)
}}
@@ -4314,7 +4269,6 @@ export default function TransactionsPage() {
<SupplierInvoicePicker
transaction={supplierInvoicePickerTransaction}
onSelect={handleSelectSupplierInvoiceFromPicker}
isProcessing={isMatchingSupplierFromPicker}
/>
</>
)}
+1 -1
View File
@@ -8,7 +8,7 @@
*/
import { NextResponse } from 'next/server'
import { skills } from '@/extensions/general/mcp-server/skills'
import { workflowSkills as skills } from '@/extensions/general/mcp-server/skills'
import { API_V1_VERSION } from '@/lib/api/v1/version'
import { withPublicSecurityHeaders } from '@/lib/api/v1/security-headers'
import { getCanonicalBaseUrl } from '@/lib/api/v1/base-url'
@@ -4,6 +4,8 @@ import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { validateBody } from '@/lib/api/validate'
import { markSignatureSigned } from '@/lib/bokslut/arsredovisning/signature-service'
import { getSwedishLocalDate } from '@/lib/bookkeeping/engine'
import { createServiceClient } from '@/lib/supabase/server'
// PATCH transitions: pending → signed (manual entry for the paper / outside-
@@ -34,19 +36,11 @@ const PatchSchema = z.discriminatedUnion('status', [
z.object({ status: z.literal('declined') }).strict(),
])
function stockholmDate(instant: string | Date): string {
return new Intl.DateTimeFormat('sv-SE', {
timeZone: 'Europe/Stockholm',
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).format(typeof instant === 'string' ? new Date(instant) : instant)
}
function isSignatureDateAllowed(signedAt: string, finalizedAt: string): boolean {
const signedDate = stockholmDate(signedAt)
const finalizedDate = stockholmDate(finalizedAt)
const today = stockholmDate(new Date())
const signedDate = getSwedishLocalDate(new Date(signedAt))
const finalizedDate = getSwedishLocalDate(new Date(finalizedAt))
const today = getSwedishLocalDate()
return signedDate >= finalizedDate && signedDate <= today
}
+6 -26
View File
@@ -4,24 +4,13 @@ import { generateCalendarFeed } from '@/lib/calendar/ics-generator'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import { createLogger } from '@/lib/logger'
import type { Deadline, Invoice } from '@/types'
import { createTokenRateLimiter } from '@/lib/api/token-rate-limit'
import { UUID_RE } from '@/lib/invariants/uuid'
const log = createLogger('api/calendar/feed-token')
// In-memory rate limiting: token -> { count, resetAt }
const rateLimitMap = new Map<string, { count: number; resetAt: number }>()
const RATE_LIMIT_WINDOW_MS = 60_000 // 1 minute
const RATE_LIMIT_MAX = 60 // 60 requests per minute per token
// Periodic cleanup to prevent memory leaks (every 5 minutes)
let lastCleanup = Date.now()
function cleanupRateLimitMap() {
const now = Date.now()
if (now - lastCleanup < 5 * 60_000) return
lastCleanup = now
for (const [key, value] of rateLimitMap) {
if (now > value.resetAt) rateLimitMap.delete(key)
}
}
// 60 requests per minute per token, process-local.
const rateLimiter = createTokenRateLimiter({ max: 60, windowMs: 60_000 })
/**
* GET /api/calendar/feed/[token]
@@ -35,23 +24,14 @@ export async function GET(
const { token } = await params
// Validate token format (UUID)
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
if (!uuidRegex.test(token)) {
if (!UUID_RE.test(token)) {
return new NextResponse('Invalid token', { status: 400 })
}
// Rate limiting per token
cleanupRateLimitMap()
const nowMs = Date.now()
const rateEntry = rateLimitMap.get(token)
if (rateEntry && nowMs < rateEntry.resetAt) {
if (rateEntry.count >= RATE_LIMIT_MAX) {
if (!rateLimiter.allow(token)) {
return new NextResponse('Too many requests', { status: 429 })
}
rateEntry.count++
} else {
rateLimitMap.set(token, { count: 1, resetAt: nowMs + RATE_LIMIT_WINDOW_MS })
}
// Create service client (no user auth required)
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL
+3 -2
View File
@@ -6,8 +6,9 @@ import { listForCompany } from '@/lib/cash-accounts/service'
* GET /api/cash-accounts
*
* Returns the active company's cash accounts (cash_accounts table). Used by the
* reconciliation CashAccountSelector (Item 5) and any other surface that needs
* the canonical list of routable cash accounts. UI panels that just display PSD2
* reconciliation surfaces (via useCashAccounts) and any other surface that needs
* the canonical list of routable cash accounts.
UI panels that just display PSD2
* connection state may still read bank_connections.accounts_data until that
* column is dropped in a follow-up migration.
*
@@ -1,5 +1,7 @@
import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { privateNoStore } from '@/lib/api/private-no-store'
import { utcDateStamp } from '@/lib/utils'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import {
estimateArchiveSize,
@@ -19,11 +21,6 @@ interface ResetArchiveRow {
created_at: string
}
function privateNoStore(response: NextResponse): NextResponse {
response.headers.set('Cache-Control', 'private, no-store')
return response
}
/**
* GET /api/company/[id]/migration-reset/archive
*
@@ -159,7 +156,7 @@ export const GET = withRouteContext<Params>(
const zipBuffer = await generateBaseDataArchive(archiveClient, reset.source_company_id, {
include_documents: includeDocuments,
})
const filename = `migration_reset_archive_${formatDateStamp(new Date())}.zip`
const filename = `migration_reset_archive_${utcDateStamp(new Date())}.zip`
log.info('migration reset source archive generated', {
userId: user.id,
@@ -190,10 +187,3 @@ export const GET = withRouteContext<Params>(
}
},
)
function formatDateStamp(date: Date): string {
const year = date.getUTCFullYear()
const month = String(date.getUTCMonth() + 1).padStart(2, '0')
const day = String(date.getUTCDate()).padStart(2, '0')
return `${year}${month}${day}`
}
@@ -1,5 +1,6 @@
import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { privateNoStore } from '@/lib/api/private-no-store'
import { validateBody } from '@/lib/api/validate'
import { CompanyMigrationResetSchema } from '@/lib/api/schemas'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
@@ -30,11 +31,6 @@ function rpcFailure(
})
}
function privateNoStore(response: NextResponse): NextResponse {
response.headers.set('Cache-Control', 'private, no-store')
return response
}
/**
* GET /api/company/[id]/migration-reset
*
@@ -27,14 +27,7 @@
* the nonce is blocked on this response.
*/
function escapeHtml(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
}
import { escapeHtml } from '@/lib/email/user-text'
/**
* Opening chunk: full document head, styles, spinner and heading, plus a
@@ -1,3 +1,4 @@
import { UUID_RE } from '@/lib/invariants/uuid'
import { NextResponse } from 'next/server'
import { createServiceClient } from '@/lib/supabase/server'
import { ensureInitialized } from '@/lib/init'
@@ -71,7 +72,7 @@ export async function POST(request: Request) {
// the DB (the column is typed uuid and would error opaquely).
const isUuid =
state !== null &&
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(state)
UUID_RE.test(state)
if (!isUuid || !consumerKey || !consumerSecret) {
return NextResponse.json({ error: 'Missing parameters' }, { status: 400 })
}
+1 -5
View File
@@ -1,3 +1,4 @@
import { normalizeNameKey as nameKey } from '@/lib/import/shared/column-utils'
import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { eventBus } from '@/lib/events'
@@ -20,11 +21,6 @@ interface ExistingArticle {
article_number: string | null
}
function nameKey(value: string | null): string | null {
if (!value) return null
return value.trim().toLowerCase() || null
}
/**
* POST /api/import/articles/execute
*
+1 -6
View File
@@ -10,16 +10,11 @@ import type {
DetectedArticleColumns,
} from '@/lib/import/articles/types'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
import { normalizeNameKey as nameKey } from '@/lib/import/shared/column-utils'
const ALLOWED_EXTENSIONS = ['.xlsx', '.xls', '.csv', '.ods']
const MAX_FILE_SIZE = 10 * 1024 * 1024 // 10 MB
/** Lowercased dedup key for matching an article by name. */
function nameKey(value: string | null): string | null {
if (!value) return null
return value.trim().toLowerCase() || null
}
/**
* POST /api/import/articles/parse
*
+2 -1
View File
@@ -1,7 +1,8 @@
import { NextResponse } from 'next/server'
import { eventBus } from '@/lib/events'
import { ensureInitialized } from '@/lib/init'
import { ingestTransactions, type RawTransaction } from '@/lib/transactions/ingest'
import { ingestTransactions } from '@/lib/transactions/ingest'
import type { RawTransaction } from '@/types'
import { generateExternalId } from '@/lib/import/bank-file/parser'
import type { IngestOptions } from '@/types'
import { getCompanyRole } from '@/lib/auth/require-write'
+1 -7
View File
@@ -1,6 +1,7 @@
import { NextResponse } from 'next/server'
import { renderToBuffer } from '@react-pdf/renderer'
import { withRouteContext } from '@/lib/api/with-route-context'
import { PRIVATE_NO_STORE_HEADERS, privateNoStore } from '@/lib/api/private-no-store'
import { InvoicePDF } from '@/lib/invoices/pdf-template'
import { prepareInvoicePdfRender, buildSwishQrDataUrl, buildPaymentLinkQrDataUrl } from '@/lib/invoices/pdf-render-helpers'
import { invoicePdfFilename, paymentConfirmationPdfFilename } from '@/lib/invoices/pdf-filename'
@@ -14,8 +15,6 @@ import {
invoiceRequiresPaymentAccount,
} from '@/lib/invoices/payment-accounts'
const PRIVATE_NO_STORE_HEADERS = { 'Cache-Control': 'private, no-store' }
/**
* `?disposition=inline` serves the PDF for in-browser review instead of forcing
* a download (#1190): reviewing an invoice should not mean opening a file from
@@ -38,11 +37,6 @@ function resolveVariant(request: Request): 'invoice' | 'paid' {
return requested === 'paid' ? 'paid' : 'invoice'
}
function privateNoStore(response: NextResponse): NextResponse {
response.headers.set('Cache-Control', 'private, no-store')
return response
}
export const GET = withRouteContext<{ params: Promise<{ id: string }> }>(
'invoice.pdf',
async (request, { supabase, companyId, log, requestId }, { params }) => {
@@ -6,8 +6,7 @@ import { roundOre } from '@/lib/money'
import { resolvePeriodStatusForDate } from '@/lib/core/bookkeeping/period-service'
import { guardSandbox } from '@/lib/sandbox/guard'
import type { Currency, Invoice } from '@/types'
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
import { UUID_RE } from '@/lib/invariants/uuid'
/**
* POST /api/invoices/[id]/refresh-exchange-rate
+1 -7
View File
@@ -1,6 +1,7 @@
import { NextResponse } from 'next/server'
import { renderToBuffer } from '@react-pdf/renderer'
import { withRouteContext } from '@/lib/api/with-route-context'
import { PRIVATE_NO_STORE_HEADERS, privateNoStore } from '@/lib/api/private-no-store'
import { InvoicePDF, type InvoicePdfInvoice } from '@/lib/invoices/pdf-template'
import { prepareInvoicePdfRender, buildSwishQrDataUrl, buildPaymentLinkQrDataUrl } from '@/lib/invoices/pdf-render-helpers'
import { getVatRules } from '@/lib/invoices/vat-rules'
@@ -18,13 +19,6 @@ import {
invoiceRequiresPaymentAccount,
} from '@/lib/invoices/payment-accounts'
const PRIVATE_NO_STORE_HEADERS = { 'Cache-Control': 'private, no-store' }
function privateNoStore(response: NextResponse): NextResponse {
response.headers.set('Cache-Control', 'private, no-store')
return response
}
/** The per-line ROT/RUT fields the editor posts alongside the amounts. */
interface PreviewItemInput {
description: string
+4 -23
View File
@@ -5,21 +5,10 @@ import { resolvePayslipToken, isValidPayslipTokenFormat } from '@/lib/salary/pay
import { buildPayslipData, payslipFileName } from '@/lib/salary/payslips/build-payslip-data'
import { PayslipPDF } from '@/lib/salary/pdf/payslip-template'
import { contentDisposition } from '@/lib/api/content-disposition'
import { createTokenRateLimiter } from '@/lib/api/token-rate-limit'
// In-memory rate limiting per token (pattern from /api/calendar/feed).
const rateLimitMap = new Map<string, { count: number; resetAt: number }>()
const RATE_LIMIT_WINDOW_MS = 60_000
const RATE_LIMIT_MAX = 20
let lastCleanup = Date.now()
function cleanupRateLimitMap() {
const now = Date.now()
if (now - lastCleanup < 5 * 60_000) return
lastCleanup = now
for (const [key, value] of rateLimitMap) {
if (now > value.resetAt) rateLimitMap.delete(key)
}
}
// 20 requests per minute per token, process-local (same limiter as /api/calendar/feed).
const rateLimiter = createTokenRateLimiter({ max: 20, windowMs: 60_000 })
/**
* GET /api/payslip/[token]/pdf
@@ -39,17 +28,9 @@ export async function GET(
return new NextResponse('Invalid token', { status: 400 })
}
cleanupRateLimitMap()
const nowMs = Date.now()
const rateEntry = rateLimitMap.get(token)
if (rateEntry && nowMs < rateEntry.resetAt) {
if (rateEntry.count >= RATE_LIMIT_MAX) {
if (!rateLimiter.allow(token)) {
return new NextResponse('Too many requests', { status: 429 })
}
rateEntry.count++
} else {
rateLimitMap.set(token, { count: 1, resetAt: nowMs + RATE_LIMIT_WINDOW_MS })
}
const serviceClient = createServiceClientNoCookies()
const resolved = await resolvePayslipToken(serviceClient, token)
@@ -2,31 +2,19 @@ import { NextResponse } from 'next/server'
import { z } from 'zod'
import { withRouteContext } from '@/lib/api/with-route-context'
import { validateBody } from '@/lib/api/validate'
import { AccountKeySchema } from '@/lib/reconciliation/schemas'
import {
AccountKeySchema,
reconciliationLinksBodyFields,
reconciliationLinksBodyRefinement,
} from '@/lib/reconciliation/schemas'
import { matchPairs } from '@/lib/reconciliation/actions'
const PairSchema = z.object({
external_ids: z.array(z.string().uuid()).min(1).max(50),
journal_entry_ids: z.array(z.string().uuid()).min(1).max(50),
// Bank 1:N only: the signed slice per verifikat (transaction sign
// convention). Omitted: each slice defaults to the voucher's bank line.
allocations: z
.array(z.object({ journal_entry_id: z.string().uuid(), amount: z.number() }))
.min(2)
.max(50)
.optional(),
})
const ReconciliationLinksBodySchema = z
.object({
pairs: z.array(PairSchema).max(200).optional(),
use_proposals: z.boolean().optional(),
confidence_threshold: z.number().min(0).max(1).optional(),
...reconciliationLinksBodyFields,
dry_run: z.boolean().optional(),
})
.refine((b) => (b.pairs && b.pairs.length > 0) || b.use_proposals === true, {
message: 'Ange pairs eller use_proposals: true.',
})
.refine(...reconciliationLinksBodyRefinement)
/**
* POST /api/reconciliation/accounts/{accountKey}/links
+3 -8
View File
@@ -9,6 +9,7 @@ import {
dateColumn,
integerColumn,
xlsxFilename,
parseCellDate,
} from '@/lib/reports/xlsx-export'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
@@ -35,12 +36,6 @@ interface InvoiceRow {
currency: string
}
function toDate(s: string): Date | null {
if (!s) return null
const d = new Date(s)
return isNaN(d.getTime()) ? null : d
}
export const GET = withRouteContext('report.ar_ledger.xlsx', async (request, { supabase, companyId }) => {
const { searchParams } = new URL(request.url)
const asOfDate = searchParams.get('as_of_date') || undefined
@@ -70,8 +65,8 @@ export const GET = withRouteContext('report.ar_ledger.xlsx', async (request, { s
invoiceRows.push({
customer_name: e.customer_name,
invoice_number: inv.invoice_number,
invoice_date: toDate(inv.invoice_date) ?? inv.invoice_date,
due_date: toDate(inv.due_date) ?? inv.due_date,
invoice_date: parseCellDate(inv.invoice_date) ?? inv.invoice_date,
due_date: parseCellDate(inv.due_date) ?? inv.due_date,
total: inv.total,
paid_amount: inv.paid_amount,
outstanding: inv.outstanding,
+3 -14
View File
@@ -5,6 +5,8 @@ import {
type ArchiveScope,
} from '@/lib/reports/full-archive-export'
import { withRouteContext } from '@/lib/api/with-route-context'
import { PRIVATE_NO_STORE_HEADERS, privateNoStore } from '@/lib/api/private-no-store'
import { utcDateStamp } from '@/lib/utils'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { createServiceClient } from '@/lib/supabase/server'
@@ -13,12 +15,6 @@ export const runtime = 'nodejs'
export const maxDuration = 300
const SIZE_LIMIT_BYTES = 80 * 1024 * 1024
const PRIVATE_NO_STORE_HEADERS = { 'Cache-Control': 'private, no-store' }
function privateNoStore(response: NextResponse): NextResponse {
response.headers.set('Cache-Control', 'private, no-store')
return response
}
export const GET = withRouteContext('report.full_archive', async (request, ctx) => {
const { supabase, companyId, user, log, requestId } = ctx
@@ -138,7 +134,7 @@ export const GET = withRouteContext('report.full_archive', async (request, ctx)
const filename =
scope === 'period'
? `arkiv_${periodId}.zip`
: `arkiv_full_${companyId}_${formatDateStamp(new Date())}.zip`
: `arkiv_full_${companyId}_${utcDateStamp(new Date())}.zip`
log.info('full archive generated', {
userId: user.id,
@@ -172,10 +168,3 @@ export const GET = withRouteContext('report.full_archive', async (request, ctx)
)
}
})
function formatDateStamp(d: Date): string {
const y = d.getUTCFullYear()
const m = String(d.getUTCMonth() + 1).padStart(2, '0')
const day = String(d.getUTCDate()).padStart(2, '0')
return `${y}${m}${day}`
}
+4 -8
View File
@@ -9,6 +9,7 @@ import {
currencyColumn,
dateColumn,
xlsxFilename,
parseCellDate,
} from '@/lib/reports/xlsx-export'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
@@ -24,13 +25,6 @@ interface FlatRow {
balance: number
}
function toDate(s: string): Date | string {
// Preserve original ISO string in the cell if parsing fails (avoids NaN
// dates polluting the workbook).
const d = new Date(s)
return isNaN(d.getTime()) ? s : d
}
export const GET = withRouteContext('report.general_ledger.xlsx', async (request, { supabase, companyId }) => {
const { searchParams } = new URL(request.url)
const periodId = searchParams.get('period_id')
@@ -96,7 +90,9 @@ export const GET = withRouteContext('report.general_ledger.xlsx', async (request
rows.push({
account_number: acc.account_number,
account_name: acc.account_name,
date: toDate(line.date),
// Preserve the original ISO string in the cell if parsing fails (avoids
// NaN dates polluting the workbook).
date: parseCellDate(line.date) ?? line.date,
voucher: `${line.voucher_series}${line.voucher_number}`,
description: line.description,
source_type: line.source_type,
@@ -6,8 +6,10 @@ import {
textColumn,
currencyColumn,
dateColumn,
parseCellDate,
xlsxFilename,
} from '@/lib/reports/xlsx-export'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
interface FlatRow {
@@ -22,11 +24,6 @@ interface FlatRow {
credit: number
}
function toDate(s: string): Date | null {
if (!s) return null
const d = new Date(s)
return isNaN(d.getTime()) ? null : d
}
export const GET = withRouteContext('report.journal_register.xlsx', async (request, { supabase, companyId }) => {
const { searchParams } = new URL(request.url)
@@ -53,7 +50,8 @@ export const GET = withRouteContext('report.journal_register.xlsx', async (reque
for (const line of entry.lines) {
rows.push({
voucher: voucherLabel,
date: toDate(entry.date),
date: parseCellDate(entry.date),
description: entry.description,
source_type: entry.source_type,
status: entry.status,
+2 -7
View File
@@ -8,15 +8,10 @@ import {
dateColumn,
integerColumn,
xlsxFilename,
parseCellDate,
} from '@/lib/reports/xlsx-export'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
function toDate(s: string): Date | null {
if (!s) return null
const d = new Date(s)
return isNaN(d.getTime()) ? null : d
}
export const GET = withRouteContext('report.salary_journal.xlsx', async (request, { supabase, companyId }) => {
const { searchParams } = new URL(request.url)
const year = parseInt(searchParams.get('year') || new Date().getFullYear().toString())
@@ -62,7 +57,7 @@ export const GET = withRouteContext('report.salary_journal.xlsx', async (request
r.employmentType,
r.periodYear,
r.periodMonth,
toDate(r.paymentDate),
parseCellDate(r.paymentDate),
r.grossSalary,
r.taxWithheld,
r.netSalary,
+13
View File
@@ -0,0 +1,13 @@
/**
* Date helpers shared by the sandbox seed modules. Seed-internal: the seed
* works in local-calendar parts on purpose (no toISOString UTC shift).
*/
export function pad2(n: number): string {
return String(n).padStart(2, '0')
}
/** Last calendar day of a 1-12 month: day 0 of the next month, leap years included. */
export function lastDayOfMonth(year: number, month: number): number {
return new Date(year, month, 0).getDate()
}
+1 -5
View File
@@ -40,6 +40,7 @@
*/
import { roundOre } from '@/lib/money'
import { lastDayOfMonth } from './date-utils'
/**
* Every BAS account this builder can emit. Exported so the seed's
@@ -196,11 +197,6 @@ function vatOf(net: number, vatRate: number): number {
return roundOre((net * vatRate) / 100)
}
/** Day 0 of the next month is the last day of this one, leap years included. */
function lastDayOfMonth(year: number, month: number): number {
return new Date(year, month, 0).getDate()
}
/**
* Deterministic per-month variation. `(month * factor) % cycle` walks the cycle
* in a non-obvious order, so the amounts look hand-entered without any
+1 -9
View File
@@ -30,6 +30,7 @@
*/
import { roundOre } from '@/lib/money'
import { lastDayOfMonth, pad2 } from './date-utils'
import { FALLBACK_TAX_TABLES_2026 } from '@/lib/salary/tax-tables-fallback'
import { getLineItemAccount } from '@/lib/salary/account-mapping'
import type { SalaryLineItemType } from '@/types'
@@ -155,20 +156,11 @@ export const SANDBOX_EMPLOYEE_LAST_NAMES = {
// Date helpers (pure: every one takes its reference date as an argument)
// ============================================================
function pad2(n: number): string {
return String(n).padStart(2, '0')
}
/** Local-calendar YYYY-MM-DD, same convention as route.ts's toDateStr. */
function toDateString(year: number, month: number, day: number): string {
return `${year}-${pad2(month)}-${pad2(day)}`
}
/** Last calendar day of a 1-12 month. */
function lastDayOfMonth(year: number, month: number): number {
return new Date(year, month, 0).getDate()
}
/**
* Shift a date by whole calendar months, clamping the day to the target
* month's length so 31 March minus 1 month is 28/29 February, never 3 March.
@@ -1,3 +1,4 @@
import { BookingTemplateLineSchema, BookingTemplateCategorySchema, BookingTemplateEntityTypeSchema } from '@/lib/bookkeeping/booking-template-schemas'
import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { z } from 'zod'
@@ -5,24 +6,11 @@ import { validateBody } from '@/lib/api/validate'
import { sparsePatchBody } from '@/lib/api/sparse-patch'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
const BookingTemplateLineSchema = z.object({
account: z.string().regex(/^\d{4}$/),
label: z.string().min(1),
side: z.enum(['debit', 'credit']),
type: z.enum(['business', 'vat', 'settlement']),
ratio: z.number().min(0).max(10).optional(),
vat_rate: z.number().min(0).max(1).optional(),
})
const UpdateBookingTemplateSchema = z.object({
name: z.string().min(1).max(200).optional(),
description: z.string().max(2000).optional(),
category: z.enum([
'eu_trade', 'tax_account', 'private_transfer',
'salary', 'representation', 'year_end',
'vat', 'financial', 'other',
]).optional(),
entity_type: z.enum(['all', 'enskild_firma', 'aktiebolag']).optional(),
category: BookingTemplateCategorySchema.optional(),
entity_type: BookingTemplateEntityTypeSchema.optional(),
lines: z.array(BookingTemplateLineSchema).min(2).optional(),
})
@@ -1,27 +1,15 @@
import { BookingTemplateLineSchema, BookingTemplateCategorySchema, BookingTemplateEntityTypeSchema } from '@/lib/bookkeeping/booking-template-schemas'
import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { z } from 'zod'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
const ImportLineSchema = z.object({
account: z.string().regex(/^\d{4}$/),
label: z.string().min(1),
side: z.enum(['debit', 'credit']),
type: z.enum(['business', 'vat', 'settlement']),
ratio: z.number().min(0).max(10).optional(),
vat_rate: z.number().min(0).max(1).optional(),
})
const ImportTemplateSchema = z.object({
name: z.string().min(1).max(200),
description: z.string().max(2000).default(''),
category: z.enum([
'eu_trade', 'tax_account', 'private_transfer',
'salary', 'representation', 'year_end',
'vat', 'financial', 'other',
]).default('other'),
entity_type: z.enum(['all', 'enskild_firma', 'aktiebolag']).default('all'),
lines: z.array(ImportLineSchema).min(2),
category: BookingTemplateCategorySchema.default('other'),
entity_type: BookingTemplateEntityTypeSchema.default('all'),
lines: z.array(BookingTemplateLineSchema).min(2),
})
const ImportPayloadSchema = z.object({
+12 -19
View File
@@ -3,31 +3,24 @@ import { withRouteContext } from '@/lib/api/with-route-context'
import { z } from 'zod'
import { validateBody } from '@/lib/api/validate'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
import { UUID_RE } from '@/lib/invariants/uuid'
import {
BookingTemplateCategorySchema,
BookingTemplateEntityTypeSchema,
BookingTemplateLineSchema,
} from '@/lib/bookkeeping/booking-template-schemas'
// 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),
side: z.enum(['debit', 'credit']),
type: z.enum(['business', 'vat', 'settlement']),
ratio: z.number().min(0).max(10).optional(),
vat_rate: z.number().min(0).max(1).optional(),
})
// Guard every interpolated id against a strict UUID shape (UUID_RE) 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 CreateBookingTemplateSchema = z.object({
name: z.string().min(1).max(200),
description: z.string().max(2000).default(''),
category: z.enum([
'eu_trade', 'tax_account', 'private_transfer',
'salary', 'representation', 'year_end',
'vat', 'financial', 'other',
]).default('other'),
entity_type: z.enum(['all', 'enskild_firma', 'aktiebolag']).default('all'),
category: BookingTemplateCategorySchema.default('other'),
entity_type: BookingTemplateEntityTypeSchema.default('all'),
lines: z.array(BookingTemplateLineSchema).min(2),
team_id: z.string().uuid().optional(),
})
@@ -10,7 +10,7 @@ import { fetchAllRows } from '@/lib/supabase/fetch-all'
import { ok } from '@/lib/api/v1/response'
import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry'
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
import { v1ErrorResponse, v1ValidationError } from '@/lib/api/v1/errors'
const Account = z.object({
account_number: z.string(),
@@ -92,17 +92,7 @@ export const GET = withApiV1<{ params: Promise<{ companyId: string }> }>(
class: url.searchParams.get('class') ?? undefined,
active: url.searchParams.get('active') ?? undefined,
})
if (!parsed.success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: {
issues: parsed.error.issues.map((i) => ({
field: i.path.join('.'),
message: i.message,
})),
},
})
}
if (!parsed.success) return v1ValidationError(ctx, parsed.error)
const f = parsed.data
const activeOnly = f.active !== 'false'
@@ -9,7 +9,7 @@ import { z } from 'zod'
import { ok } from '@/lib/api/v1/response'
import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry'
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
import { v1ErrorResponse, v1ValidationError } from '@/lib/api/v1/errors'
import { listForCompany } from '@/lib/cash-accounts/service'
const CashAccount = z.object({
@@ -84,17 +84,7 @@ export const GET = withApiV1<{ params: Promise<{ companyId: string }> }>(
const parsed = Filters.safeParse({
enabled_only: url.searchParams.get('enabled_only') ?? undefined,
})
if (!parsed.success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: {
issues: parsed.error.issues.map((i) => ({
field: i.path.join('.'),
message: i.message,
})),
},
})
}
if (!parsed.success) return v1ValidationError(ctx, parsed.error)
try {
const rows = await listForCompany(ctx.supabase, ctx.companyId!, {
@@ -18,7 +18,8 @@ import { dryRunPreview } from '@/lib/api/v1/dry-run'
import { parseExpand } from '@/lib/api/v1/expand'
import { registerEndpoint, dataEnvelope, NoBodyResponse } from '@/lib/api/v1/registry'
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
import { v1ErrorResponse, v1ErrorResponseFromCode, v1ValidationError } from '@/lib/api/v1/errors'
import { readV1JsonBody } from '@/lib/api/v1/body'
import { UpdateCustomerSchema } from '@/lib/api/schemas'
import { validateVatNumber } from '@/lib/vat/vies-client'
import {
@@ -286,28 +287,12 @@ export const PATCH = withApiV1<{ params: Promise<{ companyId: string; id: string
}
const customerId = idParse.data
let rawBody: unknown
try {
rawBody = await request.json()
} catch {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { field: 'body', message: 'Body is not valid JSON.' },
})
}
const rawBodyResult = await readV1JsonBody(request, ctx)
if (!rawBodyResult.ok) return rawBodyResult.response
const rawBody = rawBodyResult.body
const parsed = V1PatchCustomerSchema.safeParse(rawBody)
if (!parsed.success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: {
issues: parsed.error.issues.map((i) => ({
field: i.path.join('.'),
message: i.message,
})),
},
})
}
if (!parsed.success) return v1ValidationError(ctx, parsed.error)
const body = parsed.data
// Mirrors the internal PATCH route: every read path returns the masked
@@ -19,7 +19,8 @@ import { ok } from '@/lib/api/v1/response'
import { dryRunPreview } from '@/lib/api/v1/dry-run'
import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry'
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
import { v1ErrorResponseFromCode, v1ValidationError } from '@/lib/api/v1/errors'
import { readV1JsonBody } from '@/lib/api/v1/body'
import { CreateCustomerSchema } from '@/lib/api/schemas'
import { validateVatNumber } from '@/lib/vat/vies-client'
import { encryptCustomerPersonalNumber } from '@/lib/customers/protect-personal-number'
@@ -266,28 +267,12 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>(
})
}
let rawBody: unknown
try {
rawBody = await request.json()
} catch {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { field: 'body', message: 'Body is not valid JSON.' },
})
}
const rawBodyResult = await readV1JsonBody(request, ctx)
if (!rawBodyResult.ok) return rawBodyResult.response
const rawBody = rawBodyResult.body
const parsed = BulkCreateRequest.safeParse(rawBody)
if (!parsed.success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: {
issues: parsed.error.issues.map((i) => ({
field: i.path.join('.'),
message: i.message,
})),
},
})
}
if (!parsed.success) return v1ValidationError(ctx, parsed.error)
const body = parsed.data
// Reject all_or_nothing: true loudly. Same contract as invoices/bulk-create.
@@ -18,7 +18,8 @@ import {
} from '@/lib/api/v1/pagination'
import { registerEndpoint, listEnvelope, dataEnvelope } from '@/lib/api/v1/registry'
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
import { v1ErrorResponse, v1ErrorResponseFromCode, v1ValidationError } from '@/lib/api/v1/errors'
import { readV1JsonBody } from '@/lib/api/v1/body'
import { CreateCustomerSchema } from '@/lib/api/schemas'
import { validateVatNumber } from '@/lib/vat/vies-client'
import {
@@ -118,15 +119,7 @@ export const GET = withApiV1<{ params: Promise<{ companyId: string }> }>(
include_archived: url.searchParams.get('include_archived') ?? undefined,
})
if (!filtersResult.success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: {
issues: filtersResult.error.issues.map((i) => ({
field: i.path.join('.'),
message: i.message,
})),
},
})
return v1ValidationError(ctx, filtersResult.error)
}
const filters = filtersResult.data
const includeArchived = filters.include_archived === 'true'
@@ -322,27 +315,12 @@ registerEndpoint({
export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>(
'customers.create',
async (request, ctx) => {
let rawBody: unknown
try {
rawBody = await request.json()
} catch {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { field: 'body', message: 'Body is not valid JSON.' },
})
}
const raw = await readV1JsonBody(request, ctx)
if (!raw.ok) return raw.response
const parsed = CreateCustomerSchema.safeParse(rawBody)
const parsed = CreateCustomerSchema.safeParse(raw.body)
if (!parsed.success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: {
issues: parsed.error.issues.map((i) => ({
field: i.path.join('.'),
message: i.message,
})),
},
})
return v1ValidationError(ctx, parsed.error)
}
const body = parsed.data
@@ -17,7 +17,8 @@ import { ok } from '@/lib/api/v1/response'
import { dryRunPreview } from '@/lib/api/v1/dry-run'
import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry'
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
import { v1ErrorResponse, v1ErrorResponseFromCode, v1ValidationError } from '@/lib/api/v1/errors'
import { readV1JsonBody } from '@/lib/api/v1/body'
import { UpdateDimensionValueSchema } from '@/lib/api/schemas'
const DimensionValueShape = z.object({
@@ -124,28 +125,12 @@ export const PATCH = withApiV1<ValueRouteParams>(
})
}
let rawBody: unknown
try {
rawBody = await request.json()
} catch {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { field: 'body', message: 'Body is not valid JSON.' },
})
}
const rawBodyResult = await readV1JsonBody(request, ctx)
if (!rawBodyResult.ok) return rawBodyResult.response
const rawBody = rawBodyResult.body
const parsed = UpdateDimensionValueSchema.safeParse(rawBody)
if (!parsed.success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: {
issues: parsed.error.issues.map((i) => ({
field: i.path.join('.'),
message: i.message,
})),
},
})
}
if (!parsed.success) return v1ValidationError(ctx, parsed.error)
const body = parsed.data
// Value dates only make sense on accumulating dimensions (projekt-style
@@ -12,7 +12,8 @@ import { created } from '@/lib/api/v1/response'
import { dryRunPreview } from '@/lib/api/v1/dry-run'
import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry'
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
import { v1ErrorResponse, v1ErrorResponseFromCode, v1ValidationError } from '@/lib/api/v1/errors'
import { readV1JsonBody } from '@/lib/api/v1/body'
import { CreateDimensionValueSchema } from '@/lib/api/schemas'
const DimensionValueCreated = z.object({
@@ -80,28 +81,12 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
})
}
let rawBody: unknown
try {
rawBody = await request.json()
} catch {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { field: 'body', message: 'Body is not valid JSON.' },
})
}
const rawBodyResult = await readV1JsonBody(request, ctx)
if (!rawBodyResult.ok) return rawBodyResult.response
const rawBody = rawBodyResult.body
const parsed = CreateDimensionValueSchema.safeParse(rawBody)
if (!parsed.success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: {
issues: parsed.error.issues.map((i) => ({
field: i.path.join('.'),
message: i.message,
})),
},
})
}
if (!parsed.success) return v1ValidationError(ctx, parsed.error)
const body = parsed.data
// The dimension must exist and belong to the company in the URL.
@@ -25,7 +25,8 @@ import { ok } from '@/lib/api/v1/response'
import { dryRunPreview } from '@/lib/api/v1/dry-run'
import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry'
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
import { v1ErrorResponse, v1ErrorResponseFromCode, v1ValidationError } from '@/lib/api/v1/errors'
import { readV1JsonBody } from '@/lib/api/v1/body'
import { linkToJournalEntry } from '@/lib/core/documents/document-service'
const Body = z
@@ -94,22 +95,11 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
}
const documentId = idParse.data
let rawBody: unknown
try {
rawBody = await request.json()
} catch {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { field: 'body', message: 'Body is not valid JSON.' },
})
}
const rawBodyResult = await readV1JsonBody(request, ctx)
if (!rawBodyResult.ok) return rawBodyResult.response
const rawBody = rawBodyResult.body
const parsed = Body.safeParse(rawBody)
if (!parsed.success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { issues: parsed.error.issues.map((i) => ({ field: i.path.join('.'), message: i.message })) },
})
}
if (!parsed.success) return v1ValidationError(ctx, parsed.error)
const body = parsed.data
// Ownership pre-check: document AND target JE must both belong to the
@@ -18,7 +18,8 @@ import { ok } from '@/lib/api/v1/response'
import { dryRunPreview } from '@/lib/api/v1/dry-run'
import { registerEndpoint, dataEnvelope, listEnvelope } from '@/lib/api/v1/registry'
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
import { v1ErrorResponseFromCode, v1ValidationError } from '@/lib/api/v1/errors'
import { readV1JsonBody } from '@/lib/api/v1/body'
import { AbsenceTypeSchema } from '@/lib/api/schemas'
import {
ABSENCE_RANGE_MAX_DAYS,
@@ -104,17 +105,7 @@ export const GET = withApiV1<{ params: Promise<{ companyId: string; id: string }
to: url.searchParams.get('to') ?? undefined,
type: url.searchParams.get('type') ?? undefined,
})
if (!parsed.success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: {
issues: parsed.error.issues.map((i) => ({
field: i.path.join('.'),
message: i.message,
})),
},
})
}
if (!parsed.success) return v1ValidationError(ctx, parsed.error)
const { from, to, type } = parsed.data
// Same cap as writes: the bounded range is the pagination contract.
@@ -213,28 +204,12 @@ export const PUT = withApiV1<{ params: Promise<{ companyId: string; id: string }
})
}
let rawBody: unknown
try {
rawBody = await request.json()
} catch {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { field: 'body', message: 'Body is not valid JSON.' },
})
}
const rawBodyResult = await readV1JsonBody(request, ctx)
if (!rawBodyResult.ok) return rawBodyResult.response
const rawBody = rawBodyResult.body
const parsed = UpsertRangeBody.safeParse(rawBody)
if (!parsed.success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: {
issues: parsed.error.issues.map((i) => ({
field: i.path.join('.'),
message: i.message,
})),
},
})
}
if (!parsed.success) return v1ValidationError(ctx, parsed.error)
const body = parsed.data
const result = await upsertAbsenceRange(ctx.supabase, {
@@ -325,17 +300,7 @@ export const DELETE = withApiV1<{ params: Promise<{ companyId: string; id: strin
to: url.searchParams.get('to') ?? undefined,
type: url.searchParams.get('type') ?? undefined,
})
if (!parsed.success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: {
issues: parsed.error.issues.map((i) => ({
field: i.path.join('.'),
message: i.message,
})),
},
})
}
if (!parsed.success) return v1ValidationError(ctx, parsed.error)
const { from, to, type } = parsed.data
const result = await deleteAbsenceRange(ctx.supabase, {
@@ -20,7 +20,8 @@ import { ok } from '@/lib/api/v1/response'
import { dryRunPreview } from '@/lib/api/v1/dry-run'
import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry'
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
import { v1ErrorResponseFromCode, v1ValidationError } from '@/lib/api/v1/errors'
import { readV1JsonBody } from '@/lib/api/v1/body'
import { OpeningBalancesFieldsSchema } from '@/lib/api/schemas'
import { getOpeningBalances, setOpeningBalancesBulk } from '@/lib/salary/opening-balances'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
@@ -164,28 +165,12 @@ export const PUT = withApiV1<{ params: Promise<{ companyId: string; id: string }
})
}
let rawBody: unknown
try {
rawBody = await request.json()
} catch {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { field: 'body', message: 'Body is not valid JSON.' },
})
}
const rawBodyResult = await readV1JsonBody(request, ctx)
if (!rawBodyResult.ok) return rawBodyResult.response
const rawBody = rawBodyResult.body
const parsed = OpeningBalancesFieldsSchema.safeParse(rawBody)
if (!parsed.success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: {
issues: parsed.error.issues.map((i) => ({
field: i.path.join('.'),
message: i.message,
})),
},
})
}
if (!parsed.success) return v1ValidationError(ctx, parsed.error)
// The per-employee PUT is the bulk handler with one item: one validation
// and one upsert path to maintain.
@@ -22,7 +22,8 @@ import { ok, noContent } from '@/lib/api/v1/response'
import { dryRunPreview } from '@/lib/api/v1/dry-run'
import { registerEndpoint, dataEnvelope, NoBodyResponse } from '@/lib/api/v1/registry'
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
import { v1ErrorResponse, v1ErrorResponseFromCode, v1ValidationError } from '@/lib/api/v1/errors'
import { readV1JsonBody } from '@/lib/api/v1/body'
import { UpdateEmployeeSchema } from '@/lib/api/schemas'
import { maskPersonnummer } from '@/lib/api/v1/mask-personnummer'
import { decryptPersonnummer } from '@/lib/salary/personnummer'
@@ -239,15 +240,9 @@ export const PATCH = withApiV1<{ params: Promise<{ companyId: string; id: string
})
}
let rawBody: unknown
try {
rawBody = await request.json()
} catch {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { field: 'body', message: 'Body is not valid JSON.' },
})
}
const rawBodyResult = await readV1JsonBody(request, ctx)
if (!rawBodyResult.ok) return rawBodyResult.response
const rawBody = rawBodyResult.body
// OWASP V4.5: require a plain JSON object. Zod would catch a non-object
// body downstream, but the rawKeys filter below uses Object.keys on
@@ -283,17 +278,7 @@ export const PATCH = withApiV1<{ params: Promise<{ companyId: string; id: string
}
const parsed = UpdateEmployeeSchema.safeParse(rawBody)
if (!parsed.success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: {
issues: parsed.error.issues.map((i) => ({
field: i.path.join('.'),
message: i.message,
})),
},
})
}
if (!parsed.success) return v1ValidationError(ctx, parsed.error)
const body = parsed.data
// Fetch the existing row so dry-run + the eventual update see merged state
@@ -14,7 +14,8 @@ import { ok } from '@/lib/api/v1/response'
import { dryRunPreview } from '@/lib/api/v1/dry-run'
import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry'
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
import { v1ErrorResponse, v1ErrorResponseFromCode, v1ValidationError } from '@/lib/api/v1/errors'
import { readV1JsonBody } from '@/lib/api/v1/body'
import { OpeningBalancesBulkSchema } from '@/lib/api/schemas'
import { setOpeningBalancesBulk } from '@/lib/salary/opening-balances'
@@ -69,28 +70,12 @@ registerEndpoint({
export const PUT = withApiV1<{ params: Promise<{ companyId: string }> }>(
'employees.opening-balances.bulk-set',
async (request, ctx) => {
let rawBody: unknown
try {
rawBody = await request.json()
} catch {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { field: 'body', message: 'Body is not valid JSON.' },
})
}
const rawBodyResult = await readV1JsonBody(request, ctx)
if (!rawBodyResult.ok) return rawBodyResult.response
const rawBody = rawBodyResult.body
const parsed = OpeningBalancesBulkSchema.safeParse(rawBody)
if (!parsed.success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: {
issues: parsed.error.issues.map((i) => ({
field: i.path.join('.'),
message: i.message,
})),
},
})
}
if (!parsed.success) return v1ValidationError(ctx, parsed.error)
const result = await setOpeningBalancesBulk(ctx.supabase, {
companyId: ctx.companyId!,
@@ -26,7 +26,8 @@ import {
} from '@/lib/api/v1/pagination'
import { registerEndpoint, listEnvelope, dataEnvelope } from '@/lib/api/v1/registry'
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
import { v1ErrorResponse, v1ErrorResponseFromCode, v1ValidationError } from '@/lib/api/v1/errors'
import { readV1JsonBody } from '@/lib/api/v1/body'
import { CreateEmployeeSchema } from '@/lib/api/schemas'
import { maskPersonnummer } from '@/lib/api/v1/mask-personnummer'
import { decryptPersonnummer, encryptPersonnummer, validatePersonnummer } from '@/lib/salary/personnummer'
@@ -126,17 +127,7 @@ export const GET = withApiV1<{ params: Promise<{ companyId: string }> }>(
search: url.searchParams.get('search') ?? undefined,
include_inactive: url.searchParams.get('include_inactive') ?? undefined,
})
if (!filtersResult.success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: {
issues: filtersResult.error.issues.map((i) => ({
field: i.path.join('.'),
message: i.message,
})),
},
})
}
if (!filtersResult.success) return v1ValidationError(ctx, filtersResult.error)
const filters = filtersResult.data
const includeInactive = filters.include_inactive === 'true'
@@ -331,28 +322,12 @@ const EMPLOYEE_RESPONSE_COLUMNS =
export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>(
'employees.create',
async (request, ctx) => {
let rawBody: unknown
try {
rawBody = await request.json()
} catch {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { field: 'body', message: 'Body is not valid JSON.' },
})
}
const rawBodyResult = await readV1JsonBody(request, ctx)
if (!rawBodyResult.ok) return rawBodyResult.response
const rawBody = rawBodyResult.body
const parsed = CreateEmployeeSchema.safeParse(rawBody)
if (!parsed.success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: {
issues: parsed.error.issues.map((i) => ({
field: i.path.join('.'),
message: i.message,
})),
},
})
}
if (!parsed.success) return v1ValidationError(ctx, parsed.error)
const body = parsed.data
// CreateEmployeeSchema only checks the shape (12 digits), so on its own it
@@ -14,7 +14,7 @@ import { z } from 'zod'
import { accepted } from '@/lib/api/v1/response'
import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry'
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
import { v1ErrorResponseFromCode, v1ValidationError } from '@/lib/api/v1/errors'
import { ownsFiscalPeriod } from '@/lib/api/v1/owns-fiscal-period'
import { startOperation, completeOperation, failOperation } from '@/lib/api/v1/operations'
import { executeCurrencyRevaluation } from '@/lib/bookkeeping/currency-revaluation'
@@ -90,12 +90,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
}
if (rawBody) {
const parsed = Body.safeParse(rawBody)
if (!parsed.success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { issues: parsed.error.issues.map((i) => ({ field: i.path.join('.'), message: i.message })) },
})
}
if (!parsed.success) return v1ValidationError(ctx, parsed.error)
bodyAsOfDate = parsed.data.as_of_date
}
@@ -14,7 +14,7 @@ import { z } from 'zod'
import { ok } from '@/lib/api/v1/response'
import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry'
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
import { v1ErrorResponseFromCode, v1ValidationError } from '@/lib/api/v1/errors'
import { ownsFiscalPeriod } from '@/lib/api/v1/owns-fiscal-period'
import { generateOpeningBalances } from '@/lib/core/bookkeeping/year-end-service'
import { getErrorMessage } from '@/lib/errors/get-error-message'
@@ -81,12 +81,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
})
}
const parsed = Body.safeParse(rawBody)
if (!parsed.success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { issues: parsed.error.issues.map((i) => ({ field: i.path.join('.'), message: i.message })) },
})
}
if (!parsed.success) return v1ValidationError(ctx, parsed.error)
// Ownership pre-check on BOTH ids: the closed period (URL) and the next
// period (body). The wrapper has already verified the user's membership
@@ -36,7 +36,8 @@ import {
generateFileHash,
generateExternalId,
} from '@/lib/import/bank-file/parser'
import { ingestTransactions, type RawTransaction } from '@/lib/transactions/ingest'
import { ingestTransactions } from '@/lib/transactions/ingest'
import type { RawTransaction } from '@/types'
import { decodeFileContent } from '@/lib/import/shared/encoding'
import type { BankFileFormatId } from '@/lib/import/bank-file/types'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
@@ -26,7 +26,7 @@ import { z } from 'zod'
import { accepted } from '@/lib/api/v1/response'
import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry'
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
import { v1ErrorResponseFromCode, v1ValidationError } from '@/lib/api/v1/errors'
import {
startOperation,
completeOperation,
@@ -165,17 +165,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>(
// belt-and-suspenders.
.strict()
.safeParse(parsedOptions)
if (!optionsParse.success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: {
issues: optionsParse.error.issues.map((i) => ({
field: i.path.join('.'),
message: i.message,
})),
},
})
}
if (!optionsParse.success) return v1ValidationError(ctx, optionsParse.error)
const options = optionsParse.data
// Decode + parse + hash. These are all sync / fast: done before
@@ -19,7 +19,8 @@ import { z } from 'zod'
import { ok } from '@/lib/api/v1/response'
import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry'
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
import { v1ErrorResponseFromCode, v1ValidationError } from '@/lib/api/v1/errors'
import { readV1JsonBody } from '@/lib/api/v1/body'
const Body = z
.object({
@@ -80,22 +81,11 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
}
const itemId = idParse.data
let rawBody: unknown
try {
rawBody = await request.json()
} catch {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { field: 'body', message: 'Body is not valid JSON.' },
})
}
const rawBodyResult = await readV1JsonBody(request, ctx)
if (!rawBodyResult.ok) return rawBodyResult.response
const rawBody = rawBodyResult.body
const parsed = Body.safeParse(rawBody)
if (!parsed.success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { issues: parsed.error.issues.map((i) => ({ field: i.path.join('.'), message: i.message })) },
})
}
if (!parsed.success) return v1ValidationError(ctx, parsed.error)
const body = parsed.data
const [itemRes, jeRes] = await Promise.all([
@@ -29,7 +29,7 @@ import { created } from '@/lib/api/v1/response'
import { dryRunPreview } from '@/lib/api/v1/dry-run'
import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry'
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
import { v1ErrorResponse, v1ErrorResponseFromCode, v1ValidationError } from '@/lib/api/v1/errors'
import { createCreditNoteJournalEntry } from '@/lib/bookkeeping/invoice-entries'
import { eventBus } from '@/lib/events'
import type { AccountingMethod, CreditNote, EntityType, Invoice } from '@/types'
@@ -138,17 +138,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
let reason: string | undefined
if (rawBody) {
const parsed = CreditNoteRequest.safeParse(rawBody)
if (!parsed.success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: {
issues: parsed.error.issues.map((i) => ({
field: i.path.join('.'),
message: i.message,
})),
},
})
}
if (!parsed.success) return v1ValidationError(ctx, parsed.error)
reason = parsed.data.reason
}
@@ -31,7 +31,7 @@ import { ok } from '@/lib/api/v1/response'
import { dryRunPreview } from '@/lib/api/v1/dry-run'
import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry'
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
import { v1ErrorResponse, v1ErrorResponseFromCode, v1ValidationError } from '@/lib/api/v1/errors'
import { MarkInvoicePaidSchema } from '@/lib/api/schemas'
import {
createInvoiceCashEntry,
@@ -162,17 +162,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
let force = false
if (rawBody) {
const parsed = MarkInvoicePaidSchema.safeParse(rawBody)
if (!parsed.success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: {
issues: parsed.error.issues.map((i) => ({
field: i.path.join('.'),
message: i.message,
})),
},
})
}
if (!parsed.success) return v1ValidationError(ctx, parsed.error)
exchangeRateDifference = parsed.data.exchange_rate_difference
bodyPaymentDate = parsed.data.payment_date
customLines = parsed.data.lines
@@ -28,7 +28,8 @@ import { dryRunPreview } from '@/lib/api/v1/dry-run'
import { parseExpand } from '@/lib/api/v1/expand'
import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry'
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
import { v1ErrorResponse, v1ErrorResponseFromCode, v1ValidationError } from '@/lib/api/v1/errors'
import { readV1JsonBody } from '@/lib/api/v1/body'
import { INVOICE_FULL_COLUMNS, INVOICE_ITEM_FULL_COLUMNS } from '@/lib/api/v1/invoice-columns'
import { DimensionsBagSchema } from '@/lib/bookkeeping/dimension-resolver'
import { CreateInvoiceItemSchema } from '@/lib/api/schemas'
@@ -255,28 +256,12 @@ export const PATCH = withApiV1<{ params: Promise<{ companyId: string; id: string
}
const invoiceId = idParse.data
let rawBody: unknown
try {
rawBody = await request.json()
} catch {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { field: 'body', message: 'Body is not valid JSON.' },
})
}
const rawBodyResult = await readV1JsonBody(request, ctx)
if (!rawBodyResult.ok) return rawBodyResult.response
const rawBody = rawBodyResult.body
const parsed = V1PatchDraftInvoiceSchema.safeParse(rawBody)
if (!parsed.success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: {
issues: parsed.error.issues.map((i) => ({
field: i.path.join('.'),
message: i.message,
})),
},
})
}
if (!parsed.success) return v1ValidationError(ctx, parsed.error)
const body = parsed.data
const updateData: Record<string, unknown> = {}
@@ -45,7 +45,7 @@ import { ok } from '@/lib/api/v1/response'
import { dryRunPreview } from '@/lib/api/v1/dry-run'
import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry'
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
import { v1ErrorResponse, v1ErrorResponseFromCode, v1ValidationError } from '@/lib/api/v1/errors'
import { InvoicePDF } from '@/lib/invoices/pdf-template'
import { prepareInvoicePdfRender, buildSwishQrDataUrl, buildPaymentLinkQrDataUrl } from '@/lib/invoices/pdf-render-helpers'
import { applyPaymentLinkToInvoice } from '@/lib/extensions/payment-links'
@@ -271,17 +271,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
}
const bodyResult = InvoiceSendBody.safeParse(rawBody)
if (!bodyResult.success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: {
issues: bodyResult.error.issues.map((issue) => ({
field: issue.path.join('.'),
message: issue.message,
})),
},
})
}
if (!bodyResult.success) return v1ValidationError(ctx, bodyResult.error)
// Reject delivery notes: they have a different (D-series) lifecycle.
if (typed.document_type === 'delivery_note') {
@@ -38,7 +38,8 @@ import { ok } from '@/lib/api/v1/response'
import { dryRunPreview } from '@/lib/api/v1/dry-run'
import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry'
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
import { v1ErrorResponseFromCode, v1ValidationError } from '@/lib/api/v1/errors'
import { readV1JsonBody } from '@/lib/api/v1/body'
import { CreateInvoiceSchema } from '@/lib/api/schemas'
import { getPermittedVatRates, getVatRules } from '@/lib/invoices/vat-rules'
import { convertToSEK, fetchExchangeRate } from '@/lib/currency/riksbanken'
@@ -427,28 +428,12 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>(
})
}
let rawBody: unknown
try {
rawBody = await request.json()
} catch {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { field: 'body', message: 'Body is not valid JSON.' },
})
}
const rawBodyResult = await readV1JsonBody(request, ctx)
if (!rawBodyResult.ok) return rawBodyResult.response
const rawBody = rawBodyResult.body
const parsed = BulkCreateRequest.safeParse(rawBody)
if (!parsed.success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: {
issues: parsed.error.issues.map((i) => ({
field: i.path.join('.'),
message: i.message,
})),
},
})
}
if (!parsed.success) return v1ValidationError(ctx, parsed.error)
const body = parsed.data
// Reject all_or_nothing: true loudly. The schema accepts it for forward
@@ -25,7 +25,8 @@ import {
import { parseExpand } from '@/lib/api/v1/expand'
import { registerEndpoint, listEnvelope, dataEnvelope } from '@/lib/api/v1/registry'
import { withApiV1, type ApiV1Context } from '@/lib/api/v1/with-api-v1'
import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
import { v1ErrorResponse, v1ErrorResponseFromCode, v1ValidationError } from '@/lib/api/v1/errors'
import { readV1JsonBody } from '@/lib/api/v1/body'
import { CreateInvoiceSchema } from '@/lib/api/schemas'
import { INVOICE_FULL_COLUMNS, INVOICE_ITEM_FULL_COLUMNS } from '@/lib/api/v1/invoice-columns'
import { buildInvoiceWriteData } from '@/lib/invoices/build-invoice-write'
@@ -229,15 +230,7 @@ export const GET = withApiV1<{ params: Promise<{ companyId: string }> }>(
date_to: url.searchParams.get('date_to') ?? undefined,
})
if (!filtersResult.success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: {
issues: filtersResult.error.issues.map((i) => ({
field: i.path.join('.'),
message: i.message,
})),
},
})
return v1ValidationError(ctx, filtersResult.error)
}
const filters = filtersResult.data
@@ -455,27 +448,12 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>(
})
}
let rawBody: unknown
try {
rawBody = await request.json()
} catch {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { field: 'body', message: 'Body is not valid JSON.' },
})
}
const raw = await readV1JsonBody(request, ctx)
if (!raw.ok) return raw.response
const parsed = CreateInvoiceSchema.safeParse(rawBody)
const parsed = CreateInvoiceSchema.safeParse(raw.body)
if (!parsed.success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: {
issues: parsed.error.issues.map((i) => ({
field: i.path.join('.'),
message: i.message,
})),
},
})
return v1ValidationError(ctx, parsed.error)
}
const input = parsed.data
@@ -622,8 +600,8 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>(
const { invoiceFields, items: itemRows } = build
// Dry-run: validation-only preview. Drafts have no journal-entry side
// effects yet, so no pending_operations staging needed; the
// dryRunStaged() variant lands in PR-B-2b for :send.
// effects yet, so no pending_operations staging needed; a staged
// preview variant belongs to :send.
if (ctx.dryRun) {
// Never echo the encrypted personnummer blob in a preview; last4 is
// the display-safe representation the response columns expose too.
@@ -21,7 +21,8 @@ import { ok } from '@/lib/api/v1/response'
import { dryRunPreview } from '@/lib/api/v1/dry-run'
import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry'
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
import { v1ErrorResponse, v1ErrorResponseFromCode, v1ValidationError } from '@/lib/api/v1/errors'
import { readV1JsonBody } from '@/lib/api/v1/body'
import { checkPeriodLock } from '@/lib/api/v1/check-period-lock'
import { CorrectJournalEntrySchema } from '@/lib/api/schemas'
import { validateBalance } from '@/lib/bookkeeping/engine'
@@ -97,22 +98,11 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
}
const entryId = idParse.data
let rawBody: unknown
try {
rawBody = await request.json()
} catch {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { field: 'body', message: 'Body is not valid JSON.' },
})
}
const rawBodyResult = await readV1JsonBody(request, ctx)
if (!rawBodyResult.ok) return rawBodyResult.response
const rawBody = rawBodyResult.body
const parsed = CorrectJournalEntrySchema.safeParse(rawBody)
if (!parsed.success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { issues: parsed.error.issues.map((i) => ({ field: i.path.join('.'), message: i.message })) },
})
}
if (!parsed.success) return v1ValidationError(ctx, parsed.error)
const { lines, description, allow_deep_chain } = parsed.data
const balance = validateBalance(lines)
@@ -18,7 +18,7 @@ import { ok } from '@/lib/api/v1/response'
import { dryRunPreview } from '@/lib/api/v1/dry-run'
import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry'
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
import { v1ErrorResponse, v1ErrorResponseFromCode, v1ValidationError } from '@/lib/api/v1/errors'
import { checkPeriodLock } from '@/lib/api/v1/check-period-lock'
import { reverseEntry } from '@/lib/bookkeeping/engine'
import { correctionChainDepth, CORRECTION_CHAIN_GUARD_DEPTH } from '@/lib/core/bookkeeping/correction-chain'
@@ -106,12 +106,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
}
if (rawBody) {
const parsed = ReverseRequest.safeParse(rawBody)
if (!parsed.success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { issues: parsed.error.issues.map((i) => ({ field: i.path.join('.'), message: i.message })) },
})
}
if (!parsed.success) return v1ValidationError(ctx, parsed.error)
bodyReversalDate = parsed.data.reversal_date
bodyAllowDeepChain = parsed.data.allow_deep_chain === true
}
@@ -19,7 +19,8 @@ import { ok } from '@/lib/api/v1/response'
import { dryRunPreview } from '@/lib/api/v1/dry-run'
import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry'
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
import { v1ErrorResponseFromCode, v1ValidationError } from '@/lib/api/v1/errors'
import { readV1JsonBody } from '@/lib/api/v1/body'
import { ownsFiscalPeriod } from '@/lib/api/v1/owns-fiscal-period'
import { CreateJournalEntrySchema } from '@/lib/api/schemas'
import { createDraftEntry } from '@/lib/bookkeeping/engine'
@@ -158,22 +159,11 @@ async function createOne(
export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>(
'journal-entries.batch-create',
async (request, ctx) => {
let rawBody: unknown
try {
rawBody = await request.json()
} catch {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { field: 'body', message: 'Body is not valid JSON.' },
})
}
const rawBodyResult = await readV1JsonBody(request, ctx)
if (!rawBodyResult.ok) return rawBodyResult.response
const rawBody = rawBodyResult.body
const parsed = BulkRequest.safeParse(rawBody)
if (!parsed.success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { issues: parsed.error.issues.map((i) => ({ field: i.path.join('.'), message: i.message })) },
})
}
if (!parsed.success) return v1ValidationError(ctx, parsed.error)
const body = parsed.data
if (body.all_or_nothing) {
@@ -23,7 +23,8 @@ import {
} from '@/lib/api/v1/pagination'
import { registerEndpoint, listEnvelope, dataEnvelope } from '@/lib/api/v1/registry'
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
import { v1ErrorResponse, v1ErrorResponseFromCode, v1ValidationError } from '@/lib/api/v1/errors'
import { readV1JsonBody } from '@/lib/api/v1/body'
import { checkPeriodLock } from '@/lib/api/v1/check-period-lock'
import { ownsFiscalPeriod } from '@/lib/api/v1/owns-fiscal-period'
import { CreateJournalEntrySchema } from '@/lib/api/schemas'
@@ -135,12 +136,7 @@ export const GET = withApiV1<{ params: Promise<{ companyId: string }> }>(
date_from: url.searchParams.get('date_from') ?? undefined,
date_to: url.searchParams.get('date_to') ?? undefined,
})
if (!fr.success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { issues: fr.error.issues.map((i) => ({ field: i.path.join('.'), message: i.message })) },
})
}
if (!fr.success) return v1ValidationError(ctx, fr.error)
const filters = fr.data
// Sort by (created_at DESC, id ASC). created_at is the stable cursor
@@ -267,23 +263,12 @@ registerEndpoint({
export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>(
'journal-entries.create-draft',
async (request, ctx) => {
let rawBody: unknown
try {
rawBody = await request.json()
} catch {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { field: 'body', message: 'Body is not valid JSON.' },
})
}
const rawBodyResult = await readV1JsonBody(request, ctx)
if (!rawBodyResult.ok) return rawBodyResult.response
const rawBody = rawBodyResult.body
const parsed = CreateJournalEntrySchema.safeParse(rawBody)
if (!parsed.success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { issues: parsed.error.issues.map((i) => ({ field: i.path.join('.'), message: i.message })) },
})
}
if (!parsed.success) return v1ValidationError(ctx, parsed.error)
const input = parsed.data
// Ownership pre-check: the caller-supplied fiscal_period_id must belong
@@ -10,7 +10,7 @@ import { ok } from '@/lib/api/v1/response'
import { dryRunPreview } from '@/lib/api/v1/dry-run'
import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry'
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
import { v1ErrorResponse, v1ErrorResponseFromCode, v1ValidationError } from '@/lib/api/v1/errors'
import { AccountKeySchema } from '@/lib/reconciliation/schemas'
import { setItemIgnored } from '@/lib/reconciliation/actions'
@@ -69,14 +69,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; accountKey:
})
}
const parsed = IgnoreRequest.safeParse(body)
if (!parsed.success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: {
issues: parsed.error.issues.map((i) => ({ field: i.path.join('.'), message: i.message })),
},
})
}
if (!parsed.success) return v1ValidationError(ctx, parsed.error)
const ignored = parsed.data.ignored ?? true
try {
if (ctx.dryRun) {
@@ -8,7 +8,7 @@ import { z } from 'zod'
import { ok } from '@/lib/api/v1/response'
import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry'
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
import { v1ErrorResponse, v1ErrorResponseFromCode, v1ValidationError } from '@/lib/api/v1/errors'
import {
AccountKeySchema,
ReconciliationItemBucketSchema,
@@ -127,14 +127,7 @@ export const GET = withApiV1<{ params: Promise<{ companyId: string; accountKey:
limit: url.searchParams.get('limit') ?? undefined,
cursor: url.searchParams.get('cursor') ?? undefined,
})
if (!parsed.success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: {
issues: parsed.error.issues.map((i) => ({ field: i.path.join('.'), message: i.message })),
},
})
}
if (!parsed.success) return v1ValidationError(ctx, parsed.error)
const offset = decodeOffsetCursor(parsed.data.cursor)
if (offset === null) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
@@ -11,29 +11,17 @@ import { ok } from '@/lib/api/v1/response'
import { dryRunPreview } from '@/lib/api/v1/dry-run'
import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry'
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
import { AccountKeySchema } from '@/lib/reconciliation/schemas'
import { v1ErrorResponse, v1ErrorResponseFromCode, v1ValidationError } from '@/lib/api/v1/errors'
import { readV1JsonBody } from '@/lib/api/v1/body'
import {
AccountKeySchema,
ReconciliationPairSchema as PairSchema,
reconciliationLinksBodyFields,
reconciliationLinksBodyRefinement,
} from '@/lib/reconciliation/schemas'
import { matchPairs } from '@/lib/reconciliation/actions'
const PairSchema = z.object({
external_ids: z.array(z.string().uuid()).min(1).max(50),
journal_entry_ids: z.array(z.string().uuid()).min(1).max(50),
allocations: z
.array(z.object({ journal_entry_id: z.string().uuid(), amount: z.number() }))
.min(2)
.max(50)
.optional(),
})
const LinksRequest = z
.object({
pairs: z.array(PairSchema).max(200).optional(),
use_proposals: z.boolean().optional(),
confidence_threshold: z.number().min(0).max(1).optional(),
})
.refine((b) => (b.pairs && b.pairs.length > 0) || b.use_proposals === true, {
message: 'Ange pairs eller use_proposals: true.',
})
const LinksRequest = z.object(reconciliationLinksBodyFields).refine(...reconciliationLinksBodyRefinement)
const LinksResponse = z.object({
dry_run: z.boolean(),
@@ -111,24 +99,11 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; accountKey:
details: { field: 'accountKey', message: 'Okänt konto.' },
})
}
let rawBody: unknown
try {
rawBody = await request.json()
} catch {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { field: 'body', message: 'Body is not valid JSON.' },
})
}
const rawBodyResult = await readV1JsonBody(request, ctx)
if (!rawBodyResult.ok) return rawBodyResult.response
const rawBody = rawBodyResult.body
const parsed = LinksRequest.safeParse(rawBody)
if (!parsed.success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: {
issues: parsed.error.issues.map((i) => ({ field: i.path.join('.'), message: i.message })),
},
})
}
if (!parsed.success) return v1ValidationError(ctx, parsed.error)
try {
const result = await matchPairs(
ctx.supabase,
@@ -12,7 +12,8 @@ import { ok } from '@/lib/api/v1/response'
import { dryRunPreview } from '@/lib/api/v1/dry-run'
import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry'
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
import { v1ErrorResponse, v1ErrorResponseFromCode, v1ValidationError } from '@/lib/api/v1/errors'
import { readV1JsonBody } from '@/lib/api/v1/body'
import { AccountKeySchema } from '@/lib/reconciliation/schemas'
import { bookResidualAndLink, ReconciliationResidualError, RESIDUAL_MAX_AMOUNT } from '@/lib/reconciliation/residual'
import { getErrorMessage } from '@/lib/errors/get-error-message'
@@ -101,22 +102,11 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; accountKey:
details: { field: 'accountKey', message: 'Okänt konto.' },
})
}
let rawBody: unknown
try {
rawBody = await request.json()
} catch {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { field: 'body', message: 'Body is not valid JSON.' },
})
}
const rawBodyResult = await readV1JsonBody(request, ctx)
if (!rawBodyResult.ok) return rawBodyResult.response
const rawBody = rawBodyResult.body
const parsed = ResidualRequest.safeParse(rawBody)
if (!parsed.success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { issues: parsed.error.issues.map((i) => ({ field: i.path.join('.'), message: i.message })) },
})
}
if (!parsed.success) return v1ValidationError(ctx, parsed.error)
try {
const result = await bookResidualAndLink(ctx.supabase, ctx.companyId!, ctx.userId, accountKey, parsed.data, {
dryRun: ctx.dryRun,
@@ -8,7 +8,7 @@ import { z } from 'zod'
import { ok } from '@/lib/api/v1/response'
import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry'
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
import { v1ErrorResponse, v1ErrorResponseFromCode, v1ValidationError } from '@/lib/api/v1/errors'
import { ISO_DATE_RE } from '@/lib/invariants'
import { AccountKeySchema, ReconciliationStatusSchema } from '@/lib/reconciliation/schemas'
import { getAccountStatus } from '@/lib/reconciliation/service'
@@ -87,14 +87,7 @@ export const GET = withApiV1<{ params: Promise<{ companyId: string; accountKey:
date_from: url.searchParams.get('date_from') ?? undefined,
date_to: url.searchParams.get('date_to') ?? undefined,
})
if (!parsed.success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: {
issues: parsed.error.issues.map((i) => ({ field: i.path.join('.'), message: i.message })),
},
})
}
if (!parsed.success) return v1ValidationError(ctx, parsed.error)
try {
const status = await getAccountStatus(ctx.supabase, ctx.companyId!, accountKey, {
windowFrom: parsed.data.date_from ?? null,
@@ -9,7 +9,7 @@ import { ok } from '@/lib/api/v1/response'
import { dryRunPreview } from '@/lib/api/v1/dry-run'
import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry'
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
import { v1ErrorResponse, v1ErrorResponseFromCode, v1ValidationError } from '@/lib/api/v1/errors'
import { AccountKeySchema, ReconciliationSignoffSchema } from '@/lib/reconciliation/schemas'
import { ReconciliationSignoffError, reopenSignoff } from '@/lib/reconciliation/signoff'
import { getErrorMessage } from '@/lib/errors/get-error-message'
@@ -82,14 +82,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; accountKey:
})
}
const parsed = ReopenRequest.safeParse(rawBody)
if (!parsed.success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: {
issues: parsed.error.issues.map((i) => ({ field: i.path.join('.'), message: i.message })),
},
})
}
if (!parsed.success) return v1ValidationError(ctx, parsed.error)
try {
if (ctx.dryRun) {
return dryRunPreview(
@@ -13,7 +13,8 @@ import { ok } from '@/lib/api/v1/response'
import { dryRunPreview } from '@/lib/api/v1/dry-run'
import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry'
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
import { v1ErrorResponse, v1ErrorResponseFromCode, v1ValidationError } from '@/lib/api/v1/errors'
import { readV1JsonBody } from '@/lib/api/v1/body'
import { AccountKeySchema, ReconciliationSignoffSchema } from '@/lib/reconciliation/schemas'
import { listSignoffs } from '@/lib/reconciliation/signoff-store'
import { ReconciliationSignoffError, signOffAccount } from '@/lib/reconciliation/signoff'
@@ -146,24 +147,11 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; accountKey:
details: { field: 'accountKey', message: 'Okänt konto.' },
})
}
let rawBody: unknown
try {
rawBody = await request.json()
} catch {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { field: 'body', message: 'Body is not valid JSON.' },
})
}
const rawBodyResult = await readV1JsonBody(request, ctx)
if (!rawBodyResult.ok) return rawBodyResult.response
const rawBody = rawBodyResult.body
const parsed = SignoffRequest.safeParse(rawBody)
if (!parsed.success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: {
issues: parsed.error.issues.map((i) => ({ field: i.path.join('.'), message: i.message })),
},
})
}
if (!parsed.success) return v1ValidationError(ctx, parsed.error)
try {
const result = await signOffAccount(
ctx.supabase,
@@ -11,7 +11,7 @@ import { z } from 'zod'
import { ok } from '@/lib/api/v1/response'
import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry'
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
import { v1ErrorResponse, v1ValidationError } from '@/lib/api/v1/errors'
import { ISO_DATE_RE } from '@/lib/invariants'
import { ReconciliationAccountSchema } from '@/lib/reconciliation/schemas'
import { listReconciliationAccounts } from '@/lib/reconciliation/service'
@@ -100,14 +100,7 @@ export const GET = withApiV1<{ params: Promise<{ companyId: string }> }>(
date_to: url.searchParams.get('date_to') ?? undefined,
with_status: url.searchParams.get('with_status') ?? undefined,
})
if (!parsed.success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: {
issues: parsed.error.issues.map((i) => ({ field: i.path.join('.'), message: i.message })),
},
})
}
if (!parsed.success) return v1ValidationError(ctx, parsed.error)
try {
const accounts = await listReconciliationAccounts(ctx.supabase, ctx.companyId!, {
windowFrom: parsed.data.date_from,
@@ -14,7 +14,7 @@ import { ok } from '@/lib/api/v1/response'
import { dryRunPreview } from '@/lib/api/v1/dry-run'
import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry'
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
import { v1ErrorResponse, v1ErrorResponseFromCode, v1ValidationError } from '@/lib/api/v1/errors'
import { runReconciliation } from '@/lib/reconciliation/bank-reconciliation'
const RunRequest = z
@@ -117,17 +117,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>(
rawBody = {}
}
const parsed = RunRequest.safeParse(rawBody)
if (!parsed.success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: {
issues: parsed.error.issues.map((i) => ({
field: i.path.join('.'),
message: i.message,
})),
},
})
}
if (!parsed.success) return v1ValidationError(ctx, parsed.error)
const body = parsed.data
// Resolve the settlement account to its cash account (currency + id) so the
@@ -9,7 +9,7 @@ import { z } from 'zod'
import { ok } from '@/lib/api/v1/response'
import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry'
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
import { v1ErrorResponse, v1ErrorResponseFromCode, v1ValidationError } from '@/lib/api/v1/errors'
import { getReconciliationStatus } from '@/lib/reconciliation/bank-reconciliation'
// Mirrors ReconciliationStatus from lib/reconciliation/bank-reconciliation.ts:
@@ -110,17 +110,7 @@ export const GET = withApiV1<{ params: Promise<{ companyId: string }> }>(
date_to: url.searchParams.get('date_to') ?? undefined,
account_number: url.searchParams.get('account_number') ?? undefined,
})
if (!parsed.success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: {
issues: parsed.error.issues.map((i) => ({
field: i.path.join('.'),
message: i.message,
})),
},
})
}
if (!parsed.success) return v1ValidationError(ctx, parsed.error)
const accountNumber = parsed.data.account_number ?? '1930'
const { data: cashAccount } = await ctx.supabase
@@ -10,7 +10,7 @@ import { z } from 'zod'
import { ok } from '@/lib/api/v1/response'
import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry'
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
import { v1ValidationError } from '@/lib/api/v1/errors'
import { safeGenerate } from '@/lib/api/v1/report-period'
import { calculateVatDeclaration } from '@/lib/reports/vat-declaration'
import type { VatPeriodType } from '@/types'
@@ -112,17 +112,7 @@ export const GET = withApiV1<{ params: Promise<{ companyId: string }> }>(
period: url.searchParams.get('period'),
accounting_method: url.searchParams.get('accounting_method') ?? undefined,
})
if (!filters.success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: {
issues: filters.error.issues.map((i) => ({
field: i.path.join('.'),
message: i.message,
})),
},
})
}
if (!filters.success) return v1ValidationError(ctx, filters.error)
// accounting_method is still accepted (public API back-compat) but has no
// effect: see the invariant note on calculateVatDeclaration.
const { period_type, year, period } = filters.data
@@ -15,7 +15,8 @@ import { created } from '@/lib/api/v1/response'
import { dryRunPreview } from '@/lib/api/v1/dry-run'
import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry'
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
import { v1ErrorResponseFromCode, v1ValidationError } from '@/lib/api/v1/errors'
import { readV1JsonBody } from '@/lib/api/v1/body'
import { CreateSalaryLineItemSchema } from '@/lib/api/schemas'
import { createPayslipLine } from '@/lib/salary/payslip-lines'
@@ -97,28 +98,12 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string;
})
}
let rawBody: unknown
try {
rawBody = await request.json()
} catch {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { field: 'body', message: 'Body is not valid JSON.' },
})
}
const rawBodyResult = await readV1JsonBody(request, ctx)
if (!rawBodyResult.ok) return rawBodyResult.response
const rawBody = rawBodyResult.body
const parsed = CreateLineBody.safeParse(rawBody)
if (!parsed.success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: {
issues: parsed.error.issues.map((i) => ({
field: i.path.join('.'),
message: i.message,
})),
},
})
}
if (!parsed.success) return v1ValidationError(ctx, parsed.error)
const result = await createPayslipLine(ctx.supabase, {
companyId: ctx.companyId!,
@@ -24,7 +24,8 @@ import {
} from '@/lib/api/v1/pagination'
import { registerEndpoint, listEnvelope, dataEnvelope } from '@/lib/api/v1/registry'
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
import { v1ErrorResponse, v1ErrorResponseFromCode, v1ValidationError } from '@/lib/api/v1/errors'
import { readV1JsonBody } from '@/lib/api/v1/body'
import { maskPersonnummer } from '@/lib/api/v1/mask-personnummer'
import { decryptPersonnummer } from '@/lib/salary/personnummer'
import { AddEmployeeToRunSchema } from '@/lib/api/schemas'
@@ -300,28 +301,12 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
})
}
let rawBody: unknown
try {
rawBody = await request.json()
} catch {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { field: 'body', message: 'Body is not valid JSON.' },
})
}
const rawBodyResult = await readV1JsonBody(request, ctx)
if (!rawBodyResult.ok) return rawBodyResult.response
const rawBody = rawBodyResult.body
const parsed = AddEmployeeToRunSchema.safeParse(rawBody)
if (!parsed.success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: {
issues: parsed.error.issues.map((i) => ({
field: i.path.join('.'),
message: i.message,
})),
},
})
}
if (!parsed.success) return v1ValidationError(ctx, parsed.error)
const result = await addEmployeeToRun(ctx.supabase, {
companyId: ctx.companyId!,
@@ -15,7 +15,8 @@ import { ok, noContent } from '@/lib/api/v1/response'
import { dryRunPreview } from '@/lib/api/v1/dry-run'
import { registerEndpoint, dataEnvelope, NoBodyResponse } from '@/lib/api/v1/registry'
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
import { v1ErrorResponseFromCode, v1ValidationError } from '@/lib/api/v1/errors'
import { readV1JsonBody } from '@/lib/api/v1/body'
import { UpdateSalaryLineItemSchema } from '@/lib/api/schemas'
import { updatePayslipLine, deletePayslipLine } from '@/lib/salary/payslip-lines'
@@ -100,28 +101,12 @@ export const PATCH = withApiV1<{ params: Promise<{ companyId: string; id: string
})
}
let rawBody: unknown
try {
rawBody = await request.json()
} catch {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { field: 'body', message: 'Body is not valid JSON.' },
})
}
const rawBodyResult = await readV1JsonBody(request, ctx)
if (!rawBodyResult.ok) return rawBodyResult.response
const rawBody = rawBodyResult.body
const parsed = UpdateSalaryLineItemSchema.safeParse(rawBody)
if (!parsed.success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: {
issues: parsed.error.issues.map((i) => ({
field: i.path.join('.'),
message: i.message,
})),
},
})
}
if (!parsed.success) return v1ValidationError(ctx, parsed.error)
// Zod .default() materializes flags the caller never sent; strip anything
// not explicitly present so a PATCH can't silently reset flags. (Same
@@ -16,7 +16,8 @@ import { ok, noContent } from '@/lib/api/v1/response'
import { dryRunPreview } from '@/lib/api/v1/dry-run'
import { registerEndpoint, dataEnvelope, NoBodyResponse } from '@/lib/api/v1/registry'
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
import { v1ErrorResponse, v1ErrorResponseFromCode, v1ValidationError } from '@/lib/api/v1/errors'
import { readV1JsonBody } from '@/lib/api/v1/body'
// Inline; the project's shared isoDate is not exported from lib/api/schemas.
const isoDate = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Expected YYYY-MM-DD date format')
@@ -177,15 +178,9 @@ export const PATCH = withApiV1<{ params: Promise<{ companyId: string; id: string
})
}
let rawBody: unknown
try {
rawBody = await request.json()
} catch {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { field: 'body', message: 'Body is not valid JSON.' },
})
}
const rawBodyResult = await readV1JsonBody(request, ctx)
if (!rawBodyResult.ok) return rawBodyResult.response
const rawBody = rawBodyResult.body
// OWASP V4.5: require a plain JSON object. Zod would catch a non-object
// body downstream, but the rawKeys filter below uses Object.keys on
@@ -199,17 +194,7 @@ export const PATCH = withApiV1<{ params: Promise<{ companyId: string; id: string
}
const parsed = UpdateSalaryRunSchema.safeParse(rawBody)
if (!parsed.success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: {
issues: parsed.error.issues.map((i) => ({
field: i.path.join('.'),
message: i.message,
})),
},
})
}
if (!parsed.success) return v1ValidationError(ctx, parsed.error)
const body = parsed.data
const { data: existing, error: fetchErr } = await ctx.supabase
@@ -22,7 +22,8 @@ import {
} from '@/lib/api/v1/pagination'
import { registerEndpoint, listEnvelope, dataEnvelope } from '@/lib/api/v1/registry'
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
import { v1ErrorResponse, v1ErrorResponseFromCode, v1ValidationError } from '@/lib/api/v1/errors'
import { readV1JsonBody } from '@/lib/api/v1/body'
import { CreateSalaryRunSchema } from '@/lib/api/schemas'
import { eventBus } from '@/lib/events'
@@ -112,17 +113,7 @@ export const GET = withApiV1<{ params: Promise<{ companyId: string }> }>(
period_year: url.searchParams.get('period_year') ?? undefined,
status: url.searchParams.get('status') ?? undefined,
})
if (!filtersResult.success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: {
issues: filtersResult.error.issues.map((i) => ({
field: i.path.join('.'),
message: i.message,
})),
},
})
}
if (!filtersResult.success) return v1ValidationError(ctx, filtersResult.error)
const filters = filtersResult.data
let query = ctx.supabase
@@ -235,28 +226,12 @@ registerEndpoint({
export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>(
'salary-runs.create',
async (request, ctx) => {
let rawBody: unknown
try {
rawBody = await request.json()
} catch {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { field: 'body', message: 'Body is not valid JSON.' },
})
}
const rawBodyResult = await readV1JsonBody(request, ctx)
if (!rawBodyResult.ok) return rawBodyResult.response
const rawBody = rawBodyResult.body
const parsed = CreateSalaryRunSchema.safeParse(rawBody)
if (!parsed.success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: {
issues: parsed.error.issues.map((i) => ({
field: i.path.join('.'),
message: i.message,
})),
},
})
}
if (!parsed.success) return v1ValidationError(ctx, parsed.error)
const body = parsed.data
if (ctx.dryRun) {
@@ -14,7 +14,7 @@ import { ok } from '@/lib/api/v1/response'
import { dryRunPreview } from '@/lib/api/v1/dry-run'
import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry'
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
import { v1ErrorResponseFromCode, v1ValidationError } from '@/lib/api/v1/errors'
import {
commitVacationYearClose,
previewVacationYearClose,
@@ -91,17 +91,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>(
}
const parsed = CloseBody.safeParse(rawBody)
if (!parsed.success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: {
issues: parsed.error.issues.map((i) => ({
field: i.path.join('.'),
message: i.message,
})),
},
})
}
if (!parsed.success) return v1ValidationError(ctx, parsed.error)
let yearStart = parsed.data.vacation_year_start
if (!yearStart) {
@@ -27,6 +27,7 @@ import { dryRunPreview } from '@/lib/api/v1/dry-run'
import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry'
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
import { readV1JsonBody } from '@/lib/api/v1/body'
import { InvoiceEmailTextsSchema, UpdateSettingsSchema } from '@/lib/api/schemas'
import { UpdateCompanySettingsParamsSchema } from '@/lib/pending-operations/schemas/company-settings'
@@ -195,15 +196,9 @@ registerEndpoint({
export const PATCH = withApiV1<{ params: Promise<{ companyId: string }> }>(
'companies.settings.update',
async (request, ctx) => {
let rawBody: unknown
try {
rawBody = await request.json()
} catch {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { field: 'body', message: 'Body is not valid JSON.' },
})
}
const rawBodyResult = await readV1JsonBody(request, ctx)
if (!rawBodyResult.ok) return rawBodyResult.response
const rawBody = rawBodyResult.body
if (rawBody === null || typeof rawBody !== 'object' || Array.isArray(rawBody)) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
@@ -19,7 +19,7 @@ import type { SkatteverketReadServices } from '@/lib/skatteverket/declaration-st
import { ok } from '@/lib/api/v1/response'
import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry'
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
import { v1ErrorResponseFromCode, v1ValidationError } from '@/lib/api/v1/errors'
ensureInitialized()
@@ -108,17 +108,7 @@ export const GET = withApiV1<{ params: Promise<{ companyId: string }> }>(
period: url.searchParams.get('period') ?? undefined,
state: url.searchParams.get('state') ?? undefined,
})
if (!parsed.success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: {
issues: parsed.error.issues.map((i) => ({
field: i.path.join('.'),
message: i.message,
})),
},
})
}
if (!parsed.success) return v1ValidationError(ctx, parsed.error)
// The skatteverket extension is opt-in (extensions.config.json) and the
// registry is the runtime source of truth: absent registration means the
@@ -19,7 +19,7 @@ import { ok } from '@/lib/api/v1/response'
import { dryRunPreview } from '@/lib/api/v1/dry-run'
import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry'
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
import { v1ErrorResponse, v1ErrorResponseFromCode, v1ValidationError } from '@/lib/api/v1/errors'
import { checkPeriodLock } from '@/lib/api/v1/check-period-lock'
import { MarkSupplierInvoicePaidSchema } from '@/lib/api/schemas'
import {
@@ -127,17 +127,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
| undefined
if (rawBody) {
const parsed = MarkSupplierInvoicePaidSchema.safeParse(rawBody)
if (!parsed.success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: {
issues: parsed.error.issues.map((i) => ({
field: i.path.join('.'),
message: i.message,
})),
},
})
}
if (!parsed.success) return v1ValidationError(ctx, parsed.error)
bodyAmount = parsed.data.amount
bodyPaymentDate = parsed.data.payment_date
exchangeRateDifference = parsed.data.exchange_rate_difference
@@ -18,7 +18,8 @@ import { dryRunPreview } from '@/lib/api/v1/dry-run'
import { parseExpand } from '@/lib/api/v1/expand'
import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry'
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
import { v1ErrorResponse, v1ErrorResponseFromCode, v1ValidationError } from '@/lib/api/v1/errors'
import { readV1JsonBody } from '@/lib/api/v1/body'
import { UpdateSupplierInvoiceSchema } from '@/lib/api/schemas'
import {
findChangedVerifikatFields,
@@ -218,28 +219,12 @@ export const PATCH = withApiV1<{ params: Promise<{ companyId: string; id: string
}
const invoiceId = idParse.data
let rawBody: unknown
try {
rawBody = await request.json()
} catch {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { field: 'body', message: 'Body is not valid JSON.' },
})
}
const rawBodyResult = await readV1JsonBody(request, ctx)
if (!rawBodyResult.ok) return rawBodyResult.response
const rawBody = rawBodyResult.body
const parsed = V1PatchSupplierInvoiceSchema.safeParse(rawBody)
if (!parsed.success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: {
issues: parsed.error.issues.map((i) => ({
field: i.path.join('.'),
message: i.message,
})),
},
})
}
if (!parsed.success) return v1ValidationError(ctx, parsed.error)
const body = parsed.data
const updateData: Record<string, unknown> = {}
@@ -31,7 +31,8 @@ import {
} from '@/lib/api/v1/pagination'
import { registerEndpoint, listEnvelope, dataEnvelope } from '@/lib/api/v1/registry'
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
import { v1ErrorResponse, v1ErrorResponseFromCode, v1ValidationError } from '@/lib/api/v1/errors'
import { readV1JsonBody } from '@/lib/api/v1/body'
import { checkPeriodLock } from '@/lib/api/v1/check-period-lock'
import { CreateSupplierInvoiceSchema } from '@/lib/api/schemas'
import {
@@ -158,17 +159,7 @@ export const GET = withApiV1<{ params: Promise<{ companyId: string }> }>(
date_from: url.searchParams.get('date_from') ?? undefined,
date_to: url.searchParams.get('date_to') ?? undefined,
})
if (!filtersResult.success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: {
issues: filtersResult.error.issues.map((i) => ({
field: i.path.join('.'),
message: i.message,
})),
},
})
}
if (!filtersResult.success) return v1ValidationError(ctx, filtersResult.error)
const filters = filtersResult.data
// Sort by (created_at DESC, id ASC). created_at is the stable cursor
@@ -447,28 +438,12 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>(
})
}
let rawBody: unknown
try {
rawBody = await request.json()
} catch {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { field: 'body', message: 'Body is not valid JSON.' },
})
}
const rawBodyResult = await readV1JsonBody(request, ctx)
if (!rawBodyResult.ok) return rawBodyResult.response
const rawBody = rawBodyResult.body
const parsed = CreateSupplierInvoiceSchema.safeParse(rawBody)
if (!parsed.success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: {
issues: parsed.error.issues.map((i) => ({
field: i.path.join('.'),
message: i.message,
})),
},
})
}
if (!parsed.success) return v1ValidationError(ctx, parsed.error)
const body = parsed.data
// Särskild löneskatt (SLP): the 7533/2514 pair is only lawful on 741x
@@ -18,7 +18,8 @@ import { dryRunPreview } from '@/lib/api/v1/dry-run'
import { parseExpand } from '@/lib/api/v1/expand'
import { registerEndpoint, dataEnvelope, NoBodyResponse } from '@/lib/api/v1/registry'
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
import { v1ErrorResponse, v1ErrorResponseFromCode, v1ValidationError } from '@/lib/api/v1/errors'
import { readV1JsonBody } from '@/lib/api/v1/body'
import { UpdateSupplierSchema } from '@/lib/api/schemas'
// v1-only extension: allow PATCH to set archived_at back to null to
@@ -259,28 +260,12 @@ export const PATCH = withApiV1<{ params: Promise<{ companyId: string; id: string
}
const supplierId = idParse.data
let rawBody: unknown
try {
rawBody = await request.json()
} catch {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { field: 'body', message: 'Body is not valid JSON.' },
})
}
const rawBodyResult = await readV1JsonBody(request, ctx)
if (!rawBodyResult.ok) return rawBodyResult.response
const rawBody = rawBodyResult.body
const parsed = V1PatchSupplierSchema.safeParse(rawBody)
if (!parsed.success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: {
issues: parsed.error.issues.map((i) => ({
field: i.path.join('.'),
message: i.message,
})),
},
})
}
if (!parsed.success) return v1ValidationError(ctx, parsed.error)
const body = parsed.data
const updateData: Record<string, unknown> = {}
@@ -19,7 +19,8 @@ import { ok } from '@/lib/api/v1/response'
import { dryRunPreview } from '@/lib/api/v1/dry-run'
import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry'
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
import { v1ErrorResponseFromCode, v1ValidationError } from '@/lib/api/v1/errors'
import { readV1JsonBody } from '@/lib/api/v1/body'
import { CreateSupplierSchema } from '@/lib/api/schemas'
import { eventBus } from '@/lib/events'
import type { Logger } from '@/lib/logger'
@@ -239,28 +240,12 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>(
})
}
let rawBody: unknown
try {
rawBody = await request.json()
} catch {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { field: 'body', message: 'Body is not valid JSON.' },
})
}
const rawBodyResult = await readV1JsonBody(request, ctx)
if (!rawBodyResult.ok) return rawBodyResult.response
const rawBody = rawBodyResult.body
const parsed = BulkCreateRequest.safeParse(rawBody)
if (!parsed.success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: {
issues: parsed.error.issues.map((i) => ({
field: i.path.join('.'),
message: i.message,
})),
},
})
}
if (!parsed.success) return v1ValidationError(ctx, parsed.error)
const body = parsed.data
if (body.all_or_nothing) {
@@ -23,7 +23,8 @@ import {
} from '@/lib/api/v1/pagination'
import { registerEndpoint, listEnvelope, dataEnvelope } from '@/lib/api/v1/registry'
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
import { v1ErrorResponse, v1ErrorResponseFromCode, v1ValidationError } from '@/lib/api/v1/errors'
import { readV1JsonBody } from '@/lib/api/v1/body'
import { CreateSupplierSchema } from '@/lib/api/schemas'
import { eventBus } from '@/lib/events'
import type { Supplier } from '@/types'
@@ -114,17 +115,7 @@ export const GET = withApiV1<{ params: Promise<{ companyId: string }> }>(
search: url.searchParams.get('search') ?? undefined,
include_archived: url.searchParams.get('include_archived') ?? undefined,
})
if (!filtersResult.success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: {
issues: filtersResult.error.issues.map((i) => ({
field: i.path.join('.'),
message: i.message,
})),
},
})
}
if (!filtersResult.success) return v1ValidationError(ctx, filtersResult.error)
const filters = filtersResult.data
const includeArchived = filters.include_archived === 'true'
@@ -313,28 +304,12 @@ registerEndpoint({
export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>(
'suppliers.create',
async (request, ctx) => {
let rawBody: unknown
try {
rawBody = await request.json()
} catch {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { field: 'body', message: 'Body is not valid JSON.' },
})
}
const rawBodyResult = await readV1JsonBody(request, ctx)
if (!rawBodyResult.ok) return rawBodyResult.response
const rawBody = rawBodyResult.body
const parsed = CreateSupplierSchema.safeParse(rawBody)
if (!parsed.success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: {
issues: parsed.error.issues.map((i) => ({
field: i.path.join('.'),
message: i.message,
})),
},
})
}
if (!parsed.success) return v1ValidationError(ctx, parsed.error)
const body = parsed.data
if (ctx.dryRun) {
@@ -24,7 +24,8 @@ import { ok } from '@/lib/api/v1/response'
import { dryRunPreview } from '@/lib/api/v1/dry-run'
import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry'
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
import { v1ErrorResponse, v1ErrorResponseFromCode, v1ValidationError } from '@/lib/api/v1/errors'
import { readV1JsonBody } from '@/lib/api/v1/body'
import { checkPeriodLock } from '@/lib/api/v1/check-period-lock'
import { CategorizeTransactionSchema } from '@/lib/api/schemas'
import { buildMappingResultFromCategory } from '@/lib/bookkeeping/category-mapping'
@@ -120,27 +121,11 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
}
const txId = idParse.data
let rawBody: unknown
try {
rawBody = await request.json()
} catch {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { field: 'body', message: 'Body is not valid JSON.' },
})
}
const rawBodyResult = await readV1JsonBody(request, ctx)
if (!rawBodyResult.ok) return rawBodyResult.response
const rawBody = rawBodyResult.body
const parsed = CategorizeTransactionSchema.safeParse(rawBody)
if (!parsed.success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: {
issues: parsed.error.issues.map((i) => ({
field: i.path.join('.'),
message: i.message,
})),
},
})
}
if (!parsed.success) return v1ValidationError(ctx, parsed.error)
const body = parsed.data
const { is_business, category } = body
@@ -26,7 +26,8 @@ import { z } from 'zod'
import { ok } from '@/lib/api/v1/response'
import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry'
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
import { v1ErrorResponse, v1ErrorResponseFromCode, v1ValidationError } from '@/lib/api/v1/errors'
import { readV1JsonBody } from '@/lib/api/v1/body'
import { MatchInvoiceSchema } from '@/lib/api/schemas'
import { createInvoiceCashEntry } from '@/lib/bookkeeping/invoice-entries'
import { buildInvoicePaymentClearingLines } from '@/lib/bookkeeping/invoice-payment-lines'
@@ -114,27 +115,11 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
}
const txId = idParse.data
let rawBody: unknown
try {
rawBody = await request.json()
} catch {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { field: 'body', message: 'Body is not valid JSON.' },
})
}
const rawBodyResult = await readV1JsonBody(request, ctx)
if (!rawBodyResult.ok) return rawBodyResult.response
const rawBody = rawBodyResult.body
const parsed = MatchInvoiceSchema.safeParse(rawBody)
if (!parsed.success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: {
issues: parsed.error.issues.map((i) => ({
field: i.path.join('.'),
message: i.message,
})),
},
})
}
if (!parsed.success) return v1ValidationError(ctx, parsed.error)
const { invoice_id, force, expected_journal_entry_id, lines: customLines } = parsed.data
const txLog = ctx.log.child({ transactionId: txId, invoiceId: invoice_id })
@@ -9,7 +9,8 @@ import { z } from 'zod'
import { ok } from '@/lib/api/v1/response'
import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry'
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
import { v1ErrorResponse, v1ErrorResponseFromCode, v1ValidationError } from '@/lib/api/v1/errors'
import { readV1JsonBody } from '@/lib/api/v1/body'
import { MatchSupplierInvoiceSchema } from '@/lib/api/schemas'
import { cancelOrphanedPaymentEntry } from '@/lib/bookkeeping/cancel-orphaned-entry'
import {
@@ -89,27 +90,11 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
}
const txId = idParse.data
let rawBody: unknown
try {
rawBody = await request.json()
} catch {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { field: 'body', message: 'Body is not valid JSON.' },
})
}
const rawBodyResult = await readV1JsonBody(request, ctx)
if (!rawBodyResult.ok) return rawBodyResult.response
const rawBody = rawBodyResult.body
const parsed = MatchSupplierInvoiceSchema.safeParse(rawBody)
if (!parsed.success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: {
issues: parsed.error.issues.map((i) => ({
field: i.path.join('.'),
message: i.message,
})),
},
})
}
if (!parsed.success) return v1ValidationError(ctx, parsed.error)
const { supplier_invoice_id, lines: customLines } = parsed.data
const txLog = ctx.log.child({ transactionId: txId, supplierInvoiceId: supplier_invoice_id })
@@ -14,7 +14,8 @@ import { ok } from '@/lib/api/v1/response'
import { dryRunPreview } from '@/lib/api/v1/dry-run'
import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry'
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
import { v1ErrorResponseFromCode, v1ValidationError } from '@/lib/api/v1/errors'
import { readV1JsonBody } from '@/lib/api/v1/body'
import { checkPeriodLock } from '@/lib/api/v1/check-period-lock'
import { CategorizeTransactionSchema } from '@/lib/api/schemas'
import type { SupabaseClient } from '@supabase/supabase-js'
@@ -520,27 +521,11 @@ async function categorizeOne(
export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>(
'transactions.batch-categorize',
async (request, ctx) => {
let rawBody: unknown
try {
rawBody = await request.json()
} catch {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { field: 'body', message: 'Body is not valid JSON.' },
})
}
const rawBodyResult = await readV1JsonBody(request, ctx)
if (!rawBodyResult.ok) return rawBodyResult.response
const rawBody = rawBodyResult.body
const parsed = BatchRequest.safeParse(rawBody)
if (!parsed.success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: {
issues: parsed.error.issues.map((i) => ({
field: i.path.join('.'),
message: i.message,
})),
},
})
}
if (!parsed.success) return v1ValidationError(ctx, parsed.error)
const body = parsed.data
if (body.all_or_nothing) {
@@ -20,7 +20,8 @@ import { ok } from '@/lib/api/v1/response'
import { dryRunPreview } from '@/lib/api/v1/dry-run'
import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry'
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
import { v1ErrorResponse, v1ValidationError } from '@/lib/api/v1/errors'
import { readV1JsonBody } from '@/lib/api/v1/body'
import { ingestTransactions } from '@/lib/transactions/ingest'
import { contentBucketKey, descriptionsBridge, normalizeImportedDescription } from '@/lib/transactions/external-id'
import type { RawTransaction } from '@/types'
@@ -105,27 +106,11 @@ registerEndpoint({
export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>(
'transactions.ingest',
async (request, ctx) => {
let rawBody: unknown
try {
rawBody = await request.json()
} catch {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { field: 'body', message: 'Body is not valid JSON.' },
})
}
const rawBodyResult = await readV1JsonBody(request, ctx)
if (!rawBodyResult.ok) return rawBodyResult.response
const rawBody = rawBodyResult.body
const parsed = IngestRequest.safeParse(rawBody)
if (!parsed.success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: {
issues: parsed.error.issues.map((i) => ({
field: i.path.join('.'),
message: i.message,
})),
},
})
}
if (!parsed.success) return v1ValidationError(ctx, parsed.error)
const body = parsed.data
if (ctx.dryRun) {
@@ -15,7 +15,7 @@ import {
} from '@/lib/api/v1/pagination'
import { registerEndpoint, listEnvelope } from '@/lib/api/v1/registry'
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
import { v1ErrorResponse, v1ValidationError } from '@/lib/api/v1/errors'
const TransactionSummary = z.object({
id: z.string().uuid(),
@@ -115,17 +115,7 @@ export const GET = withApiV1<{ params: Promise<{ companyId: string }> }>(
search: url.searchParams.get('search') ?? undefined,
cash_account_id: url.searchParams.get('cash_account_id') ?? undefined,
})
if (!filtersResult.success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: {
issues: filtersResult.error.issues.map((i) => ({
field: i.path.join('.'),
message: i.message,
})),
},
})
}
if (!filtersResult.success) return v1ValidationError(ctx, filtersResult.error)
const f = filtersResult.data
// Sort by (created_at DESC, id ASC). created_at is the stable cursor
@@ -21,7 +21,8 @@ import { created } from '@/lib/api/v1/response'
import { dryRunPreview } from '@/lib/api/v1/dry-run'
import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry'
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
import { v1ErrorResponse, v1ErrorResponseFromCode, v1ValidationError } from '@/lib/api/v1/errors'
import { readV1JsonBody } from '@/lib/api/v1/body'
const CreateVoucherGapExplanation = z
.object({
@@ -89,22 +90,11 @@ registerEndpoint({
export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>(
'voucher-gap-explanations.create',
async (request, ctx) => {
let rawBody: unknown
try {
rawBody = await request.json()
} catch {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { field: 'body', message: 'Body is not valid JSON.' },
})
}
const rawBodyResult = await readV1JsonBody(request, ctx)
if (!rawBodyResult.ok) return rawBodyResult.response
const rawBody = rawBodyResult.body
const parsed = CreateVoucherGapExplanation.safeParse(rawBody)
if (!parsed.success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { issues: parsed.error.issues.map((i) => ({ field: i.path.join('.'), message: i.message })) },
})
}
if (!parsed.success) return v1ValidationError(ctx, parsed.error)
const body = parsed.data
// Ownership pre-check: the caller-supplied `fiscal_period_id` must belong
@@ -21,7 +21,8 @@ import { ok, noContent } from '@/lib/api/v1/response'
import { dryRunPreview } from '@/lib/api/v1/dry-run'
import { registerEndpoint, dataEnvelope, NoBodyResponse } from '@/lib/api/v1/registry'
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
import { v1ErrorResponse, v1ErrorResponseFromCode, v1ValidationError } from '@/lib/api/v1/errors'
import { readV1JsonBody } from '@/lib/api/v1/body'
import { validateWebhookUrl } from '@/lib/webhooks/url-guard'
const WEBHOOK_DETAIL_COLUMNS =
@@ -162,27 +163,11 @@ export const PATCH = withApiV1<{ params: Promise<{ companyId: string; id: string
async (request, ctx, params) => {
const { id } = await params.params
let rawBody: unknown
try {
rawBody = await request.json()
} catch {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { field: 'body', message: 'Body is not valid JSON.' },
})
}
const rawBodyResult = await readV1JsonBody(request, ctx)
if (!rawBodyResult.ok) return rawBodyResult.response
const rawBody = rawBodyResult.body
const parsed = PatchWebhookSchema.safeParse(rawBody)
if (!parsed.success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: {
issues: parsed.error.issues.map((i) => ({
field: i.path.join('.'),
message: i.message,
})),
},
})
}
if (!parsed.success) return v1ValidationError(ctx, parsed.error)
const body = parsed.data
// SSRF guard on webhook_url change: same DNS/IP-class validation as
@@ -16,7 +16,8 @@ import { created, ok } from '@/lib/api/v1/response'
import { dryRunPreview } from '@/lib/api/v1/dry-run'
import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry'
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
import { v1ErrorResponse, v1ErrorResponseFromCode, v1ValidationError } from '@/lib/api/v1/errors'
import { readV1JsonBody } from '@/lib/api/v1/body'
import { generateWebhookSecret } from '@/lib/webhooks/signing'
import { validateWebhookUrl } from '@/lib/webhooks/url-guard'
import { API_V1_VERSION } from '@/lib/api/v1/version'
@@ -193,28 +194,12 @@ registerEndpoint({
export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>(
'webhooks.create',
async (request, ctx) => {
let rawBody: unknown
try {
rawBody = await request.json()
} catch {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { field: 'body', message: 'Body is not valid JSON.' },
})
}
const rawBodyResult = await readV1JsonBody(request, ctx)
if (!rawBodyResult.ok) return rawBodyResult.response
const rawBody = rawBodyResult.body
const parsed = CreateWebhookSchema.safeParse(rawBody)
if (!parsed.success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: {
issues: parsed.error.issues.map((i) => ({
field: i.path.join('.'),
message: i.message,
})),
},
})
}
if (!parsed.success) return v1ValidationError(ctx, parsed.error)
const body = parsed.data
// Elevated-scope check for high-sensitivity payloads. Subscribing to
+6 -16
View File
@@ -18,7 +18,8 @@ import {
} from '@/lib/api/v1/pagination'
import { registerEndpoint, listEnvelope, dataEnvelope } from '@/lib/api/v1/registry'
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
import { v1ErrorResponse, v1ErrorResponseFromCode, v1ValidationError } from '@/lib/api/v1/errors'
import { readV1JsonBody } from '@/lib/api/v1/body'
import { dryRunPreview } from '@/lib/api/v1/dry-run'
import { createCompanyCore } from '@/lib/company/create-company'
import { CompanySetupSchema, planCompanySetup } from '@/lib/company/onboarding-input'
@@ -143,23 +144,12 @@ registerEndpoint({
})
export const POST = withApiV1('companies.create', async (request, ctx) => {
let rawBody: unknown
try {
rawBody = await request.json()
} catch {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { field: 'body', message: 'Body is not valid JSON.' },
})
}
const rawBodyResult = await readV1JsonBody(request, ctx)
if (!rawBodyResult.ok) return rawBodyResult.response
const rawBody = rawBodyResult.body
const parsed = CompanySetupSchema.safeParse(rawBody)
if (!parsed.success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { issues: parsed.error.issues.map((i) => ({ field: i.path.join('.'), message: i.message })) },
})
}
if (!parsed.success) return v1ValidationError(ctx, parsed.error)
const setup = parsed.data
const plan = planCompanySetup(setup)
+2 -2
View File
@@ -6,7 +6,7 @@ import { RotateCw } from 'lucide-react'
import { getAccountDescription } from '@/lib/bookkeeping/account-descriptions'
import { useBasReference } from '@/lib/bookkeeping/use-bas-reference'
import { useTranslations } from 'next-intl'
import { formatCurrency } from '@/lib/utils'
import { formatAmount, formatCurrency } from '@/lib/utils'
import type { DeepEntity, DeepLedgerContext } from '@/lib/agent-context/ledger-deep'
import { entityMagnitude, selectAccountRing, type RingBasis } from './ledger-graph-magnitude'
@@ -751,7 +751,7 @@ function DetailCard({ p, t }: { p: Payee; t: ReturnType<typeof useTranslations>
<div className="flex justify-between gap-3">
<span>{t('card_amount')}</span>
<span className="tabular-nums" style={{ color: PAPER }}>
{e.total_amount.toLocaleString('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
{formatAmount(e.total_amount)}
</span>
</div>
<div className="mt-2 border-t pt-2" style={{ borderColor: HAIR }}>

Some files were not shown because too many files have changed in this diff Show More