From f266c386f3d18f17fcf1fbaf5f837b1e61109034 Mon Sep 17 00:00:00 2001 From: Jakob Wennberg Date: Wed, 2 Sep 2026 11:51:16 +0200 Subject: [PATCH] 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 * 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 --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 --- .claude/rules/design.md | 2 +- DECISIONS.md | 1 + app/(dashboard)/bookkeeping/page.tsx | 2 +- app/(dashboard)/import/page.tsx | 2 +- app/(dashboard)/invoices/[id]/credit/page.tsx | 8 +- app/(dashboard)/invoices/[id]/page.tsx | 12 +- .../KassaflodesanalysClient.tsx | 9 +- app/(dashboard)/suppliers/[id]/page.tsx | 6 +- app/(dashboard)/transactions/page.tsx | 48 +- app/.well-known/skills/index.json/route.ts | 2 +- .../signatures/[signatureId]/route.ts | 18 +- app/api/calendar/feed/[token]/route.ts | 34 +- app/api/cash-accounts/route.ts | 5 +- .../[id]/migration-reset/archive/route.ts | 16 +- app/api/company/[id]/migration-reset/route.ts | 6 +- .../enable-banking/callback/finalize-page.ts | 9 +- .../extensions/woocommerce/callback/route.ts | 3 +- app/api/import/articles/execute/route.ts | 6 +- app/api/import/articles/parse/route.ts | 7 +- app/api/import/bank-file/execute/route.ts | 3 +- app/api/invoices/[id]/pdf/route.ts | 8 +- .../[id]/refresh-exchange-rate/route.ts | 3 +- app/api/invoices/preview-pdf/route.ts | 8 +- app/api/payslip/[token]/pdf/route.ts | 29 +- .../accounts/[accountKey]/links/route.ts | 26 +- app/api/reports/ar-ledger/xlsx/route.ts | 11 +- app/api/reports/full-archive/route.ts | 17 +- app/api/reports/general-ledger/xlsx/route.ts | 12 +- .../reports/journal-register/xlsx/route.ts | 10 +- app/api/reports/salary-journal/xlsx/route.ts | 9 +- app/api/sandbox/seed/date-utils.ts | 13 + app/api/sandbox/seed/ledger-history.ts | 6 +- app/api/sandbox/seed/salary.ts | 10 +- .../settings/booking-templates/[id]/route.ts | 18 +- .../booking-templates/import/route.ts | 20 +- app/api/settings/booking-templates/route.ts | 31 +- .../companies/[companyId]/accounts/route.ts | 14 +- .../[companyId]/cash-accounts/route.ts | 14 +- .../[companyId]/customers/[id]/route.ts | 27 +- .../customers/bulk-create/route.ts | 27 +- .../companies/[companyId]/customers/route.ts | 36 +- .../dimensions/[id]/values/[valueId]/route.ts | 27 +- .../dimensions/[id]/values/route.ts | 27 +- .../[companyId]/documents/[id]/link/route.ts | 22 +- .../employees/[id]/absence/route.ts | 51 +- .../employees/[id]/opening-balances/route.ts | 27 +- .../[companyId]/employees/[id]/route.ts | 27 +- .../employees/opening-balances/route.ts | 27 +- .../companies/[companyId]/employees/route.ts | 39 +- .../[id]/currency-revaluation/route.ts | 9 +- .../[id]/opening-balances/route.ts | 9 +- .../[companyId]/imports/bank/route.ts | 3 +- .../[companyId]/imports/sie/route.ts | 14 +- .../inbox-items/[id]/stamp/route.ts | 22 +- .../[companyId]/invoices/[id]/credit/route.ts | 14 +- .../invoices/[id]/mark-paid/route.ts | 14 +- .../[companyId]/invoices/[id]/route.ts | 27 +- .../[companyId]/invoices/[id]/send/route.ts | 14 +- .../[companyId]/invoices/bulk-create/route.ts | 27 +- .../companies/[companyId]/invoices/route.ts | 40 +- .../journal-entries/[id]/correct/route.ts | 22 +- .../journal-entries/[id]/reverse/route.ts | 9 +- .../journal-entries/batch-create/route.ts | 22 +- .../[companyId]/journal-entries/route.ts | 29 +- .../items/[itemId]/ignore/route.ts | 11 +- .../accounts/[accountKey]/items/route.ts | 11 +- .../accounts/[accountKey]/links/route.ts | 51 +- .../accounts/[accountKey]/residual/route.ts | 22 +- .../accounts/[accountKey]/route.ts | 11 +- .../signoff/[signoffId]/reopen/route.ts | 11 +- .../accounts/[accountKey]/signoff/route.ts | 24 +- .../reconciliation/accounts/route.ts | 11 +- .../reconciliation/bank/run/route.ts | 14 +- .../reconciliation/bank/status/route.ts | 14 +- .../reports/vat-declaration/route.ts | 14 +- .../employees/[employeeId]/lines/route.ts | 27 +- .../salary-runs/[id]/employees/route.ts | 27 +- .../salary-runs/[id]/lines/[lineId]/route.ts | 27 +- .../[companyId]/salary-runs/[id]/route.ts | 27 +- .../[companyId]/salary-runs/route.ts | 39 +- .../salary/vacation-year-close/route.ts | 14 +- .../companies/[companyId]/settings/route.ts | 13 +- .../skatteverket/vat-declarations/route.ts | 14 +- .../supplier-invoices/[id]/mark-paid/route.ts | 14 +- .../supplier-invoices/[id]/route.ts | 27 +- .../[companyId]/supplier-invoices/route.ts | 39 +- .../[companyId]/suppliers/[id]/route.ts | 27 +- .../suppliers/bulk-create/route.ts | 27 +- .../companies/[companyId]/suppliers/route.ts | 39 +- .../transactions/[id]/categorize/route.ts | 27 +- .../transactions/[id]/match-invoice/route.ts | 27 +- .../[id]/match-supplier-invoice/route.ts | 27 +- .../transactions/batch-categorize/route.ts | 27 +- .../[companyId]/transactions/ingest/route.ts | 27 +- .../[companyId]/transactions/route.ts | 14 +- .../voucher-gap-explanations/route.ts | 22 +- .../[companyId]/webhooks/[id]/route.ts | 27 +- .../companies/[companyId]/webhooks/route.ts | 27 +- app/api/v1/companies/route.ts | 22 +- components/agent-knowledge/LedgerGraph.tsx | 4 +- components/bookkeeping/ChartOfAccounts.tsx | 201 --- components/bookkeeping/DocumentViewButton.tsx | 2 +- .../bookkeeping/FiscalPeriodDateFields.tsx | 106 +- .../bookkeeping/JournalEntryReviewContent.tsx | 6 +- .../year-end/DepreciationPanel.tsx | 7 +- components/common/CashAccountSelector.tsx | 137 -- components/common/DataState.tsx | 97 -- components/deadlines/TaxTodoWidget.tsx | 246 ---- .../deadlines/UpcomingDeadlinesWidget.tsx | 222 --- components/deadlines/index.ts | 5 - .../general/AiCategorizationWorkspace.tsx | 15 - .../general/InvoiceInboxWorkspace.tsx | 6 +- .../general/MailConnectionsPanel.tsx | 2 +- .../general/ReceiptOcrWorkspace.tsx | 15 - .../extensions/shared/ConfirmDeleteDialog.tsx | 63 - .../extensions/shared/CsvImportWizard.tsx | 232 ---- .../extensions/shared/DataEntryForm.tsx | 39 - .../extensions/shared/DateRangeFilter.tsx | 66 - .../extensions/shared/EditEntryDialog.tsx | 62 - .../shared/ExtensionLoadingSkeleton.tsx | 23 - components/extensions/shared/KPICard.tsx | 34 - .../extensions/shared/MockDataBanner.tsx | 49 - .../shared/MockDataImportDialog.tsx | 406 ------ .../extensions/shared/MonthlyTrendTable.tsx | 74 - components/extensions/shared/SetupPrompt.tsx | 72 - components/import/BankFileResultStep.tsx | 2 +- components/import/SIEUploadStep.tsx | 2 +- components/invoices/PaymentBookingDialog.tsx | 11 +- components/invoices/RotRutPayoutDialog.tsx | 2 +- components/invoices/SendInvoiceDialog.tsx | 8 +- components/invoices/types.ts | 15 + components/reports/INK2DeclarationView.tsx | 11 +- components/reports/NEDeclarationView.tsx | 11 +- .../reports/PeriodiskSammanstallningView.tsx | 48 +- components/reports/ReportRowExpansion.tsx | 8 +- components/reports/SkatteverketPanel.tsx | 5 +- components/reports/VatChecksCard.tsx | 18 +- components/reports/api-error.ts | 20 + components/reports/views/index.tsx | 17 +- components/salary/NewSalaryRunDialog.tsx | 129 -- components/salary/VacationBalanceCard.tsx | 242 ---- components/settings/AgentMemoryPanel.tsx | 3 +- components/settings/AgentSkillsPanel.tsx | 2 +- components/settings/sections/index.ts | 2 - .../NewSupplierInvoiceForm.tsx | 6 +- .../SupplierInvoiceReviewContent.tsx | 6 +- .../transactions/CategoryExpandedDialog.tsx | 135 -- components/transactions/InvoicePicker.tsx | 7 +- .../transactions/SupplierInvoicePicker.tsx | 6 +- components/transactions/TemplatePicker.tsx | 2 +- components/transactions/transaction-types.ts | 5 - components/ui/empty-state.tsx | 73 - components/ui/fallback-prompt.tsx | 21 - components/ui/sheet.tsx | 147 -- .../arcim-migration/lib/arcim-client.ts | 86 -- .../lib/migration-orchestrator.ts | 7 +- .../arcim-migration/lib/provider-client.ts | 8 +- extensions/general/arcim-migration/types.ts | 17 +- extensions/general/bolagsverket/types.ts | 8 - .../components/PaymentSummaryCard.tsx | 108 -- .../general/cloud-backup/lib/backup-alert.ts | 9 +- extensions/general/cloud-backup/lib/sync.ts | 3 - extensions/general/cloud-backup/types.ts | 3 - .../general/enable-banking/lib/api-client.ts | 48 - extensions/general/enable-banking/lib/jwt.ts | 6 +- extensions/general/enable-banking/types.ts | 18 +- .../invoice-inbox/lib/resend-inbound.ts | 2 - .../invoice-inbox/lib/upload-and-extract.ts | 10 +- .../general/mcp-server/company-routing.ts | 4 +- extensions/general/mcp-server/server.ts | 1212 +++++------------ extensions/general/mcp-server/skills/index.ts | 5 - .../NotificationSettings.tsx | 386 ------ .../general/push-notifications/PushPrompt.tsx | 211 --- .../general/push-notifications/types.ts | 2 - extensions/general/shopify/lib/api-client.ts | 5 +- .../general/shopify/lib/settings-actions.ts | 81 +- extensions/general/skatteverket/index.ts | 3 +- .../skatteverket/lib/connection-store.ts | 5 - .../skatteverket/lib/kvittens-notification.ts | 9 +- .../skatteverket/lib/skattekonto-booking.ts | 3 +- extensions/general/skatteverket/types.ts | 29 - .../general/stripe/lib/settings-actions.ts | 113 +- .../general/stripe/lib/transaction-sync.ts | 7 +- extensions/general/tic/lib/bankid-types.ts | 23 - extensions/general/tic/lib/tic-client.ts | 12 - extensions/general/tic/lib/tic-types.ts | 15 - .../whatsapp-inbox/lib/process-inbound.ts | 9 +- .../general/woocommerce/lib/api-client.ts | 5 +- .../woocommerce/lib/settings-actions.ts | 81 +- lib/agent-context/underlag-candidates.ts | 6 +- lib/agent/intents/registry.ts | 5 - lib/ai/index.ts | 20 +- lib/ai/provider.ts | 5 +- lib/api/__tests__/token-rate-limit.test.ts | 41 + lib/api/private-no-store.ts | 2 + lib/api/schemas.ts | 165 --- lib/api/token-rate-limit.ts | 40 + lib/api/v1/body.ts | 35 + lib/api/v1/dry-run.ts | 60 - lib/api/v1/errors.ts | 41 +- lib/api/v1/pagination.ts | 11 +- lib/api/v1/registry.ts | 10 - lib/api/v1/report-period.ts | 3 +- lib/auth/api-keys.ts | 2 +- lib/auth/turnstile.ts | 2 - lib/bokslut/accruals/auto-detect.ts | 6 +- lib/bokslut/accruals/templates.ts | 84 +- .../arsredovisning/compliance-types.ts | 13 +- .../arsredovisning/k3-noter-builder.ts | 3 - .../arsredovisning/signature-service.ts | 14 - lib/bokslut/assets/asset-service.ts | 67 - lib/bokslut/assets/jamkning.ts | 4 +- .../reserves/overavskrivningar-service.ts | 6 +- .../__tests__/template-embeddings.test.ts | 10 +- .../account-classifier-heuristic.ts | 10 +- lib/bookkeeping/account-classifier.ts | 3 - lib/bookkeeping/account-descriptions.ts | 3 +- lib/bookkeeping/accruals/service.ts | 9 +- lib/bookkeeping/booking-template-schemas.ts | 24 + lib/bookkeeping/counterparty-templates.ts | 11 +- lib/bookkeeping/dimension-errors.ts | 10 - lib/bookkeeping/engine.ts | 6 +- lib/bookkeeping/errors.ts | 27 +- lib/bookkeeping/invoice-entries.ts | 7 +- lib/bookkeeping/own-account-detector.ts | 7 +- lib/bookkeeping/proposal-lines.ts | 35 +- lib/bookkeeping/suggest-fiscal-period.ts | 8 +- lib/bookkeeping/template-embeddings.ts | 21 - lib/branding/team-brands.ts | 5 - lib/browser/panel-request.ts | 112 ++ lib/byra/kpi-overview.ts | 7 +- lib/cash-accounts/service.ts | 7 +- lib/clients/fetch-client-overview.ts | 7 +- lib/connect/hosted/ledger.ts | 18 - lib/connect/hosted/upstream-budget.ts | 3 +- lib/core/audit/audit-service.ts | 80 -- lib/core/bookkeeping/period-service.ts | 150 +- lib/dates/iso.ts | 32 + lib/documents/core-receipt-matcher.ts | 1 - lib/email/invoice-templates.ts | 22 +- lib/email/reminder-templates.ts | 6 +- lib/entitlements/has-capability.ts | 30 +- lib/entitlements/metering.ts | 33 - lib/entitlements/multi-user.ts | 4 +- lib/events/index.ts | 7 - lib/events/types.ts | 5 - lib/export/register-export.ts | 4 +- lib/extensions/index.ts | 10 - lib/extensions/validation.ts | 14 +- lib/hooks/use-fetch.ts | 3 +- lib/import/articles/types.ts | 6 - lib/import/bank-file/formats/generic-csv.ts | 9 - lib/import/bank-file/types.ts | 19 - lib/import/customers/types.ts | 6 - lib/import/opening-balance/types.ts | 10 - lib/import/shared/column-utils.ts | 9 + lib/import/sie-import.ts | 2 +- lib/import/suppliers/types.ts | 5 - lib/import/types.ts | 55 - lib/invariants/__tests__/uuid.test.ts | 24 + lib/invariants/index.ts | 2 +- lib/invariants/org-number.ts | 2 - lib/invariants/uuid.ts | 12 + lib/invariants/zod.ts | 7 +- .../link-migrated-registration-vouchers.ts | 7 +- lib/invoices/pdf-template.tsx | 10 +- lib/invoices/peppol-bis-billing.ts | 10 +- lib/invoices/peppol-delivery-sync.ts | 6 +- lib/invoices/peppol-delivery.ts | 5 + lib/invoices/peppol-inbound.ts | 6 +- lib/invoices/rot-rut-file.ts | 10 +- lib/invoices/supplier-voucher-matching.ts | 74 +- lib/invoices/transports/qvalia.ts | 5 - lib/invoices/voucher-matching-shared.ts | 62 + lib/invoices/voucher-matching.ts | 74 +- lib/notifications/bookkeeping-digest.ts | 17 +- lib/observability/index.ts | 9 - lib/packs/schema.ts | 2 - lib/payments/pain001-supplier.ts | 10 +- lib/pdf/number-text.ts | 20 + lib/pending-operations/commit.ts | 271 ++-- lib/pending-operations/errors.ts | 4 - lib/pending-operations/schemas/account.ts | 3 - lib/pending-operations/schemas/article.ts | 2 - .../schemas/company-settings.ts | 3 - .../schemas/create-supplier.ts | 2 - lib/pending-operations/schemas/customer.ts | 1 - .../schemas/dimension-value.ts | 2 - .../schemas/ignore-transaction.ts | 2 - .../schemas/recurring-schedule.ts | 3 - .../schemas/retag-line-dimensions.ts | 2 - .../schemas/update-invoice.ts | 1 - .../schemas/voucher-note.ts | 2 - lib/providers/bjornlunden/oauth.ts | 1 - lib/providers/bokio/oauth.ts | 10 - lib/providers/dto.ts | 104 -- lib/providers/fortnox/mapper.ts | 13 - lib/providers/fortnox/oauth.ts | 24 - lib/providers/oauth-config.ts | 4 - lib/providers/visma/config.ts | 1 - lib/providers/visma/oauth.ts | 25 +- lib/providers/wint/client.ts | 6 - lib/providers/with-provider-call.ts | 4 - lib/reconciliation/bank-reconciliation.ts | 3 +- lib/reconciliation/schemas.ts | 29 +- lib/reconciliation/service.ts | 18 +- lib/reconciliation/signoff.ts | 7 +- .../skattekonto-reconciliation.ts | 71 +- lib/reference-data/fiscal-scope.ts | 9 +- .../behandlingshistorik-pdf-template.tsx | 6 +- lib/reports/bokslutsbilagor-pdf-template.tsx | 6 +- lib/reports/catalog.ts | 26 - .../financial-statement-pdf-template.tsx | 35 +- lib/reports/ink2/sru-generator.ts | 29 +- .../kassaflodesanalys-pdf-template.tsx | 16 +- lib/reports/kpi-definitions.ts | 4 - lib/reports/ne-bilaga/ne-engine.ts | 17 - lib/reports/ne-bilaga/sru-generator.ts | 22 +- lib/reports/ne-bilaga/types.ts | 14 - .../operational-report-pdf-template.tsx | 16 +- lib/reports/period-dates.ts | 4 +- lib/reports/reskontra-pdf-template.tsx | 43 +- lib/reports/sru/format.ts | 26 + lib/reports/vat-declaration-pdf-template.tsx | 16 +- lib/reports/vat-revenue-accounts.ts | 14 - lib/reports/xlsx-export.ts | 11 + lib/salary/agi/kontrollera-schemas.ts | 4 - lib/salary/agi/xml-generator.ts | 14 +- lib/salary/derive-absence-line-items.ts | 9 +- lib/salary/ku/ku10-generator.ts | 9 +- lib/salary/payment/pain001-generator.ts | 10 +- lib/salary/personnummer-format.ts | 21 +- lib/salary/traktamente.ts | 3 +- lib/skatteverket/manual-verifikat-prefill.ts | 2 +- lib/supabase/proxy-timing.ts | 4 +- lib/tax/deadline-config.ts | 35 - lib/tax/deadline-generator.ts | 26 +- lib/tax/swedish-holidays.ts | 68 +- lib/transactions/__tests__/ingest.test.ts | 3 +- lib/transactions/ingest.ts | 3 - lib/transactions/period-filter.ts | 7 +- lib/utils.ts | 29 +- lib/vat/moms-box-mapping.ts | 12 - lib/webhooks/signing.ts | 74 +- lib/webhooks/url-guard.ts | 2 - lib/webshop-orders/booking-lines.ts | 2 - lib/webshop-orders/ingest.ts | 7 +- lib/xml/escape.ts | 14 + messages/en.json | 647 +-------- messages/sv.json | 647 +-------- package-lock.json | 46 +- package.json | 6 +- scripts/backfill-tax-deadlines.ts | 2 +- scripts/checks/antipatterns-baseline.json | 4 +- scripts/checks/no-new-antipatterns.mjs | 3 - tests/helpers.ts | 78 -- types/chat.ts | 95 -- types/index.ts | 684 +--------- types/skatteverket.ts | 22 - 359 files changed, 1796 insertions(+), 11199 deletions(-) create mode 100644 app/api/sandbox/seed/date-utils.ts delete mode 100644 components/bookkeeping/ChartOfAccounts.tsx delete mode 100644 components/common/CashAccountSelector.tsx delete mode 100644 components/common/DataState.tsx delete mode 100644 components/deadlines/TaxTodoWidget.tsx delete mode 100644 components/deadlines/UpcomingDeadlinesWidget.tsx delete mode 100644 components/deadlines/index.ts delete mode 100644 components/extensions/general/AiCategorizationWorkspace.tsx delete mode 100644 components/extensions/general/ReceiptOcrWorkspace.tsx delete mode 100644 components/extensions/shared/ConfirmDeleteDialog.tsx delete mode 100644 components/extensions/shared/CsvImportWizard.tsx delete mode 100644 components/extensions/shared/DataEntryForm.tsx delete mode 100644 components/extensions/shared/DateRangeFilter.tsx delete mode 100644 components/extensions/shared/EditEntryDialog.tsx delete mode 100644 components/extensions/shared/ExtensionLoadingSkeleton.tsx delete mode 100644 components/extensions/shared/KPICard.tsx delete mode 100644 components/extensions/shared/MockDataBanner.tsx delete mode 100644 components/extensions/shared/MockDataImportDialog.tsx delete mode 100644 components/extensions/shared/MonthlyTrendTable.tsx delete mode 100644 components/extensions/shared/SetupPrompt.tsx create mode 100644 components/invoices/types.ts create mode 100644 components/reports/api-error.ts delete mode 100644 components/salary/NewSalaryRunDialog.tsx delete mode 100644 components/salary/VacationBalanceCard.tsx delete mode 100644 components/transactions/CategoryExpandedDialog.tsx delete mode 100644 components/ui/fallback-prompt.tsx delete mode 100644 components/ui/sheet.tsx delete mode 100644 extensions/general/calendar/components/PaymentSummaryCard.tsx delete mode 100644 extensions/general/push-notifications/NotificationSettings.tsx delete mode 100644 extensions/general/push-notifications/PushPrompt.tsx delete mode 100644 extensions/general/push-notifications/types.ts create mode 100644 lib/api/__tests__/token-rate-limit.test.ts create mode 100644 lib/api/token-rate-limit.ts create mode 100644 lib/api/v1/body.ts create mode 100644 lib/bookkeeping/booking-template-schemas.ts delete mode 100644 lib/bookkeeping/template-embeddings.ts create mode 100644 lib/browser/panel-request.ts create mode 100644 lib/dates/iso.ts delete mode 100644 lib/entitlements/metering.ts delete mode 100644 lib/extensions/index.ts create mode 100644 lib/invariants/__tests__/uuid.test.ts create mode 100644 lib/invariants/uuid.ts create mode 100644 lib/invoices/voucher-matching-shared.ts delete mode 100644 lib/providers/bokio/oauth.ts create mode 100644 lib/reports/sru/format.ts create mode 100644 lib/xml/escape.ts delete mode 100644 types/chat.ts diff --git a/.claude/rules/design.md b/.claude/rules/design.md index 56de8394..0b8aaa4a 100644 --- a/.claude/rules/design.md +++ b/.claude/rules/design.md @@ -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 ``, 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 `` 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` `` | 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 `
…
`. Preset variants exist (`EmptyInvoices`, `EmptyCustomers`, `EmptyTransactions`, etc.). | +| No-data state | `components/ui/empty-state.tsx` `EmptyState` | Don't hand-roll `
…
`. Preset variants exist (`EmptyCustomers`, `EmptyByraClients`). | | Loading placeholder | `components/ui/skeleton.tsx` `` | 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. | diff --git a/DECISIONS.md b/DECISIONS.md index c375d543..f9a90596 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1481,6 +1481,7 @@ One line per decision: `[YYYY-MM-DD] : `. 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. diff --git a/app/(dashboard)/bookkeeping/page.tsx b/app/(dashboard)/bookkeeping/page.tsx index e2cde481..f92bd111 100644 --- a/app/(dashboard)/bookkeeping/page.tsx +++ b/app/(dashboard)/bookkeeping/page.tsx @@ -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() diff --git a/app/(dashboard)/import/page.tsx b/app/(dashboard)/import/page.tsx index aac59c78..1dd60a7f 100644 --- a/app/(dashboard)/import/page.tsx +++ b/app/(dashboard)/import/page.tsx @@ -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, diff --git a/app/(dashboard)/invoices/[id]/credit/page.tsx b/app/(dashboard)/invoices/[id]/credit/page.tsx index 42b5819b..07644cdb 100644 --- a/app/(dashboard)/invoices/[id]/credit/page.tsx +++ b/app/(dashboard)/invoices/[id]/credit/page.tsx @@ -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() diff --git a/app/(dashboard)/invoices/[id]/page.tsx b/app/(dashboard)/invoices/[id]/page.tsx index 019bffaf..82d54b53 100644 --- a/app/(dashboard)/invoices/[id]/page.tsx +++ b/app/(dashboard)/invoices/[id]/page.tsx @@ -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() diff --git a/app/(dashboard)/reports/kassaflodesanalys/KassaflodesanalysClient.tsx b/app/(dashboard)/reports/kassaflodesanalys/KassaflodesanalysClient.tsx index b1e0e7b1..526cf263 100644 --- a/app/(dashboard)/reports/kassaflodesanalys/KassaflodesanalysClient.tsx +++ b/app/(dashboard)/reports/kassaflodesanalys/KassaflodesanalysClient.tsx @@ -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 diff --git a/app/(dashboard)/suppliers/[id]/page.tsx b/app/(dashboard)/suppliers/[id]/page.tsx index 679adacf..111e6777 100644 --- a/app/(dashboard)/suppliers/[id]/page.tsx +++ b/app/(dashboard)/suppliers/[id]/page.tsx @@ -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}` diff --git a/app/(dashboard)/transactions/page.tsx b/app/(dashboard)/transactions/page.tsx index 79bd940a..4fac088d 100644 --- a/app/(dashboard)/transactions/page.tsx +++ b/app/(dashboard)/transactions/page.tsx @@ -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(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 { - 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 && { - if (isMatchingFromPicker) return setInvoicePickerOpen(open) if (!open) setInvoicePickerTransaction(null) }} @@ -4284,7 +4241,6 @@ export default function TransactionsPage() { )} @@ -4294,7 +4250,6 @@ export default function TransactionsPage() { {supplierInvoicePickerOpen && { - if (isMatchingSupplierFromPicker) return setSupplierInvoicePickerOpen(open) if (!open) setSupplierInvoicePickerTransaction(null) }} @@ -4314,7 +4269,6 @@ export default function TransactionsPage() { )} diff --git a/app/.well-known/skills/index.json/route.ts b/app/.well-known/skills/index.json/route.ts index 591756dc..512f673e 100644 --- a/app/.well-known/skills/index.json/route.ts +++ b/app/.well-known/skills/index.json/route.ts @@ -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' diff --git a/app/api/bookkeeping/fiscal-periods/[id]/arsredovisning/signatures/[signatureId]/route.ts b/app/api/bookkeeping/fiscal-periods/[id]/arsredovisning/signatures/[signatureId]/route.ts index 97d61e87..96d57209 100644 --- a/app/api/bookkeeping/fiscal-periods/[id]/arsredovisning/signatures/[signatureId]/route.ts +++ b/app/api/bookkeeping/fiscal-periods/[id]/arsredovisning/signatures/[signatureId]/route.ts @@ -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 } diff --git a/app/api/calendar/feed/[token]/route.ts b/app/api/calendar/feed/[token]/route.ts index f09d33f5..31eeccce 100644 --- a/app/api/calendar/feed/[token]/route.ts +++ b/app/api/calendar/feed/[token]/route.ts @@ -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() -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,22 +24,13 @@ 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) { - return new NextResponse('Too many requests', { status: 429 }) - } - rateEntry.count++ - } else { - rateLimitMap.set(token, { count: 1, resetAt: nowMs + RATE_LIMIT_WINDOW_MS }) + if (!rateLimiter.allow(token)) { + return new NextResponse('Too many requests', { status: 429 }) } // Create service client (no user auth required) diff --git a/app/api/cash-accounts/route.ts b/app/api/cash-accounts/route.ts index 478059c1..d181e2e3 100644 --- a/app/api/cash-accounts/route.ts +++ b/app/api/cash-accounts/route.ts @@ -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. * diff --git a/app/api/company/[id]/migration-reset/archive/route.ts b/app/api/company/[id]/migration-reset/archive/route.ts index 372f2f7b..50ea0f59 100644 --- a/app/api/company/[id]/migration-reset/archive/route.ts +++ b/app/api/company/[id]/migration-reset/archive/route.ts @@ -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( 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( } }, ) - -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}` -} diff --git a/app/api/company/[id]/migration-reset/route.ts b/app/api/company/[id]/migration-reset/route.ts index c6c8304e..e8480095 100644 --- a/app/api/company/[id]/migration-reset/route.ts +++ b/app/api/company/[id]/migration-reset/route.ts @@ -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 * diff --git a/app/api/extensions/enable-banking/callback/finalize-page.ts b/app/api/extensions/enable-banking/callback/finalize-page.ts index 750fa817..9cdca510 100644 --- a/app/api/extensions/enable-banking/callback/finalize-page.ts +++ b/app/api/extensions/enable-banking/callback/finalize-page.ts @@ -27,14 +27,7 @@ * the nonce is blocked on this response. */ -function escapeHtml(value: string): string { - return value - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, ''') -} +import { escapeHtml } from '@/lib/email/user-text' /** * Opening chunk: full document head, styles, spinner and heading, plus a diff --git a/app/api/extensions/woocommerce/callback/route.ts b/app/api/extensions/woocommerce/callback/route.ts index 2d441053..52ee7828 100644 --- a/app/api/extensions/woocommerce/callback/route.ts +++ b/app/api/extensions/woocommerce/callback/route.ts @@ -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 }) } diff --git a/app/api/import/articles/execute/route.ts b/app/api/import/articles/execute/route.ts index a6131fd9..3554ae2b 100644 --- a/app/api/import/articles/execute/route.ts +++ b/app/api/import/articles/execute/route.ts @@ -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 * diff --git a/app/api/import/articles/parse/route.ts b/app/api/import/articles/parse/route.ts index c2ed0785..4b84fb9b 100644 --- a/app/api/import/articles/parse/route.ts +++ b/app/api/import/articles/parse/route.ts @@ -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 * diff --git a/app/api/import/bank-file/execute/route.ts b/app/api/import/bank-file/execute/route.ts index 07a3d807..9b55dd8d 100644 --- a/app/api/import/bank-file/execute/route.ts +++ b/app/api/import/bank-file/execute/route.ts @@ -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' diff --git a/app/api/invoices/[id]/pdf/route.ts b/app/api/invoices/[id]/pdf/route.ts index 0c48004f..8e8a5755 100644 --- a/app/api/invoices/[id]/pdf/route.ts +++ b/app/api/invoices/[id]/pdf/route.ts @@ -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 }) => { diff --git a/app/api/invoices/[id]/refresh-exchange-rate/route.ts b/app/api/invoices/[id]/refresh-exchange-rate/route.ts index a3cc9ca8..67641a1e 100644 --- a/app/api/invoices/[id]/refresh-exchange-rate/route.ts +++ b/app/api/invoices/[id]/refresh-exchange-rate/route.ts @@ -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 diff --git a/app/api/invoices/preview-pdf/route.ts b/app/api/invoices/preview-pdf/route.ts index c2b2d688..94270672 100644 --- a/app/api/invoices/preview-pdf/route.ts +++ b/app/api/invoices/preview-pdf/route.ts @@ -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 diff --git a/app/api/payslip/[token]/pdf/route.ts b/app/api/payslip/[token]/pdf/route.ts index 521e13a3..1e95cfae 100644 --- a/app/api/payslip/[token]/pdf/route.ts +++ b/app/api/payslip/[token]/pdf/route.ts @@ -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() -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,16 +28,8 @@ 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) { - return new NextResponse('Too many requests', { status: 429 }) - } - rateEntry.count++ - } else { - rateLimitMap.set(token, { count: 1, resetAt: nowMs + RATE_LIMIT_WINDOW_MS }) + if (!rateLimiter.allow(token)) { + return new NextResponse('Too many requests', { status: 429 }) } const serviceClient = createServiceClientNoCookies() diff --git a/app/api/reconciliation/accounts/[accountKey]/links/route.ts b/app/api/reconciliation/accounts/[accountKey]/links/route.ts index 7e9b57c2..03beb0fd 100644 --- a/app/api/reconciliation/accounts/[accountKey]/links/route.ts +++ b/app/api/reconciliation/accounts/[accountKey]/links/route.ts @@ -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 diff --git a/app/api/reports/ar-ledger/xlsx/route.ts b/app/api/reports/ar-ledger/xlsx/route.ts index 2367a262..cdc539a0 100644 --- a/app/api/reports/ar-ledger/xlsx/route.ts +++ b/app/api/reports/ar-ledger/xlsx/route.ts @@ -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, diff --git a/app/api/reports/full-archive/route.ts b/app/api/reports/full-archive/route.ts index ae0f8865..4bf8a07b 100644 --- a/app/api/reports/full-archive/route.ts +++ b/app/api/reports/full-archive/route.ts @@ -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}` -} diff --git a/app/api/reports/general-ledger/xlsx/route.ts b/app/api/reports/general-ledger/xlsx/route.ts index d447aac9..6c4945f5 100644 --- a/app/api/reports/general-ledger/xlsx/route.ts +++ b/app/api/reports/general-ledger/xlsx/route.ts @@ -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, diff --git a/app/api/reports/journal-register/xlsx/route.ts b/app/api/reports/journal-register/xlsx/route.ts index 13b51e6d..3306dacd 100644 --- a/app/api/reports/journal-register/xlsx/route.ts +++ b/app/api/reports/journal-register/xlsx/route.ts @@ -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, diff --git a/app/api/reports/salary-journal/xlsx/route.ts b/app/api/reports/salary-journal/xlsx/route.ts index b681c2ff..5e659efc 100644 --- a/app/api/reports/salary-journal/xlsx/route.ts +++ b/app/api/reports/salary-journal/xlsx/route.ts @@ -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, diff --git a/app/api/sandbox/seed/date-utils.ts b/app/api/sandbox/seed/date-utils.ts new file mode 100644 index 00000000..633e747a --- /dev/null +++ b/app/api/sandbox/seed/date-utils.ts @@ -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() +} diff --git a/app/api/sandbox/seed/ledger-history.ts b/app/api/sandbox/seed/ledger-history.ts index a5e6ca43..3cfcd405 100644 --- a/app/api/sandbox/seed/ledger-history.ts +++ b/app/api/sandbox/seed/ledger-history.ts @@ -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 diff --git a/app/api/sandbox/seed/salary.ts b/app/api/sandbox/seed/salary.ts index 8e98d6ec..413d24f2 100644 --- a/app/api/sandbox/seed/salary.ts +++ b/app/api/sandbox/seed/salary.ts @@ -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. diff --git a/app/api/settings/booking-templates/[id]/route.ts b/app/api/settings/booking-templates/[id]/route.ts index 87b21728..2a63a1a7 100644 --- a/app/api/settings/booking-templates/[id]/route.ts +++ b/app/api/settings/booking-templates/[id]/route.ts @@ -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(), }) diff --git a/app/api/settings/booking-templates/import/route.ts b/app/api/settings/booking-templates/import/route.ts index b18cf51c..aed1e877 100644 --- a/app/api/settings/booking-templates/import/route.ts +++ b/app/api/settings/booking-templates/import/route.ts @@ -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({ diff --git a/app/api/settings/booking-templates/route.ts b/app/api/settings/booking-templates/route.ts index 15b47cfd..c74f38a3 100644 --- a/app/api/settings/booking-templates/route.ts +++ b/app/api/settings/booking-templates/route.ts @@ -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(), }) diff --git a/app/api/v1/companies/[companyId]/accounts/route.ts b/app/api/v1/companies/[companyId]/accounts/route.ts index c64349ae..b765e90e 100644 --- a/app/api/v1/companies/[companyId]/accounts/route.ts +++ b/app/api/v1/companies/[companyId]/accounts/route.ts @@ -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' diff --git a/app/api/v1/companies/[companyId]/cash-accounts/route.ts b/app/api/v1/companies/[companyId]/cash-accounts/route.ts index b4a88235..47c433da 100644 --- a/app/api/v1/companies/[companyId]/cash-accounts/route.ts +++ b/app/api/v1/companies/[companyId]/cash-accounts/route.ts @@ -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!, { diff --git a/app/api/v1/companies/[companyId]/customers/[id]/route.ts b/app/api/v1/companies/[companyId]/customers/[id]/route.ts index 72faa962..793b60a9 100644 --- a/app/api/v1/companies/[companyId]/customers/[id]/route.ts +++ b/app/api/v1/companies/[companyId]/customers/[id]/route.ts @@ -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 diff --git a/app/api/v1/companies/[companyId]/customers/bulk-create/route.ts b/app/api/v1/companies/[companyId]/customers/bulk-create/route.ts index 0f9ca975..6e243bba 100644 --- a/app/api/v1/companies/[companyId]/customers/bulk-create/route.ts +++ b/app/api/v1/companies/[companyId]/customers/bulk-create/route.ts @@ -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. diff --git a/app/api/v1/companies/[companyId]/customers/route.ts b/app/api/v1/companies/[companyId]/customers/route.ts index 422ad8f9..4fa3a73c 100644 --- a/app/api/v1/companies/[companyId]/customers/route.ts +++ b/app/api/v1/companies/[companyId]/customers/route.ts @@ -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 diff --git a/app/api/v1/companies/[companyId]/dimensions/[id]/values/[valueId]/route.ts b/app/api/v1/companies/[companyId]/dimensions/[id]/values/[valueId]/route.ts index 22d1d69c..33411b8c 100644 --- a/app/api/v1/companies/[companyId]/dimensions/[id]/values/[valueId]/route.ts +++ b/app/api/v1/companies/[companyId]/dimensions/[id]/values/[valueId]/route.ts @@ -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( }) } - 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 diff --git a/app/api/v1/companies/[companyId]/dimensions/[id]/values/route.ts b/app/api/v1/companies/[companyId]/dimensions/[id]/values/route.ts index ff53bd21..10c9605f 100644 --- a/app/api/v1/companies/[companyId]/dimensions/[id]/values/route.ts +++ b/app/api/v1/companies/[companyId]/dimensions/[id]/values/route.ts @@ -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. diff --git a/app/api/v1/companies/[companyId]/documents/[id]/link/route.ts b/app/api/v1/companies/[companyId]/documents/[id]/link/route.ts index 2a503abb..e976aa45 100644 --- a/app/api/v1/companies/[companyId]/documents/[id]/link/route.ts +++ b/app/api/v1/companies/[companyId]/documents/[id]/link/route.ts @@ -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 diff --git a/app/api/v1/companies/[companyId]/employees/[id]/absence/route.ts b/app/api/v1/companies/[companyId]/employees/[id]/absence/route.ts index ac607d2a..da7c87dd 100644 --- a/app/api/v1/companies/[companyId]/employees/[id]/absence/route.ts +++ b/app/api/v1/companies/[companyId]/employees/[id]/absence/route.ts @@ -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, { diff --git a/app/api/v1/companies/[companyId]/employees/[id]/opening-balances/route.ts b/app/api/v1/companies/[companyId]/employees/[id]/opening-balances/route.ts index f5810fc5..b64b44b5 100644 --- a/app/api/v1/companies/[companyId]/employees/[id]/opening-balances/route.ts +++ b/app/api/v1/companies/[companyId]/employees/[id]/opening-balances/route.ts @@ -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. diff --git a/app/api/v1/companies/[companyId]/employees/[id]/route.ts b/app/api/v1/companies/[companyId]/employees/[id]/route.ts index 18c69fb9..c24eb9a7 100644 --- a/app/api/v1/companies/[companyId]/employees/[id]/route.ts +++ b/app/api/v1/companies/[companyId]/employees/[id]/route.ts @@ -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 diff --git a/app/api/v1/companies/[companyId]/employees/opening-balances/route.ts b/app/api/v1/companies/[companyId]/employees/opening-balances/route.ts index cf44c45d..66cf3998 100644 --- a/app/api/v1/companies/[companyId]/employees/opening-balances/route.ts +++ b/app/api/v1/companies/[companyId]/employees/opening-balances/route.ts @@ -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!, diff --git a/app/api/v1/companies/[companyId]/employees/route.ts b/app/api/v1/companies/[companyId]/employees/route.ts index 4419cc1e..50cdd519 100644 --- a/app/api/v1/companies/[companyId]/employees/route.ts +++ b/app/api/v1/companies/[companyId]/employees/route.ts @@ -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 diff --git a/app/api/v1/companies/[companyId]/fiscal-periods/[id]/currency-revaluation/route.ts b/app/api/v1/companies/[companyId]/fiscal-periods/[id]/currency-revaluation/route.ts index 542ad84d..594b7c04 100644 --- a/app/api/v1/companies/[companyId]/fiscal-periods/[id]/currency-revaluation/route.ts +++ b/app/api/v1/companies/[companyId]/fiscal-periods/[id]/currency-revaluation/route.ts @@ -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 } diff --git a/app/api/v1/companies/[companyId]/fiscal-periods/[id]/opening-balances/route.ts b/app/api/v1/companies/[companyId]/fiscal-periods/[id]/opening-balances/route.ts index dbbae0a9..2260c783 100644 --- a/app/api/v1/companies/[companyId]/fiscal-periods/[id]/opening-balances/route.ts +++ b/app/api/v1/companies/[companyId]/fiscal-periods/[id]/opening-balances/route.ts @@ -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 diff --git a/app/api/v1/companies/[companyId]/imports/bank/route.ts b/app/api/v1/companies/[companyId]/imports/bank/route.ts index 1ac3b147..e2217974 100644 --- a/app/api/v1/companies/[companyId]/imports/bank/route.ts +++ b/app/api/v1/companies/[companyId]/imports/bank/route.ts @@ -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' diff --git a/app/api/v1/companies/[companyId]/imports/sie/route.ts b/app/api/v1/companies/[companyId]/imports/sie/route.ts index 46b73e02..06a05cd0 100644 --- a/app/api/v1/companies/[companyId]/imports/sie/route.ts +++ b/app/api/v1/companies/[companyId]/imports/sie/route.ts @@ -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 diff --git a/app/api/v1/companies/[companyId]/inbox-items/[id]/stamp/route.ts b/app/api/v1/companies/[companyId]/inbox-items/[id]/stamp/route.ts index 76c1a0a0..d56d3c21 100644 --- a/app/api/v1/companies/[companyId]/inbox-items/[id]/stamp/route.ts +++ b/app/api/v1/companies/[companyId]/inbox-items/[id]/stamp/route.ts @@ -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([ diff --git a/app/api/v1/companies/[companyId]/invoices/[id]/credit/route.ts b/app/api/v1/companies/[companyId]/invoices/[id]/credit/route.ts index 500a9137..5181205b 100644 --- a/app/api/v1/companies/[companyId]/invoices/[id]/credit/route.ts +++ b/app/api/v1/companies/[companyId]/invoices/[id]/credit/route.ts @@ -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 } diff --git a/app/api/v1/companies/[companyId]/invoices/[id]/mark-paid/route.ts b/app/api/v1/companies/[companyId]/invoices/[id]/mark-paid/route.ts index d0a79a2b..39e7e36b 100644 --- a/app/api/v1/companies/[companyId]/invoices/[id]/mark-paid/route.ts +++ b/app/api/v1/companies/[companyId]/invoices/[id]/mark-paid/route.ts @@ -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 diff --git a/app/api/v1/companies/[companyId]/invoices/[id]/route.ts b/app/api/v1/companies/[companyId]/invoices/[id]/route.ts index 0a19ba06..9480384d 100644 --- a/app/api/v1/companies/[companyId]/invoices/[id]/route.ts +++ b/app/api/v1/companies/[companyId]/invoices/[id]/route.ts @@ -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 = {} diff --git a/app/api/v1/companies/[companyId]/invoices/[id]/send/route.ts b/app/api/v1/companies/[companyId]/invoices/[id]/send/route.ts index 5c3a7c37..40e8e3a9 100644 --- a/app/api/v1/companies/[companyId]/invoices/[id]/send/route.ts +++ b/app/api/v1/companies/[companyId]/invoices/[id]/send/route.ts @@ -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') { diff --git a/app/api/v1/companies/[companyId]/invoices/bulk-create/route.ts b/app/api/v1/companies/[companyId]/invoices/bulk-create/route.ts index 192927f2..77ffd0c5 100644 --- a/app/api/v1/companies/[companyId]/invoices/bulk-create/route.ts +++ b/app/api/v1/companies/[companyId]/invoices/bulk-create/route.ts @@ -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 diff --git a/app/api/v1/companies/[companyId]/invoices/route.ts b/app/api/v1/companies/[companyId]/invoices/route.ts index 1078b15d..d6b7f74f 100644 --- a/app/api/v1/companies/[companyId]/invoices/route.ts +++ b/app/api/v1/companies/[companyId]/invoices/route.ts @@ -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. diff --git a/app/api/v1/companies/[companyId]/journal-entries/[id]/correct/route.ts b/app/api/v1/companies/[companyId]/journal-entries/[id]/correct/route.ts index 90ebd767..5858b5bd 100644 --- a/app/api/v1/companies/[companyId]/journal-entries/[id]/correct/route.ts +++ b/app/api/v1/companies/[companyId]/journal-entries/[id]/correct/route.ts @@ -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) diff --git a/app/api/v1/companies/[companyId]/journal-entries/[id]/reverse/route.ts b/app/api/v1/companies/[companyId]/journal-entries/[id]/reverse/route.ts index ccfec185..52da09c5 100644 --- a/app/api/v1/companies/[companyId]/journal-entries/[id]/reverse/route.ts +++ b/app/api/v1/companies/[companyId]/journal-entries/[id]/reverse/route.ts @@ -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 } diff --git a/app/api/v1/companies/[companyId]/journal-entries/batch-create/route.ts b/app/api/v1/companies/[companyId]/journal-entries/batch-create/route.ts index f851f29e..6a2567b3 100644 --- a/app/api/v1/companies/[companyId]/journal-entries/batch-create/route.ts +++ b/app/api/v1/companies/[companyId]/journal-entries/batch-create/route.ts @@ -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) { diff --git a/app/api/v1/companies/[companyId]/journal-entries/route.ts b/app/api/v1/companies/[companyId]/journal-entries/route.ts index 027932fd..d2e45279 100644 --- a/app/api/v1/companies/[companyId]/journal-entries/route.ts +++ b/app/api/v1/companies/[companyId]/journal-entries/route.ts @@ -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 diff --git a/app/api/v1/companies/[companyId]/reconciliation/accounts/[accountKey]/items/[itemId]/ignore/route.ts b/app/api/v1/companies/[companyId]/reconciliation/accounts/[accountKey]/items/[itemId]/ignore/route.ts index 757feb23..9b818ec1 100644 --- a/app/api/v1/companies/[companyId]/reconciliation/accounts/[accountKey]/items/[itemId]/ignore/route.ts +++ b/app/api/v1/companies/[companyId]/reconciliation/accounts/[accountKey]/items/[itemId]/ignore/route.ts @@ -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) { diff --git a/app/api/v1/companies/[companyId]/reconciliation/accounts/[accountKey]/items/route.ts b/app/api/v1/companies/[companyId]/reconciliation/accounts/[accountKey]/items/route.ts index 354437c8..fde16873 100644 --- a/app/api/v1/companies/[companyId]/reconciliation/accounts/[accountKey]/items/route.ts +++ b/app/api/v1/companies/[companyId]/reconciliation/accounts/[accountKey]/items/route.ts @@ -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, { diff --git a/app/api/v1/companies/[companyId]/reconciliation/accounts/[accountKey]/links/route.ts b/app/api/v1/companies/[companyId]/reconciliation/accounts/[accountKey]/links/route.ts index dc9d15b1..c497f788 100644 --- a/app/api/v1/companies/[companyId]/reconciliation/accounts/[accountKey]/links/route.ts +++ b/app/api/v1/companies/[companyId]/reconciliation/accounts/[accountKey]/links/route.ts @@ -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, diff --git a/app/api/v1/companies/[companyId]/reconciliation/accounts/[accountKey]/residual/route.ts b/app/api/v1/companies/[companyId]/reconciliation/accounts/[accountKey]/residual/route.ts index c1588d1c..c05c9045 100644 --- a/app/api/v1/companies/[companyId]/reconciliation/accounts/[accountKey]/residual/route.ts +++ b/app/api/v1/companies/[companyId]/reconciliation/accounts/[accountKey]/residual/route.ts @@ -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, diff --git a/app/api/v1/companies/[companyId]/reconciliation/accounts/[accountKey]/route.ts b/app/api/v1/companies/[companyId]/reconciliation/accounts/[accountKey]/route.ts index 8fe8a8ed..803eae85 100644 --- a/app/api/v1/companies/[companyId]/reconciliation/accounts/[accountKey]/route.ts +++ b/app/api/v1/companies/[companyId]/reconciliation/accounts/[accountKey]/route.ts @@ -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, diff --git a/app/api/v1/companies/[companyId]/reconciliation/accounts/[accountKey]/signoff/[signoffId]/reopen/route.ts b/app/api/v1/companies/[companyId]/reconciliation/accounts/[accountKey]/signoff/[signoffId]/reopen/route.ts index 98353965..65350855 100644 --- a/app/api/v1/companies/[companyId]/reconciliation/accounts/[accountKey]/signoff/[signoffId]/reopen/route.ts +++ b/app/api/v1/companies/[companyId]/reconciliation/accounts/[accountKey]/signoff/[signoffId]/reopen/route.ts @@ -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( diff --git a/app/api/v1/companies/[companyId]/reconciliation/accounts/[accountKey]/signoff/route.ts b/app/api/v1/companies/[companyId]/reconciliation/accounts/[accountKey]/signoff/route.ts index 41758cb7..0c3e1be5 100644 --- a/app/api/v1/companies/[companyId]/reconciliation/accounts/[accountKey]/signoff/route.ts +++ b/app/api/v1/companies/[companyId]/reconciliation/accounts/[accountKey]/signoff/route.ts @@ -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, diff --git a/app/api/v1/companies/[companyId]/reconciliation/accounts/route.ts b/app/api/v1/companies/[companyId]/reconciliation/accounts/route.ts index 8cda76c4..3095afc7 100644 --- a/app/api/v1/companies/[companyId]/reconciliation/accounts/route.ts +++ b/app/api/v1/companies/[companyId]/reconciliation/accounts/route.ts @@ -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, diff --git a/app/api/v1/companies/[companyId]/reconciliation/bank/run/route.ts b/app/api/v1/companies/[companyId]/reconciliation/bank/run/route.ts index bfacb60d..74a8ba09 100644 --- a/app/api/v1/companies/[companyId]/reconciliation/bank/run/route.ts +++ b/app/api/v1/companies/[companyId]/reconciliation/bank/run/route.ts @@ -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 diff --git a/app/api/v1/companies/[companyId]/reconciliation/bank/status/route.ts b/app/api/v1/companies/[companyId]/reconciliation/bank/status/route.ts index f98b1e34..d11ea57f 100644 --- a/app/api/v1/companies/[companyId]/reconciliation/bank/status/route.ts +++ b/app/api/v1/companies/[companyId]/reconciliation/bank/status/route.ts @@ -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 diff --git a/app/api/v1/companies/[companyId]/reports/vat-declaration/route.ts b/app/api/v1/companies/[companyId]/reports/vat-declaration/route.ts index ac2f58c5..de58f5dc 100644 --- a/app/api/v1/companies/[companyId]/reports/vat-declaration/route.ts +++ b/app/api/v1/companies/[companyId]/reports/vat-declaration/route.ts @@ -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 diff --git a/app/api/v1/companies/[companyId]/salary-runs/[id]/employees/[employeeId]/lines/route.ts b/app/api/v1/companies/[companyId]/salary-runs/[id]/employees/[employeeId]/lines/route.ts index 515a0b91..b6d7c300 100644 --- a/app/api/v1/companies/[companyId]/salary-runs/[id]/employees/[employeeId]/lines/route.ts +++ b/app/api/v1/companies/[companyId]/salary-runs/[id]/employees/[employeeId]/lines/route.ts @@ -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!, diff --git a/app/api/v1/companies/[companyId]/salary-runs/[id]/employees/route.ts b/app/api/v1/companies/[companyId]/salary-runs/[id]/employees/route.ts index dc4d29ee..a4f4c92a 100644 --- a/app/api/v1/companies/[companyId]/salary-runs/[id]/employees/route.ts +++ b/app/api/v1/companies/[companyId]/salary-runs/[id]/employees/route.ts @@ -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!, diff --git a/app/api/v1/companies/[companyId]/salary-runs/[id]/lines/[lineId]/route.ts b/app/api/v1/companies/[companyId]/salary-runs/[id]/lines/[lineId]/route.ts index 03f1f59b..cd175a35 100644 --- a/app/api/v1/companies/[companyId]/salary-runs/[id]/lines/[lineId]/route.ts +++ b/app/api/v1/companies/[companyId]/salary-runs/[id]/lines/[lineId]/route.ts @@ -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 diff --git a/app/api/v1/companies/[companyId]/salary-runs/[id]/route.ts b/app/api/v1/companies/[companyId]/salary-runs/[id]/route.ts index 951a0c68..61ad713b 100644 --- a/app/api/v1/companies/[companyId]/salary-runs/[id]/route.ts +++ b/app/api/v1/companies/[companyId]/salary-runs/[id]/route.ts @@ -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 diff --git a/app/api/v1/companies/[companyId]/salary-runs/route.ts b/app/api/v1/companies/[companyId]/salary-runs/route.ts index 6498b5a7..7932762c 100644 --- a/app/api/v1/companies/[companyId]/salary-runs/route.ts +++ b/app/api/v1/companies/[companyId]/salary-runs/route.ts @@ -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) { diff --git a/app/api/v1/companies/[companyId]/salary/vacation-year-close/route.ts b/app/api/v1/companies/[companyId]/salary/vacation-year-close/route.ts index 752957a3..544f3b26 100644 --- a/app/api/v1/companies/[companyId]/salary/vacation-year-close/route.ts +++ b/app/api/v1/companies/[companyId]/salary/vacation-year-close/route.ts @@ -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) { diff --git a/app/api/v1/companies/[companyId]/settings/route.ts b/app/api/v1/companies/[companyId]/settings/route.ts index 072d5912..ad6803f6 100644 --- a/app/api/v1/companies/[companyId]/settings/route.ts +++ b/app/api/v1/companies/[companyId]/settings/route.ts @@ -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, { diff --git a/app/api/v1/companies/[companyId]/skatteverket/vat-declarations/route.ts b/app/api/v1/companies/[companyId]/skatteverket/vat-declarations/route.ts index b4827bbc..b4ced3df 100644 --- a/app/api/v1/companies/[companyId]/skatteverket/vat-declarations/route.ts +++ b/app/api/v1/companies/[companyId]/skatteverket/vat-declarations/route.ts @@ -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 diff --git a/app/api/v1/companies/[companyId]/supplier-invoices/[id]/mark-paid/route.ts b/app/api/v1/companies/[companyId]/supplier-invoices/[id]/mark-paid/route.ts index 5b02112d..b6f634ca 100644 --- a/app/api/v1/companies/[companyId]/supplier-invoices/[id]/mark-paid/route.ts +++ b/app/api/v1/companies/[companyId]/supplier-invoices/[id]/mark-paid/route.ts @@ -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 diff --git a/app/api/v1/companies/[companyId]/supplier-invoices/[id]/route.ts b/app/api/v1/companies/[companyId]/supplier-invoices/[id]/route.ts index 3b1ed5b6..e0ab4bd9 100644 --- a/app/api/v1/companies/[companyId]/supplier-invoices/[id]/route.ts +++ b/app/api/v1/companies/[companyId]/supplier-invoices/[id]/route.ts @@ -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 = {} diff --git a/app/api/v1/companies/[companyId]/supplier-invoices/route.ts b/app/api/v1/companies/[companyId]/supplier-invoices/route.ts index 6236cfbb..a8523d4e 100644 --- a/app/api/v1/companies/[companyId]/supplier-invoices/route.ts +++ b/app/api/v1/companies/[companyId]/supplier-invoices/route.ts @@ -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 diff --git a/app/api/v1/companies/[companyId]/suppliers/[id]/route.ts b/app/api/v1/companies/[companyId]/suppliers/[id]/route.ts index f11fe8f2..763be86e 100644 --- a/app/api/v1/companies/[companyId]/suppliers/[id]/route.ts +++ b/app/api/v1/companies/[companyId]/suppliers/[id]/route.ts @@ -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 = {} diff --git a/app/api/v1/companies/[companyId]/suppliers/bulk-create/route.ts b/app/api/v1/companies/[companyId]/suppliers/bulk-create/route.ts index 030dca89..12ff665f 100644 --- a/app/api/v1/companies/[companyId]/suppliers/bulk-create/route.ts +++ b/app/api/v1/companies/[companyId]/suppliers/bulk-create/route.ts @@ -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) { diff --git a/app/api/v1/companies/[companyId]/suppliers/route.ts b/app/api/v1/companies/[companyId]/suppliers/route.ts index 3a74915d..8070ce17 100644 --- a/app/api/v1/companies/[companyId]/suppliers/route.ts +++ b/app/api/v1/companies/[companyId]/suppliers/route.ts @@ -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) { diff --git a/app/api/v1/companies/[companyId]/transactions/[id]/categorize/route.ts b/app/api/v1/companies/[companyId]/transactions/[id]/categorize/route.ts index e41c9f42..9f733b85 100644 --- a/app/api/v1/companies/[companyId]/transactions/[id]/categorize/route.ts +++ b/app/api/v1/companies/[companyId]/transactions/[id]/categorize/route.ts @@ -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 diff --git a/app/api/v1/companies/[companyId]/transactions/[id]/match-invoice/route.ts b/app/api/v1/companies/[companyId]/transactions/[id]/match-invoice/route.ts index 9fa32f13..a6427c27 100644 --- a/app/api/v1/companies/[companyId]/transactions/[id]/match-invoice/route.ts +++ b/app/api/v1/companies/[companyId]/transactions/[id]/match-invoice/route.ts @@ -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 }) diff --git a/app/api/v1/companies/[companyId]/transactions/[id]/match-supplier-invoice/route.ts b/app/api/v1/companies/[companyId]/transactions/[id]/match-supplier-invoice/route.ts index 38a4f150..3c498388 100644 --- a/app/api/v1/companies/[companyId]/transactions/[id]/match-supplier-invoice/route.ts +++ b/app/api/v1/companies/[companyId]/transactions/[id]/match-supplier-invoice/route.ts @@ -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 }) diff --git a/app/api/v1/companies/[companyId]/transactions/batch-categorize/route.ts b/app/api/v1/companies/[companyId]/transactions/batch-categorize/route.ts index 90aca06e..16eaa1b0 100644 --- a/app/api/v1/companies/[companyId]/transactions/batch-categorize/route.ts +++ b/app/api/v1/companies/[companyId]/transactions/batch-categorize/route.ts @@ -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) { diff --git a/app/api/v1/companies/[companyId]/transactions/ingest/route.ts b/app/api/v1/companies/[companyId]/transactions/ingest/route.ts index 7b038519..26a40819 100644 --- a/app/api/v1/companies/[companyId]/transactions/ingest/route.ts +++ b/app/api/v1/companies/[companyId]/transactions/ingest/route.ts @@ -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) { diff --git a/app/api/v1/companies/[companyId]/transactions/route.ts b/app/api/v1/companies/[companyId]/transactions/route.ts index e83c2dfa..823e73c2 100644 --- a/app/api/v1/companies/[companyId]/transactions/route.ts +++ b/app/api/v1/companies/[companyId]/transactions/route.ts @@ -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 diff --git a/app/api/v1/companies/[companyId]/voucher-gap-explanations/route.ts b/app/api/v1/companies/[companyId]/voucher-gap-explanations/route.ts index 9dae67d1..f3102670 100644 --- a/app/api/v1/companies/[companyId]/voucher-gap-explanations/route.ts +++ b/app/api/v1/companies/[companyId]/voucher-gap-explanations/route.ts @@ -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 diff --git a/app/api/v1/companies/[companyId]/webhooks/[id]/route.ts b/app/api/v1/companies/[companyId]/webhooks/[id]/route.ts index da3be5bf..d93cd3d4 100644 --- a/app/api/v1/companies/[companyId]/webhooks/[id]/route.ts +++ b/app/api/v1/companies/[companyId]/webhooks/[id]/route.ts @@ -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 diff --git a/app/api/v1/companies/[companyId]/webhooks/route.ts b/app/api/v1/companies/[companyId]/webhooks/route.ts index 1965b639..fb01cd20 100644 --- a/app/api/v1/companies/[companyId]/webhooks/route.ts +++ b/app/api/v1/companies/[companyId]/webhooks/route.ts @@ -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 diff --git a/app/api/v1/companies/route.ts b/app/api/v1/companies/route.ts index 2c584340..6fd05a1b 100644 --- a/app/api/v1/companies/route.ts +++ b/app/api/v1/companies/route.ts @@ -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) diff --git a/components/agent-knowledge/LedgerGraph.tsx b/components/agent-knowledge/LedgerGraph.tsx index 5600478a..7927eaca 100644 --- a/components/agent-knowledge/LedgerGraph.tsx +++ b/components/agent-knowledge/LedgerGraph.tsx @@ -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
{t('card_amount')} - {e.total_amount.toLocaleString('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} + {formatAmount(e.total_amount)}
diff --git a/components/bookkeeping/ChartOfAccounts.tsx b/components/bookkeeping/ChartOfAccounts.tsx deleted file mode 100644 index 732ea57f..00000000 --- a/components/bookkeeping/ChartOfAccounts.tsx +++ /dev/null @@ -1,201 +0,0 @@ -'use client' - -import { useState } from 'react' -import { createClient } from '@/lib/supabase/client' -import { guardBrowserWrite } from '@/lib/company/tab-guard' -import { useAccounts } from '@/lib/reference-data/hooks' -import { invalidateReferenceData } from '@/lib/reference-data/invalidate' -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' -import { Button } from '@/components/ui/button' -import { Input } from '@/components/ui/input' -import { ChevronDown, ChevronRight, Search } from 'lucide-react' -import { AccountNumber } from '@/components/ui/account-number' -import type { BASAccount } from '@/types' - -const CLASS_LABELS: Record = { - 1: 'Tillgångar', - 2: 'Eget kapital och skulder', - 3: 'Intäkter', - 4: 'Varor och material', - 5: 'Övriga externa kostnader', - 6: 'Övriga externa kostnader', - 7: 'Personal och avskrivningar', - 8: 'Finansiella poster', -} - -export default function ChartOfAccounts() { - // Session-cached chart (lib/reference-data), shared with every picker. - const { accounts, isLoading: loading } = useAccounts() - const [expandedClasses, setExpandedClasses] = useState>(new Set()) - const [searchQuery, setSearchQuery] = useState('') - const [editingSRU, setEditingSRU] = useState(null) - const [sruValue, setSruValue] = useState('') - - async function updateSRUCode(accountId: string, newSruCode: string) { - // Cross-tab guard (WL-09): browser-direct Supabase write, outside the - // patched-fetch seam. The blocking dialog is the user feedback. - if (!guardBrowserWrite()) return - const supabase = createClient() - const trimmed = newSruCode.trim() || null - await supabase - .from('chart_of_accounts') - .update({ sru_code: trimmed }) - .eq('id', accountId) - - // Refresh the shared chart so this list and every picker show the code. - await invalidateReferenceData('ref:accounts') - setEditingSRU(null) - } - - const toggleClass = (cls: number) => { - const next = new Set(expandedClasses) - if (next.has(cls)) { - next.delete(cls) - } else { - next.add(cls) - } - setExpandedClasses(next) - } - - const filteredAccounts = accounts.filter((a) => { - if (!searchQuery) return true - const q = searchQuery.toLowerCase() - return ( - a.account_number.includes(q) || - a.account_name.toLowerCase().includes(q) - ) - }) - - const groupedByClass = filteredAccounts.reduce( - (acc, account) => { - const cls = account.account_class - if (!acc[cls]) acc[cls] = [] - acc[cls].push(account) - return acc - }, - {} as Record - ) - - if (loading) { - return ( - - - Laddar kontoplan... - - - ) - } - - return ( -
-
- - setSearchQuery(e.target.value)} - className="pl-9" - /> -
- -
- {Object.entries(groupedByClass) - .sort(([a], [b]) => Number(a) - Number(b)) - .map(([cls, classAccounts]) => { - const classNum = Number(cls) - const isExpanded = expandedClasses.has(classNum) || !!searchQuery - - return ( - - - - {isExpanded && ( - -
- - - - - - - - - - - {classAccounts.map((account) => ( - - - - - - - - ))} - -
KontoNamnSRUTypNormal
{account.account_name} - {editingSRU === account.id ? ( - setSruValue(e.target.value)} - onBlur={() => updateSRUCode(account.id, sruValue)} - onKeyDown={(e) => { - if (e.key === 'Enter') updateSRUCode(account.id, sruValue) - if (e.key === 'Escape') setEditingSRU(null) - }} - className="h-7 w-16 text-xs text-center px-1" - autoFocus - /> - ) : ( - - )} - - {account.account_type === 'asset' - ? 'Tillgång' - : account.account_type === 'liability' - ? 'Skuld' - : account.account_type === 'equity' - ? 'EK' - : account.account_type === 'revenue' - ? 'Intäkt' - : 'Kostnad'} - - {account.normal_balance === 'debit' ? 'Debet' : 'Kredit'} -
- - )} - - ) - })} - - - ) -} diff --git a/components/bookkeeping/DocumentViewButton.tsx b/components/bookkeeping/DocumentViewButton.tsx index dfb77715..5469c17e 100644 --- a/components/bookkeeping/DocumentViewButton.tsx +++ b/components/bookkeeping/DocumentViewButton.tsx @@ -1,5 +1,6 @@ 'use client' +import { UUID_RE } from '@/lib/invariants/uuid' import { Eye } from 'lucide-react' import { Button } from '@/components/ui/button' import { useToast } from '@/components/ui/use-toast' @@ -11,7 +12,6 @@ interface DocumentViewButtonProps { className?: 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 /** * Opens a document in the browser through the same-origin inline proxy diff --git a/components/bookkeeping/FiscalPeriodDateFields.tsx b/components/bookkeeping/FiscalPeriodDateFields.tsx index 3a25a75c..7e5f0d6f 100644 --- a/components/bookkeeping/FiscalPeriodDateFields.tsx +++ b/components/bookkeeping/FiscalPeriodDateFields.tsx @@ -1,9 +1,5 @@ 'use client' -import { useMemo, type ReactNode } from 'react' -import { Input } from '@/components/ui/input' -import { Label } from '@/components/ui/label' -import { CalendarDays } from 'lucide-react' import { monthsBetween, parseDateParts, @@ -11,16 +7,6 @@ import { } from '@/lib/bookkeeping/validate-period-duration' import type { EntityType } from '@/types' -const monthNames = [ - 'januari', 'februari', 'mars', 'april', 'maj', 'juni', - 'juli', 'augusti', 'september', 'oktober', 'november', 'december', -] - -function formatSwedishDate(dateStr: string): string { - const { year, month, day } = parseDateParts(dateStr) - return `${day} ${monthNames[month - 1]} ${year}` -} - function endsOnDec31(end: string): boolean { const e = parseDateParts(end) return e.month === 12 && e.day === 31 @@ -47,8 +33,8 @@ export interface FiscalPeriodValidation { } /** - * Shared validation for the first fiscal period: used by both onboarding Step 3 - * and the settings FiscalPeriodEditor. Returns Swedish error copy. + * Validation for the first fiscal period, used by the settings + * FiscalPeriodEditor. Returns Swedish error copy. */ export function validateFirstPeriod( startDate: string, @@ -89,91 +75,3 @@ export function validateFirstPeriod( canSummarise: true, } } - -interface FiscalPeriodDateFieldsProps { - startDate: string - onStartDateChange: (value: string) => void - startHelpText?: string - /** - * Render the end-date control. Onboarding passes its AB end-month ; settings passes a native . - */ - endDateSlot: ReactNode - /** The raw end-date string (used for summary + validation). */ - endDate: string - entityType: EntityType | undefined - /** Label for the summary card. Defaults to "Ditt första räkenskapsår". */ - summaryTitle?: string - /** Optional override for start date