Check/salary bankid api (#892)
* fix(bankid): harden login/signup flow — polling, signup rollback, metadata merge, enrichment lookup - middleware: read BankID enrichment from the bankid_enrichment table (the extension_data path has been dead since the multi-tenant refactor), so company-less BankID users land on /select-company instead of the manual wizard - BankIdAuth: hard 6-min poll deadline; every failed poll counts toward the give-up limit; guard overlapping ticks so completion runs exactly once (a double /complete regenerated the magic link and invalidated the first, failing logins intermittently); retry clicks wait out the start cooldown instead of silently no-oping; Swedish messages for 429/unknown start errors - bankid/complete: all-or-nothing signup — delete the created user when the identity insert, app_metadata update, or magic-link generation fails, so a retry starts clean instead of hitting account_exists with an unusable account - bankid/unlink: read-merge-write app_metadata so has_password survives unlink (BankID-only users could otherwise strand themselves with no login method) - login: BankID "create account" CTA now links to /register instead of dismissing the notice; sv.json: fix missing å/ä/ö in settings_bankid strings Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: move secondary guides into docs/, delete dead root files Move DOCKER.md, SELF-HOSTING.md, WHITELABEL.md and extensions.md (renamed EXTENSIONS.md) into a new docs/ folder and update all path references (README, setup.sh, .dockerignore image rules, docker-publish workflow comment, _example-branding, lib/branding/service.ts). Delete two dead root files: customer.json (stray API-test payload) and findings.md (point-in-time swarm audit export, criticals already filed). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Emil <emilmattsson14@gmail.com> * fix(api): security & correctness hardening + withRouteContext MFA migration across API routes Audit of ~100 app/api routes. Highlights: Security - agent/conversations: list leaked colleagues' titles + message previews (company-scoped RLS, no user filter) -> user-scoped - calendar/feed PUT: raw body into .update() allowed feed_token fixation on a public unauthenticated URL -> strict schema, content toggles only - bokslutsdispositioner: unbounded schablonintaktRate could inflate the IL 30 kap 25% periodiseringsfond cap base -> bounded - agent profile/composer/onboarding: viewers could rewrite the agent profile while sibling /verify blocked them -> role-gated Correctness - account-totals / listAssets: unbounded queries silently truncated at 1000 rows (under-counted money; skipped assets at year-end depreciation) -> fetchAllRows with stable order (+3 more pagination fixes) - voucher-gaps: swallowed detect_voucher_gaps RPC errors (BFNAR gap view could show "no gaps" when the check never ran) -> surfaced - 5 phantom-success writes (OK on zero matched rows) fixed - assets K3 component-sum validated against stale acquisition_cost -> fixed - invite silent email-send failure -> response carries email_sent; deadlines/calendar cast-then-check JSON crashes -> Zod Convention - ~44 legacy routes converted to withRouteContext (MFA); added Zod validation, corrected status codes, console.* -> lib/logger Response shapes preserved for existing callers. ~110 new tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(bookkeeping): save a booking as a reusable template from Bokför direkt Add a "Spara som mall" action to the manual booking dialog so users can capture a kontering they just worked out as a booking template — right where they figured out how something should be booked. - derive amount-parameterised template lines from the concrete booking (settlement = the non-VAT leg nearest the total, 26xx = a VAT line with its rate snapped to the nearest standard rate, the rest = business ratios; line labels come from the loaded BAS chart) - extract the shared TemplateForm out of BookingTemplatesPanel so the booking dialog reuses the same editor, live preview and convertibility hints instead of duplicating them - save via the existing POST /api/settings/booking-templates endpoint Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(bokslut): render arsredovisning RR/BR at ÅRL post level — no kontonummer Bolagsverket rejected a user's filed årsredovisning with "Balansräkning och resultaträkning ska inte innehålla kontonummer": the PDF built every statement row as per-account "1930 Företagskonto" lines while the iXBRL filing path already aggregated to statutory posts, so the two artifacts diverged. The PDF statements now derive from the same K2 risbs mapping the iXBRL document uses (mapTrialBalancesToK2), via a new statement-rows.ts that emits post-level rows in uppställningsform order for both the K2 and K3 templates. Also fixed along the way: - Jämförelseår column (ÅRL 3:5 §) — previous-year trial balances now load and render; the old PDF had no comparatives at all. - mapping.warnings (unmapped accounts, RR ≠ 2099, obalans, reclass nudges) flow into ArsredovisningData.warnings so the wizard flags a non-fileable document before download. - Flerårsöversikt current/previous year overridden with the mapper's strict-3000–3799 Nettoomsattning, mirroring build-input's duplicate-fact rule, so the FB table ties to the RR. - FB eget kapital-table is post-level and drops obeskattade reserver (never eget kapital); K3 equity-changes statement uses real prior-year opening balances with derived utdelning/nyemission residuals that tie the roll-forward exactly to booked UB. - build-input dedupes warnings now that the PDF path runs the same mapping. Regression test asserts no RR/BR label ever contains a four-digit account number again. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(reports): diagnose untransferred prior-year results behind balance-sheet differens Prod incident (97 kr): a multi-year SIE migration lacked one year's omforing av arets resultat; the residual corrupted every later derived opening balance and Balansrakningen showed a bare "Differens: 97 kr" with no explanation. Continuity checking cannot catch this failure mode (prior-year UB and derived IB match per-account by construction) - the invariant that actually breaks is per-year P&L = 0 for all non-latest years. - lib/reports/imbalance-diagnosis.ts: shared detector (findUntransferredResults + buildImbalanceDiagnosis) - Balansrakning/Balansrapport attach imbalance_diagnosis when unbalanced, naming the exact culprit years; rendered in web views + PDF; MCP gnubok_get_balance_sheet inherits the field via spread - SIE import: parse-time warning when a completed year's vouchers leave a P&L residual, plus a post-import DB walk surfacing culprits as warnings and structured details.untransferredResults; the Arcim migration workspace previously dropped result.warnings entirely and now renders them - opening-balance/correct: pre-flight the company lock date and return 409 OB_COMPANY_LOCK_DATE (retryable: false, lock date interpolated in the client message) instead of the retryable 500 that invited blind retries; catch-path maps a raced trigger rejection to the same code Diagnosis runs only on unbalanced paths (zero cost when healthy) and never fails the report or the import. No migration, nothing persisted. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix: production error remediation — FX rates, deadlines, log levels, correction relink Batch of fixes for recurring Vercel runtime errors: - Riksbanken FX rates: persistent read-through cache (exchange_rates table), one retry honoring Retry-After on 429/5xx, bounded ingest concurrency, and an honest fallback — most recent cached observation or null, never a hardcoded rate silently booked into amount_sek. Unrated transactions stay repairable via refresh-exchange-rate. - Tax deadline regeneration inserts replacement rows before deleting the superseded set, so a failed insert no longer wipes a company's deadlines (the 23502 user_id regression did exactly that). Migration makes deadlines.user_id nullable for system-generated rows. - Route wrappers + errorResponse log 4xx outcomes at warn so only genuine 5xx reach Vercel's runtime-error clustering; client-supplied /api/log telemetry demoted to warn as well. - application/json documents (raw PSD2 responses archived per BFL) validate as parseable JSON with object/array root instead of always failing the magic-byte check. - correctEntry surfaces document-relink failures to callers, and the BFL document-immutability trigger now allows relinking underlag from a reversed entry to its correction (migration + pg test). - Middleware clears stale session cookies on /api requests too, using scope 'local' so cleanup doesn't re-trigger the failed token refresh. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(skatteverket): persist token health and stop retrying dead consents Terminal auth errors (SESSION_EXPIRED, REFRESH_EXHAUSTED, MISSING_SCOPE, TOKEN_CORRUPTED) mark the token row needs_reconsent with the error code and timestamp — SKV per-flow refresh tokens live 65 minutes, so once expired nothing recovers without a fresh BankID consent. The AGI kvittens and skattekonto sync crons skip flagged connections instead of failing every night, and the settings panel prompts for re-consent proactively. A successful reconnect resets the row to active. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(banking): allocate distinct BAS ledger slots for PSD2 mirror accounts A bank returning N same-currency accounts used to map them all onto the currency default (1930/1932/1933/1934), tripping the UNIQUE (company_id, ledger_account) constraint per-account — swallowed errors left accounts silently unmirrored. allocatePsd2LedgerAccount now hands out the currency default first, then free 1931–1959 sub-account slots, skipping slots held by any existing row. - Callback persists allocations to accounts_data so the picker pre-fills reality; reconnect reuses previously mirrored ledgers instead of re-deriving (a user remap to 1935 survives). - Selection save resolves effective ledgers up front and rejects duplicates or cross-connection conflicts with a 400 instead of silently skipping the mirror. - Bank error codes + psu_type are forwarded to the settings page for every OAuth error, keying the Handelsbanken corporate fullmakt guidance. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(agent): stage exact journal lines on categorization previews Categorization previews only carried debit/credit accounts, the GROSS amount, and separate VAT rows — read together that looks like an unbalanced 'gross on cost account + VAT debit' entry, and it misled both users and agents into rejecting correct proposals. The MCP preview and the pending-operation PATCH now materialize the exact lines the commit executor will post (net cost line, VAT line, gross bank line, SEK) via buildTransactionEntryLines, and PATCH re-derives them from the new mapping instead of spreading stale staged lines. ApprovalCard and /pending render the verifikat lines, falling back to the legacy summary only for operations staged before this fix. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(bookkeeping): prune unused imported accounts from the chart SIE imports routinely bring in hundreds of accounts that were never used and clutter the kontoplan. New account_usage_counts RPC (one grouped query instead of a count per account) backs GET /api/bookkeeping/accounts/usage, and POST /api/bookkeeping/accounts/prune deletes zero-usage accounts — dry-run first, then an explicit account list capped at 2000. Accounts with journal lines are skipped, never deleted. The chart manager shows a usage column and a prune dialog grouping custom accounts vs unused BAS-seeded ones. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(api): carry dimensions through v1 invoice and supplier-invoice surfaces Credit-note creation now copies default_dimensions and per-line dimensions from the original, so the reversing journal entry nets against the same dimension cells instead of dropping them. List/detail responses expose the dimension fields, and the OpenAPI spec snapshot follows. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * perf: batch serial Supabase round-trips on hot dashboard paths Every dashboard render pays the layout's query chain, so serialized awaits are direct wall-clock: the layout, chat conversation, invoice detail, supplier detail, select-company, and agent-onboarding pages now run their independent lookups in parallel batches, and getCompanyCapabilities folds its disabled-config read into the same round-trip. JournalEntryList hydrates the saved fiscal-year scope optimistically instead of serializing the first entries fetch behind the fiscal-periods request. The supplier detail page filters invoices server-side via a new supplier_id query param instead of fetching the whole company ledger, and the invoice editor (with its framer-motion dependency) lazy-loads so it stops shipping with the invoice list bundle. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(salary): one-click runs, payslip delivery, payments settings, run cockpit Salary P1 batch, driving the 20-click flow toward 3 clicks: - One-click 'Starta lönekörning': POST /api/salary/runs accepts an empty body and resolves defaults server-side — period follows the latest non-corrected run, payment date from the new salary_pay_day setting, series from the per-source-type map. The separate /salary/runs/new page is gone. - Run detail page rebuilt as a step-railed cockpit (progress rail, KPI cards, employee ledger, journal preview) on a deliberately wider canvas; components extracted to components/salary/run/. - Payslip delivery: tokenized public payslip pages (/payslip/[token], backed by salary_payslip_links) plus per-employee email send with PDF — employees need no account, and the middleware exempts the route from auth redirects. - Payments settings: salary pay day, default bank, and pain.001 vs Bankgirot Lön format with per-bank upload instructions and an LB sunset warning (banks retire LB during 2026). - AGI panel: full submission status flows (stale drafts, signing links, kvittens polling, error reports); tax payment panel with skattekonto shortcut and mark-as-paid. - Salary calendar bulk editing, employee benefits/tax-card polish, municipality tax-table lookup improvements. messages/sv+en also carry the strings for the account-prune, skatteverket-reconsent, and banking surfaces committed just before this. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore: adopt Next 16 proxy.ts convention + repo housekeeping - Rename middleware.ts to proxy.ts with the proxy() export (Next 16 renamed the middleware convention; behavior unchanged). - Exclude dev_docs/ from tsconfig so stray snippets in planning docs don't break the build type-check. - Ratchet antipatterns-baseline down (raw-route-auth 165 → 119) to lock in the withRouteContext migration from 5cfd2b76. - template-library uses roundOre() instead of inline rounding. - database.md: drop account_balances from the key-tables list. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(bookkeeping): robust service-role detection in correction document relink relink_documents_to_correction() keyed its service-role branch on auth.role(), which reads the singular request.jwt.claim.role GUC that PostgREST v10+ and the pg-real harness no longer populate. Genuine service-role callers (pending-ops executor / MCP approve) landed in the auth gate and could not relink underlag. Read the role from the request.jwt.claims JSON directly, mirroring the canonical link_voucher_rpcs_tenant_guard convention. Validated on staging. Also: harden the salary run page's error paths (res.json().catch) against non-JSON error bodies, and roll back the pg-real service-role case in finally so an aborted transaction cannot poison a pooled connection for the next test. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(documents): restore journal_entry_line_id link durability (BFL 7 kap) Migration 20260704103000 rewrote enforce_document_journal_entry_immutability to guard journal_entry_id but left journal_entry_line_id to the metadata trigger, which exempts draft-linked docs -- and the entry-level trigger only fired on UPDATE OF journal_entry_id, so a line-id-only UPDATE never invoked it at all. That let a set journal_entry_line_id be cleared to NULL, breaking the "link durable from first set" invariant (document-immutability.pg regression). Widen the trigger to fire on journal_entry_line_id too and guard it with the same uuid-durability rule as journal_entry_id (setting NULL -> uuid stays allowed; clearing/re-pointing a set value is blocked, status-independent). The correction-relink GUC path, which legitimately clears line_id when moving underlag to the posted correction, stays exempt. Validated on staging. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Signed-off-by: Emil <emilmattsson14@gmail.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
ec27228a8e
commit
2c2743eb79
@@ -1,7 +1,8 @@
|
||||
import { createClient, createServiceClient } from '@/lib/supabase/server'
|
||||
import { createServiceClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { requireAuth } from '@/lib/auth/require-auth'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { eventBus } from '@/lib/events'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
@@ -26,14 +27,17 @@ const DeleteAccountSchema = z.object({
|
||||
* Precondition: the user must own zero non-archived companies. The RPC
|
||||
* enforces this at the DB level and raises SQLSTATE P0001 with a message
|
||||
* if the precondition fails: we return 409 in that case.
|
||||
*
|
||||
* Not wrapped in withRouteContext: deletion must work for users with zero
|
||||
* companies, so there is no company context to resolve. requireAuth() is
|
||||
* used directly so MFA (AAL2) is still enforced on hosted: a stolen AAL1
|
||||
* cookie must not be able to destroy the account. BankID-linked users are
|
||||
* exempt from the AAL2 gate (BankID is inherently 2FA, see shouldEnforceMfa).
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
const supabase = await createClient()
|
||||
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
const auth = await requireAuth()
|
||||
if (auth.error) return auth.error
|
||||
const { user, supabase } = auth
|
||||
|
||||
const result = await validateBody(request, DeleteAccountSchema)
|
||||
if (!result.success) return result.response
|
||||
@@ -130,8 +134,5 @@ export async function POST(request: Request) {
|
||||
// Best-effort: clear the caller's session cookie too.
|
||||
await supabase.auth.signOut().catch(() => {})
|
||||
|
||||
// Request body is consumed; avoid unused-var lint.
|
||||
void request
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* Tests for POST /api/agent/composer.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { createQueuedMockSupabase, createMockRequest, parseJsonResponse } from '@/tests/helpers'
|
||||
|
||||
const { supabase, enqueue, reset } = createQueuedMockSupabase()
|
||||
|
||||
const requireAuthMock = vi.fn()
|
||||
vi.mock('@/lib/auth/require-auth', () => ({
|
||||
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/company/context', () => ({
|
||||
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
}))
|
||||
|
||||
const checkRateMock = vi.fn()
|
||||
vi.mock('@/lib/rate-limits/agent', () => ({
|
||||
checkAgentRateLimit: (...args: unknown[]) => checkRateMock(...args),
|
||||
agentRateLimitResponseBody: () => ({ error: 'För många förfrågningar.' }),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/sandbox/guard', () => ({
|
||||
guardSandbox: vi.fn().mockResolvedValue(null),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/entitlements/has-capability', () => ({
|
||||
requireCapability: vi.fn().mockResolvedValue(null),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/entitlements/keys', () => ({
|
||||
CAPABILITY: { ai: 'ai' },
|
||||
}))
|
||||
|
||||
const composeMock = vi.fn()
|
||||
vi.mock('@/lib/agent/composer', () => ({
|
||||
composeAgentProfile: (...args: unknown[]) => composeMock(...args),
|
||||
}))
|
||||
|
||||
import { POST } from '../composer/route'
|
||||
|
||||
const routeParams = { params: Promise.resolve({}) }
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase, error: null })
|
||||
checkRateMock.mockResolvedValue({ ok: true })
|
||||
})
|
||||
|
||||
describe('POST /api/agent/composer', () => {
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
requireAuthMock.mockResolvedValue({
|
||||
user: null,
|
||||
supabase,
|
||||
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
|
||||
})
|
||||
const req = createMockRequest('/api/agent/composer', { method: 'POST', body: {} })
|
||||
const res = await POST(req, routeParams)
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
it('returns 429 when rate limited', async () => {
|
||||
checkRateMock.mockResolvedValue({ ok: false, retryAfterSec: 30 })
|
||||
|
||||
const req = createMockRequest('/api/agent/composer', { method: 'POST', body: {} })
|
||||
const res = await POST(req, routeParams)
|
||||
expect(res.status).toBe(429)
|
||||
expect(res.headers.get('Retry-After')).toBe('30')
|
||||
})
|
||||
|
||||
it('refuses a viewer with 403 (composer rewrites the profile)', async () => {
|
||||
enqueue({ data: { role: 'viewer' } })
|
||||
|
||||
const req = createMockRequest('/api/agent/composer', { method: 'POST', body: {} })
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(
|
||||
await POST(req, routeParams)
|
||||
)
|
||||
expect(status).toBe(403)
|
||||
expect(body.error.code).toBe('WRITE_PERMISSION_REQUIRED')
|
||||
expect(composeMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('runs the composer for a non-viewer member', async () => {
|
||||
enqueue({ data: { role: 'owner' } })
|
||||
composeMock.mockResolvedValue({ company_id: 'company-1', profile_summary: 'Byggd' })
|
||||
|
||||
const req = createMockRequest('/api/agent/composer', {
|
||||
method: 'POST',
|
||||
body: { dry_run: true },
|
||||
})
|
||||
const { status, body } = await parseJsonResponse<{ data: { profile_summary: string } }>(
|
||||
await POST(req, routeParams)
|
||||
)
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.profile_summary).toBe('Byggd')
|
||||
expect(composeMock).toHaveBeenCalledWith(expect.anything(), 'company-1', { dryRun: true })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* Tests for GET /api/agent/conversations and GET/PATCH /api/agent/conversations/[id].
|
||||
*
|
||||
* Uses a filter-capturing Supabase mock so the user-scoping regression is
|
||||
* locked in: RLS on agent_conversations is company-scoped, so the explicit
|
||||
* .eq('user_id', …) filter in the list route is the only thing preventing
|
||||
* team members from seeing each other's conversation titles/previews.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { createMockRequest, parseJsonResponse } from '@/tests/helpers'
|
||||
|
||||
const requireAuthMock = vi.fn()
|
||||
vi.mock('@/lib/auth/require-auth', () => ({
|
||||
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/company/context', () => ({
|
||||
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
}))
|
||||
|
||||
import { GET as listGET } from '../conversations/route'
|
||||
import { GET as detailGET, PATCH as detailPATCH } from '../conversations/[id]/route'
|
||||
|
||||
interface CapturedCall {
|
||||
method: string
|
||||
args: unknown[]
|
||||
}
|
||||
|
||||
/** Chainable builder that records every call and resolves queued results per from(). */
|
||||
function createCapturingSupabase(results: { data?: unknown; error?: unknown }[]) {
|
||||
const calls: CapturedCall[] = []
|
||||
let idx = 0
|
||||
const makeBuilder = () => {
|
||||
const result = results[idx++] ?? { data: null, error: null }
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const b: any = {}
|
||||
for (const m of ['select', 'eq', 'is', 'or', 'order', 'limit', 'insert', 'update', 'maybeSingle', 'single']) {
|
||||
b[m] = (...args: unknown[]) => {
|
||||
calls.push({ method: m, args })
|
||||
return b
|
||||
}
|
||||
}
|
||||
b.then = (resolve: (v: unknown) => void) =>
|
||||
resolve({ data: result.data ?? null, error: result.error ?? null, count: null })
|
||||
return b
|
||||
}
|
||||
const supabase = {
|
||||
from: (table: string) => {
|
||||
calls.push({ method: 'from', args: [table] })
|
||||
return makeBuilder()
|
||||
},
|
||||
}
|
||||
return { supabase, calls }
|
||||
}
|
||||
|
||||
const routeParams = { params: Promise.resolve({}) }
|
||||
const idParams = { params: Promise.resolve({ id: 'conv-1' }) }
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('GET /api/agent/conversations', () => {
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
requireAuthMock.mockResolvedValue({
|
||||
user: null,
|
||||
supabase: {},
|
||||
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
|
||||
})
|
||||
|
||||
const res = await listGET(createMockRequest('/api/agent/conversations'), routeParams)
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
it('returns 400 for a non-numeric limit', async () => {
|
||||
const { supabase } = createCapturingSupabase([])
|
||||
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase, error: null })
|
||||
|
||||
const req = createMockRequest('/api/agent/conversations', { searchParams: { limit: 'abc' } })
|
||||
const { status } = await parseJsonResponse(await listGET(req, routeParams))
|
||||
expect(status).toBe(400)
|
||||
})
|
||||
|
||||
it('filters the list by BOTH company_id and the calling user_id', async () => {
|
||||
const { supabase, calls } = createCapturingSupabase([
|
||||
{ data: [{ id: 'conv-1', title: 'Min konversation' }] },
|
||||
])
|
||||
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase, error: null })
|
||||
|
||||
const req = createMockRequest('/api/agent/conversations')
|
||||
const { status, body } = await parseJsonResponse<{ data: unknown[] }>(
|
||||
await listGET(req, routeParams)
|
||||
)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data).toHaveLength(1)
|
||||
const eqCalls = calls.filter((c) => c.method === 'eq').map((c) => c.args)
|
||||
expect(eqCalls).toContainEqual(['company_id', 'company-1'])
|
||||
// Privacy regression guard: without this filter, company-scoped RLS lets
|
||||
// every member read colleagues' titles and last_message_preview.
|
||||
expect(eqCalls).toContainEqual(['user_id', 'user-1'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('GET /api/agent/conversations/[id]', () => {
|
||||
it('returns 404 when the conversation is not owned by the caller', async () => {
|
||||
// Ownership is part of the fetch (.eq user_id) — a non-owned id resolves null.
|
||||
const { supabase, calls } = createCapturingSupabase([{ data: null }])
|
||||
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase, error: null })
|
||||
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(
|
||||
await detailGET(createMockRequest('/api/agent/conversations/conv-1'), idParams)
|
||||
)
|
||||
|
||||
expect(status).toBe(404)
|
||||
expect(body.error.code).toBe('CONVERSATION_NOT_FOUND')
|
||||
const eqCalls = calls.filter((c) => c.method === 'eq').map((c) => c.args)
|
||||
expect(eqCalls).toContainEqual(['user_id', 'user-1'])
|
||||
})
|
||||
|
||||
it('returns the conversation with its messages for the owner', async () => {
|
||||
const { supabase } = createCapturingSupabase([
|
||||
{ data: { id: 'conv-1', company_id: 'company-1', user_id: 'user-1', title: 'T' } },
|
||||
{ data: { role: 'member' } },
|
||||
{ data: [{ id: 'm1', role: 'user', content: 'Hej' }] },
|
||||
])
|
||||
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase, error: null })
|
||||
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
data: { conversation: { id: string }; messages: unknown[] }
|
||||
}>(await detailGET(createMockRequest('/api/agent/conversations/conv-1'), idParams))
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.conversation.id).toBe('conv-1')
|
||||
expect(body.data.messages).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('PATCH /api/agent/conversations/[id]', () => {
|
||||
it('returns 400 when the body has nothing to update', async () => {
|
||||
const { supabase } = createCapturingSupabase([])
|
||||
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase, error: null })
|
||||
|
||||
const req = createMockRequest('/api/agent/conversations/conv-1', {
|
||||
method: 'PATCH',
|
||||
body: {},
|
||||
})
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(
|
||||
await detailPATCH(req, idParams)
|
||||
)
|
||||
|
||||
expect(status).toBe(400)
|
||||
expect(body.error.code).toBe('NOTHING_TO_UPDATE')
|
||||
})
|
||||
|
||||
it('updates pin state for an owned conversation', async () => {
|
||||
const { supabase } = createCapturingSupabase([
|
||||
{ data: { user_id: 'user-1', company_id: 'company-1' } },
|
||||
{ data: { id: 'conv-1', pinned: true } },
|
||||
])
|
||||
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase, error: null })
|
||||
|
||||
const req = createMockRequest('/api/agent/conversations/conv-1', {
|
||||
method: 'PATCH',
|
||||
body: { pinned: true },
|
||||
})
|
||||
const { status, body } = await parseJsonResponse<{ data: { pinned: boolean } }>(
|
||||
await detailPATCH(req, idParams)
|
||||
)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.pinned).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* Tests for GET/POST /api/agent/memory and PATCH /api/agent/memory/[id].
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { createQueuedMockSupabase, createMockRequest, parseJsonResponse } from '@/tests/helpers'
|
||||
|
||||
const { supabase, enqueue, reset } = createQueuedMockSupabase()
|
||||
|
||||
const requireAuthMock = vi.fn()
|
||||
vi.mock('@/lib/auth/require-auth', () => ({
|
||||
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/company/context', () => ({
|
||||
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
}))
|
||||
|
||||
const requireWriteMock = vi.fn()
|
||||
vi.mock('@/lib/auth/require-write', () => ({
|
||||
requireWritePermission: (...args: unknown[]) => requireWriteMock(...args),
|
||||
}))
|
||||
|
||||
import { GET, POST } from '../memory/route'
|
||||
import { PATCH } from '../memory/[id]/route'
|
||||
|
||||
const routeParams = { params: Promise.resolve({}) }
|
||||
const idParams = { params: Promise.resolve({ id: 'mem-1' }) }
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase, error: null })
|
||||
requireWriteMock.mockResolvedValue({ ok: true })
|
||||
})
|
||||
|
||||
describe('GET /api/agent/memory', () => {
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
requireAuthMock.mockResolvedValue({
|
||||
user: null,
|
||||
supabase,
|
||||
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
|
||||
})
|
||||
|
||||
const res = await GET(createMockRequest('/api/agent/memory'), routeParams)
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
it('returns 400 for an unknown kind filter', async () => {
|
||||
const req = createMockRequest('/api/agent/memory', { searchParams: { kind: 'gossip' } })
|
||||
const { status } = await parseJsonResponse(await GET(req, routeParams))
|
||||
expect(status).toBe(400)
|
||||
})
|
||||
|
||||
it('lists memory entries', async () => {
|
||||
enqueue({ data: [{ id: 'mem-1', kind: 'fact', content: 'Fakturerar i SEK' }] })
|
||||
|
||||
const { status, body } = await parseJsonResponse<{ data: unknown[] }>(
|
||||
await GET(createMockRequest('/api/agent/memory'), routeParams)
|
||||
)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /api/agent/memory', () => {
|
||||
it('rejects a viewer in the target company with 403', async () => {
|
||||
// requireWrite passed for the ACTIVE company, but the body targets a
|
||||
// company where the caller is only a viewer — the re-check must refuse.
|
||||
enqueue({ data: { role: 'viewer' } })
|
||||
|
||||
const req = createMockRequest('/api/agent/memory', {
|
||||
method: 'POST',
|
||||
body: { company_id: '7f3e0b1a-9c4d-4a2b-8f6e-1d2c3b4a5e6f', content: 'Ett minne' },
|
||||
})
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(
|
||||
await POST(req, routeParams)
|
||||
)
|
||||
|
||||
expect(status).toBe(403)
|
||||
expect(body.error.code).toBe('WRITE_PERMISSION_REQUIRED')
|
||||
})
|
||||
|
||||
it('rejects an invalid body with 400', async () => {
|
||||
const req = createMockRequest('/api/agent/memory', {
|
||||
method: 'POST',
|
||||
body: { content: 'x' }, // below min length 2
|
||||
})
|
||||
const { status } = await parseJsonResponse(await POST(req, routeParams))
|
||||
expect(status).toBe(400)
|
||||
})
|
||||
|
||||
it('inserts a memory entry for the active company', async () => {
|
||||
enqueue({ data: { role: 'admin' } }) // membership re-check
|
||||
enqueue({ data: { id: 'mem-2', kind: 'fact', content: 'Ett minne' } })
|
||||
|
||||
const req = createMockRequest('/api/agent/memory', {
|
||||
method: 'POST',
|
||||
body: { content: 'Ett minne' },
|
||||
})
|
||||
const { status, body } = await parseJsonResponse<{ data: { id: string } }>(
|
||||
await POST(req, routeParams)
|
||||
)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.id).toBe('mem-2')
|
||||
})
|
||||
})
|
||||
|
||||
describe('PATCH /api/agent/memory/[id]', () => {
|
||||
it('returns 400 when the body has nothing to update', async () => {
|
||||
const req = createMockRequest('/api/agent/memory/mem-1', { method: 'PATCH', body: {} })
|
||||
const { status } = await parseJsonResponse(await PATCH(req, idParams))
|
||||
expect(status).toBe(400)
|
||||
})
|
||||
|
||||
it('returns 404 when the memory row does not exist', async () => {
|
||||
enqueue({ data: null })
|
||||
|
||||
const req = createMockRequest('/api/agent/memory/mem-1', {
|
||||
method: 'PATCH',
|
||||
body: { is_pinned: true },
|
||||
})
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(
|
||||
await PATCH(req, idParams)
|
||||
)
|
||||
|
||||
expect(status).toBe(404)
|
||||
expect(body.error.code).toBe('MEMORY_NOT_FOUND')
|
||||
})
|
||||
|
||||
it('updates a memory entry', async () => {
|
||||
enqueue({ data: { company_id: 'company-1' } }) // row lookup
|
||||
enqueue({ data: { role: 'member' } }) // membership re-check
|
||||
enqueue({ data: { id: 'mem-1', is_pinned: true } }) // update
|
||||
|
||||
const req = createMockRequest('/api/agent/memory/mem-1', {
|
||||
method: 'PATCH',
|
||||
body: { is_pinned: true },
|
||||
})
|
||||
const { status, body } = await parseJsonResponse<{ data: { is_pinned: boolean } }>(
|
||||
await PATCH(req, idParams)
|
||||
)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.is_pinned).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Tests for POST /api/agent/onboarding/stream — auth surface only.
|
||||
*
|
||||
* The composer pipeline itself is exercised via lib tests; here we lock in
|
||||
* the guard order: 401, membership 403, and the viewer refusal (the pipeline
|
||||
* upserts agent_profiles, so viewers must not be able to trigger it).
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { createMockRequest, parseJsonResponse } from '@/tests/helpers'
|
||||
|
||||
vi.mock('@/lib/company/context', () => ({
|
||||
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/rate-limits/agent', () => ({
|
||||
checkAgentRateLimit: vi.fn().mockResolvedValue({ ok: true }),
|
||||
agentRateLimitResponseBody: () => ({ error: 'För många förfrågningar.' }),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/sandbox/guard', () => ({ guardSandbox: vi.fn().mockResolvedValue(null) }))
|
||||
vi.mock('@/lib/entitlements/has-capability', () => ({
|
||||
requireCapability: vi.fn().mockResolvedValue(null),
|
||||
}))
|
||||
vi.mock('@/lib/entitlements/keys', () => ({ CAPABILITY: { ai: 'ai' } }))
|
||||
|
||||
// Pipeline internals — never reached in these tests, stubbed so the module loads.
|
||||
vi.mock('@/lib/agent/composer/inputs', () => ({
|
||||
gatherComposerInputs: vi.fn(),
|
||||
inputsToSourceSignals: vi.fn(),
|
||||
}))
|
||||
vi.mock('@/lib/agent/composer/atom-selection', () => ({
|
||||
selectAtoms: vi.fn(),
|
||||
filterRedundantQuestions: vi.fn(),
|
||||
}))
|
||||
vi.mock('@/lib/agent/composer/narrative', () => ({ writeNarrative: vi.fn() }))
|
||||
vi.mock('@/lib/agent/composer/fallback', () => ({
|
||||
fallbackAtomSelection: vi.fn(),
|
||||
fallbackNarrative: vi.fn(),
|
||||
}))
|
||||
vi.mock('@/lib/agent/composer/prewarm', () => ({ preWarmAtomCache: vi.fn() }))
|
||||
vi.mock('@/lib/agent/composer/client', () => ({ OPUS_MODEL: 'opus-test' }))
|
||||
vi.mock('@/lib/agent/composer/tic-fetch', () => ({ ensureTicSnapshot: vi.fn() }))
|
||||
|
||||
vi.mock('@/lib/supabase/server', () => ({ createClient: vi.fn() }))
|
||||
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { POST } from '../onboarding/stream/route'
|
||||
|
||||
const mockCreateClient = vi.mocked(createClient)
|
||||
|
||||
function mockAuth(userId: string | null, membership: { role: string } | null) {
|
||||
mockCreateClient.mockResolvedValue({
|
||||
auth: {
|
||||
getUser: vi.fn().mockResolvedValue({ data: { user: userId ? { id: userId } : null } }),
|
||||
},
|
||||
from: vi.fn().mockReturnValue({
|
||||
select: vi.fn().mockReturnThis(),
|
||||
eq: vi.fn().mockReturnThis(),
|
||||
maybeSingle: vi.fn().mockResolvedValue({ data: membership, error: null }),
|
||||
}),
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('POST /api/agent/onboarding/stream', () => {
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
mockAuth(null, null)
|
||||
const req = createMockRequest('/api/agent/onboarding/stream', { method: 'POST', body: {} })
|
||||
const res = await POST(req)
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
it('returns 403 for a non-member', async () => {
|
||||
mockAuth('user-1', null)
|
||||
const req = createMockRequest('/api/agent/onboarding/stream', { method: 'POST', body: {} })
|
||||
const { status } = await parseJsonResponse(await POST(req))
|
||||
expect(status).toBe(403)
|
||||
})
|
||||
|
||||
it('refuses a viewer with 403 (pipeline upserts agent_profiles)', async () => {
|
||||
mockAuth('user-1', { role: 'viewer' })
|
||||
const req = createMockRequest('/api/agent/onboarding/stream', { method: 'POST', body: {} })
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(await POST(req))
|
||||
expect(status).toBe(403)
|
||||
expect(body.error).toContain('läsbehörighet')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* Tests for GET/PATCH /api/agent/profile and POST /api/agent/profile/verify.
|
||||
*
|
||||
* Covers the role model: reads allow any member, mutations (PATCH, verify)
|
||||
* refuse viewers — the same rule verify always had, now enforced on PATCH too.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { createQueuedMockSupabase, createMockRequest, parseJsonResponse } from '@/tests/helpers'
|
||||
|
||||
const { supabase, enqueue, reset } = createQueuedMockSupabase()
|
||||
|
||||
const requireAuthMock = vi.fn()
|
||||
vi.mock('@/lib/auth/require-auth', () => ({
|
||||
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/company/context', () => ({
|
||||
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
}))
|
||||
|
||||
import { GET, PATCH } from '../profile/route'
|
||||
import { POST as VERIFY } from '../profile/verify/route'
|
||||
|
||||
const routeParams = { params: Promise.resolve({}) }
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase, error: null })
|
||||
})
|
||||
|
||||
describe('GET /api/agent/profile', () => {
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
requireAuthMock.mockResolvedValue({
|
||||
user: null,
|
||||
supabase,
|
||||
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
|
||||
})
|
||||
const res = await GET(createMockRequest('/api/agent/profile'), routeParams)
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
it('returns 403 when the caller is not a member of the target company', async () => {
|
||||
enqueue({ data: null }) // membership lookup
|
||||
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(
|
||||
await GET(createMockRequest('/api/agent/profile'), routeParams)
|
||||
)
|
||||
expect(status).toBe(403)
|
||||
expect(body.error.code).toBe('NOT_COMPANY_MEMBER')
|
||||
})
|
||||
|
||||
it('returns the profile for a member (viewers may read)', async () => {
|
||||
enqueue({ data: { role: 'viewer' } })
|
||||
enqueue({ data: { company_id: 'company-1', profile_summary: 'Konsultbolag' } })
|
||||
|
||||
const { status, body } = await parseJsonResponse<{ data: { profile_summary: string } }>(
|
||||
await GET(createMockRequest('/api/agent/profile'), routeParams)
|
||||
)
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.profile_summary).toBe('Konsultbolag')
|
||||
})
|
||||
})
|
||||
|
||||
describe('PATCH /api/agent/profile', () => {
|
||||
it('refuses a viewer with 403 (profile mutation)', async () => {
|
||||
enqueue({ data: { role: 'viewer' } })
|
||||
|
||||
const req = createMockRequest('/api/agent/profile', {
|
||||
method: 'PATCH',
|
||||
body: { profile_summary: 'Nytt sammandrag' },
|
||||
})
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(
|
||||
await PATCH(req, routeParams)
|
||||
)
|
||||
expect(status).toBe(403)
|
||||
expect(body.error.code).toBe('WRITE_PERMISSION_REQUIRED')
|
||||
})
|
||||
|
||||
it('returns 404 when the company has no agent_profile row', async () => {
|
||||
enqueue({ data: { role: 'admin' } })
|
||||
enqueue({ data: null }) // current profile lookup
|
||||
|
||||
const req = createMockRequest('/api/agent/profile', {
|
||||
method: 'PATCH',
|
||||
body: { profile_summary: 'Nytt sammandrag' },
|
||||
})
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(
|
||||
await PATCH(req, routeParams)
|
||||
)
|
||||
expect(status).toBe(404)
|
||||
expect(body.error.code).toBe('AGENT_PROFILE_NOT_FOUND')
|
||||
})
|
||||
|
||||
it('returns 400 when the body contains nothing to update', async () => {
|
||||
enqueue({ data: { role: 'admin' } })
|
||||
enqueue({ data: { field_overrides: null } })
|
||||
|
||||
const req = createMockRequest('/api/agent/profile', { method: 'PATCH', body: {} })
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(
|
||||
await PATCH(req, routeParams)
|
||||
)
|
||||
expect(status).toBe(400)
|
||||
expect(body.error.code).toBe('NOTHING_TO_UPDATE')
|
||||
})
|
||||
|
||||
it('merges field_overrides and updates the profile', async () => {
|
||||
enqueue({ data: { role: 'admin' } })
|
||||
enqueue({ data: { field_overrides: { old: { value: 1, overridden_at: 'x' } } } })
|
||||
enqueue({ data: { company_id: 'company-1', profile_summary: 'Uppdaterad' } })
|
||||
|
||||
const req = createMockRequest('/api/agent/profile', {
|
||||
method: 'PATCH',
|
||||
body: { profile_summary: 'Uppdaterad', field_overrides: { vat_period: 'quarterly' } },
|
||||
})
|
||||
const { status, body } = await parseJsonResponse<{ data: { profile_summary: string } }>(
|
||||
await PATCH(req, routeParams)
|
||||
)
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.profile_summary).toBe('Uppdaterad')
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /api/agent/profile/verify', () => {
|
||||
it('refuses a viewer with 403', async () => {
|
||||
enqueue({ data: { role: 'viewer' } })
|
||||
|
||||
const req = createMockRequest('/api/agent/profile/verify', { method: 'POST', body: {} })
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(
|
||||
await VERIFY(req, routeParams)
|
||||
)
|
||||
expect(status).toBe(403)
|
||||
expect(body.error.code).toBe('WRITE_PERMISSION_REQUIRED')
|
||||
})
|
||||
|
||||
it('stamps verified_at for a non-viewer member', async () => {
|
||||
enqueue({ data: { role: 'owner' } })
|
||||
enqueue({ data: { company_id: 'company-1', verified_at: '2026-07-03T00:00:00Z', verified_by_user_id: 'user-1' } })
|
||||
|
||||
const req = createMockRequest('/api/agent/profile/verify', { method: 'POST', body: {} })
|
||||
const { status, body } = await parseJsonResponse<{ data: { verified_by_user_id: string } }>(
|
||||
await VERIFY(req, routeParams)
|
||||
)
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.verified_by_user_id).toBe('user-1')
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,6 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { getActiveCompanyId } from '@/lib/company/context'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { checkAgentRateLimit, agentRateLimitResponseBody } from '@/lib/rate-limits/agent'
|
||||
import { composeAgentProfile } from '@/lib/agent/composer'
|
||||
import { guardSandbox } from '@/lib/sandbox/guard'
|
||||
@@ -24,13 +23,12 @@ const BodySchema = z.object({
|
||||
// 4. Persists to agent_profiles (skipped on dry_run).
|
||||
// 5. Fires fire-and-forget cache pre-warm.
|
||||
//
|
||||
// Auth: must be a member of the target company.
|
||||
// Auth: must be a non-viewer member of the target company (it rewrites the
|
||||
// company's agent_profile unless dry_run).
|
||||
//
|
||||
// Plan ref: dev_docs/specialized-agent-plan.md §6.
|
||||
export async function POST(request: Request) {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
export const POST = withRouteContext('agent.composer.run', async (request, ctx) => {
|
||||
const { supabase, companyId: activeCompanyId, user } = ctx
|
||||
|
||||
const rate = await checkAgentRateLimit(supabase, user.id)
|
||||
if (!rate.ok) {
|
||||
@@ -40,22 +38,29 @@ export async function POST(request: Request) {
|
||||
})
|
||||
}
|
||||
|
||||
let body: z.infer<typeof BodySchema>
|
||||
try {
|
||||
body = BodySchema.parse(await request.json().catch(() => ({})))
|
||||
} catch (err) {
|
||||
// Tolerant parse: ops callers POST with an empty body, which is valid here.
|
||||
const raw = await request.json().catch(() => ({}))
|
||||
const parsed = BodySchema.safeParse(raw)
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: err instanceof Error ? err.message : 'Invalid body' },
|
||||
{
|
||||
error: 'Validation failed',
|
||||
type: 'validation_error',
|
||||
errors: parsed.error.issues.map((issue) => ({
|
||||
field: issue.path.join('.'),
|
||||
message: issue.message,
|
||||
code: issue.code,
|
||||
})),
|
||||
},
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
const body = parsed.data
|
||||
|
||||
const companyId = body.company_id ?? (await getActiveCompanyId(supabase, user.id))
|
||||
if (!companyId) {
|
||||
return NextResponse.json({ error: 'No active company' }, { status: 400 })
|
||||
}
|
||||
const companyId = body.company_id ?? activeCompanyId
|
||||
|
||||
// Defense in depth alongside RLS: confirm membership before composing.
|
||||
// Defense in depth alongside RLS: confirm membership before composing, and
|
||||
// require a non-viewer role: the composer rewrites agent_profiles.
|
||||
const { data: membership } = await supabase
|
||||
.from('company_members')
|
||||
.select('role')
|
||||
@@ -63,7 +68,28 @@ export async function POST(request: Request) {
|
||||
.eq('user_id', user.id)
|
||||
.maybeSingle()
|
||||
if (!membership) {
|
||||
return NextResponse.json({ error: 'Not a member of this company' }, { status: 403 })
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: {
|
||||
code: 'NOT_COMPANY_MEMBER',
|
||||
message: 'Du är inte medlem i detta företag.',
|
||||
message_en: 'Not a member of this company.',
|
||||
},
|
||||
},
|
||||
{ status: 403 },
|
||||
)
|
||||
}
|
||||
if (membership.role === 'viewer') {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: {
|
||||
code: 'WRITE_PERMISSION_REQUIRED',
|
||||
message: 'Du har endast läsbehörighet i detta företag.',
|
||||
message_en: 'You only have read access in this company.',
|
||||
},
|
||||
},
|
||||
{ status: 403 },
|
||||
)
|
||||
}
|
||||
|
||||
const blocked = await guardSandbox(supabase, companyId)
|
||||
@@ -72,11 +98,6 @@ export async function POST(request: Request) {
|
||||
const capBlocked = await requireCapability(supabase, companyId, CAPABILITY.ai)
|
||||
if (capBlocked) return capBlocked
|
||||
|
||||
try {
|
||||
const composed = await composeAgentProfile(supabase, companyId, { dryRun: body.dry_run })
|
||||
return NextResponse.json({ data: composed })
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Composer failed'
|
||||
return NextResponse.json({ error: message }, { status: 500 })
|
||||
}
|
||||
}
|
||||
const composed = await composeAgentProfile(supabase, companyId, { dryRun: body.dry_run })
|
||||
return NextResponse.json({ data: composed })
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
|
||||
// GET /api/agent/conversations/[id]
|
||||
//
|
||||
@@ -18,98 +19,108 @@ const PatchSchema = z.object({
|
||||
title: z.string().min(1).max(200).nullable().optional(),
|
||||
})
|
||||
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
const notFound = () =>
|
||||
NextResponse.json(
|
||||
{
|
||||
error: {
|
||||
code: 'CONVERSATION_NOT_FOUND',
|
||||
message: 'Konversationen hittades inte.',
|
||||
message_en: 'Conversation not found.',
|
||||
},
|
||||
},
|
||||
{ status: 404 },
|
||||
)
|
||||
|
||||
const { id } = await params
|
||||
export const GET = withRouteContext(
|
||||
'agent.conversations.get',
|
||||
async (_request, ctx, { params }: { params: Promise<{ id: string }> }) => {
|
||||
const { id } = await params
|
||||
const { supabase, user } = ctx
|
||||
|
||||
const { data: conv, error: convErr } = await supabase
|
||||
.from('agent_conversations')
|
||||
.select(
|
||||
'id, company_id, user_id, intent_id, context_ref, title, pinned, archived, last_message_at, created_at',
|
||||
)
|
||||
.eq('id', id)
|
||||
.maybeSingle()
|
||||
if (convErr) return NextResponse.json({ error: convErr.message }, { status: 500 })
|
||||
if (!conv) return NextResponse.json({ error: 'Conversation not found' }, { status: 404 })
|
||||
// Conversations are user-scoped: fetched by ownership rather than the
|
||||
// active company, so a user can open their own conversations in any
|
||||
// company they belong to.
|
||||
const { data: conv, error: convErr } = await supabase
|
||||
.from('agent_conversations')
|
||||
.select(
|
||||
'id, company_id, user_id, intent_id, context_ref, title, pinned, archived, last_message_at, created_at',
|
||||
)
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.maybeSingle()
|
||||
if (convErr) throw convErr
|
||||
if (!conv) return notFound()
|
||||
|
||||
// Defense in depth alongside RLS: verify caller is a member of the
|
||||
// conversation's company AND owns the conversation row. Conversations are
|
||||
// user-scoped within a company; one team member should not see another's.
|
||||
if (conv.user_id !== user.id) {
|
||||
return NextResponse.json({ error: 'Conversation not found' }, { status: 404 })
|
||||
}
|
||||
const { data: membership } = await supabase
|
||||
.from('company_members')
|
||||
.select('role')
|
||||
.eq('company_id', conv.company_id)
|
||||
.eq('user_id', user.id)
|
||||
.maybeSingle()
|
||||
if (!membership) {
|
||||
return NextResponse.json({ error: 'Conversation not found' }, { status: 404 })
|
||||
}
|
||||
// Defense in depth alongside RLS: the caller must still be a member of
|
||||
// the conversation's company (they may have been removed since).
|
||||
const { data: membership } = await supabase
|
||||
.from('company_members')
|
||||
.select('role')
|
||||
.eq('company_id', conv.company_id)
|
||||
.eq('user_id', user.id)
|
||||
.maybeSingle()
|
||||
if (!membership) return notFound()
|
||||
|
||||
const { data: messages, error: msgErr } = await supabase
|
||||
.from('agent_messages')
|
||||
.select('id, role, content, tool_use_id, hidden, created_at')
|
||||
.eq('conversation_id', id)
|
||||
.order('created_at', { ascending: true })
|
||||
if (msgErr) return NextResponse.json({ error: msgErr.message }, { status: 500 })
|
||||
const { data: messages, error: msgErr } = await supabase
|
||||
.from('agent_messages')
|
||||
.select('id, role, content, tool_use_id, hidden, created_at')
|
||||
.eq('conversation_id', id)
|
||||
.order('created_at', { ascending: true })
|
||||
if (msgErr) throw msgErr
|
||||
|
||||
return NextResponse.json({ data: { conversation: conv, messages: messages ?? [] } })
|
||||
}
|
||||
return NextResponse.json({ data: { conversation: conv, messages: messages ?? [] } })
|
||||
},
|
||||
)
|
||||
|
||||
export async function PATCH(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
export const PATCH = withRouteContext(
|
||||
'agent.conversations.update',
|
||||
async (request, ctx, { params }: { params: Promise<{ id: string }> }) => {
|
||||
const { id } = await params
|
||||
const { supabase, user, log } = ctx
|
||||
|
||||
const { id } = await params
|
||||
let body: z.infer<typeof PatchSchema>
|
||||
try {
|
||||
body = PatchSchema.parse(await request.json())
|
||||
} catch (err) {
|
||||
return NextResponse.json(
|
||||
{ error: err instanceof Error ? err.message : 'Invalid body' },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
const validation = await validateBody(request, PatchSchema, {
|
||||
log,
|
||||
operation: 'agent.conversations.update',
|
||||
})
|
||||
if (!validation.success) return validation.response
|
||||
const body = validation.data
|
||||
|
||||
const update: Record<string, unknown> = {}
|
||||
if (body.pinned != null) update.pinned = body.pinned
|
||||
if (body.archived != null) update.archived = body.archived
|
||||
if (body.title != null) update.title = body.title
|
||||
if (Object.keys(update).length === 0) {
|
||||
return NextResponse.json({ error: 'Nothing to update' }, { status: 400 })
|
||||
}
|
||||
const update: Record<string, unknown> = {}
|
||||
if (body.pinned != null) update.pinned = body.pinned
|
||||
if (body.archived != null) update.archived = body.archived
|
||||
if (body.title != null) update.title = body.title
|
||||
if (Object.keys(update).length === 0) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: {
|
||||
code: 'NOTHING_TO_UPDATE',
|
||||
message: 'Inget att uppdatera.',
|
||||
message_en: 'Nothing to update.',
|
||||
},
|
||||
},
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
|
||||
// Defense in depth: verify ownership before update so a 404 is returned
|
||||
// (instead of relying solely on RLS, which would silently 0-row).
|
||||
const { data: existing } = await supabase
|
||||
.from('agent_conversations')
|
||||
.select('user_id, company_id')
|
||||
.eq('id', id)
|
||||
.maybeSingle()
|
||||
if (!existing || existing.user_id !== user.id) {
|
||||
return NextResponse.json({ error: 'Conversation not found' }, { status: 404 })
|
||||
}
|
||||
// Defense in depth: verify ownership before update so a 404 is returned
|
||||
// (instead of relying solely on RLS, which would silently 0-row).
|
||||
const { data: existing } = await supabase
|
||||
.from('agent_conversations')
|
||||
.select('user_id, company_id')
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.maybeSingle()
|
||||
if (!existing) return notFound()
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('agent_conversations')
|
||||
.update(update)
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.eq('company_id', existing.company_id)
|
||||
.select('id, intent_id, context_ref, title, pinned, archived, last_message_at, created_at')
|
||||
.single()
|
||||
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
const { data, error } = await supabase
|
||||
.from('agent_conversations')
|
||||
.update(update)
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.eq('company_id', existing.company_id)
|
||||
.select('id, intent_id, context_ref, title, pinned, archived, last_message_at, created_at')
|
||||
.single()
|
||||
if (error) throw error
|
||||
return NextResponse.json({ data })
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getActiveCompanyId } from '@/lib/company/context'
|
||||
import { z } from 'zod'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { validateQuery } from '@/lib/api/validate'
|
||||
|
||||
// GET /api/agent/conversations
|
||||
//
|
||||
@@ -16,20 +17,25 @@ import { getActiveCompanyId } from '@/lib/company/context'
|
||||
//
|
||||
// Ordered: pinned first (within archived bucket), then last_message_at desc.
|
||||
// Used by the /chat sidebar and "resume conversation" UI in the sheet.
|
||||
export async function GET(request: Request) {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
|
||||
const companyId = await getActiveCompanyId(supabase, user.id)
|
||||
if (!companyId) return NextResponse.json({ error: 'No active company' }, { status: 400 })
|
||||
const ListQuerySchema = z.object({
|
||||
archived: z.enum(['true', 'false']).default('false'),
|
||||
pinned: z.enum(['true', 'false']).optional(),
|
||||
intent: z.string().min(1).optional(),
|
||||
q: z.string().optional(),
|
||||
limit: z.coerce.number().int().min(1).max(200).default(50),
|
||||
})
|
||||
|
||||
const url = new URL(request.url)
|
||||
const archived = url.searchParams.get('archived') === 'true'
|
||||
const pinnedOnly = url.searchParams.get('pinned') === 'true'
|
||||
const intent = url.searchParams.get('intent') ?? null
|
||||
const q = url.searchParams.get('q')?.trim() ?? ''
|
||||
const limit = Math.min(Math.max(Number(url.searchParams.get('limit')) || 50, 1), 200)
|
||||
export const GET = withRouteContext('agent.conversations.list', async (request, ctx) => {
|
||||
const { supabase, companyId, user, log } = ctx
|
||||
|
||||
const validated = validateQuery(request, ListQuerySchema, {
|
||||
log,
|
||||
operation: 'agent.conversations.list',
|
||||
})
|
||||
if (!validated.success) return validated.response
|
||||
const { archived, pinned, intent, limit } = validated.data
|
||||
const q = validated.data.q?.trim() ?? ''
|
||||
|
||||
let query = supabase
|
||||
.from('agent_conversations')
|
||||
@@ -37,9 +43,14 @@ export async function GET(request: Request) {
|
||||
'id, intent_id, context_ref, title, pinned, archived, last_message_at, last_message_preview, created_at',
|
||||
)
|
||||
.eq('company_id', companyId)
|
||||
.eq('archived', archived)
|
||||
// Conversations are user-scoped within a company — one member must not
|
||||
// see another's (see [id]/route.ts). The RLS policy is company-scoped,
|
||||
// so this filter is what actually prevents cross-member leakage of
|
||||
// titles and last_message_preview snippets.
|
||||
.eq('user_id', user.id)
|
||||
.eq('archived', archived === 'true')
|
||||
|
||||
if (pinnedOnly) query = query.eq('pinned', true)
|
||||
if (pinned === 'true') query = query.eq('pinned', true)
|
||||
if (intent) query = query.eq('intent_id', intent)
|
||||
if (q.length > 0) {
|
||||
// Pattern is sanitized via Postgres' percent-handling; ilike accepts the
|
||||
@@ -55,6 +66,6 @@ export async function GET(request: Request) {
|
||||
.limit(limit)
|
||||
|
||||
const { data, error } = await query
|
||||
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
if (error) throw error
|
||||
return NextResponse.json({ data: data ?? [] })
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
|
||||
// PATCH /api/agent/memory/[id]
|
||||
//
|
||||
@@ -30,61 +30,65 @@ const PatchSchema = z
|
||||
{ message: 'Nothing to update' },
|
||||
)
|
||||
|
||||
export async function PATCH(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
const notFound = () =>
|
||||
NextResponse.json(
|
||||
{
|
||||
error: {
|
||||
code: 'MEMORY_NOT_FOUND',
|
||||
message: 'Minnet hittades inte.',
|
||||
message_en: 'Memory not found.',
|
||||
},
|
||||
},
|
||||
{ status: 404 },
|
||||
)
|
||||
|
||||
const writeCheck = await requireWritePermission(supabase, user.id)
|
||||
if (!writeCheck.ok) return writeCheck.response
|
||||
export const PATCH = withRouteContext(
|
||||
'agent.memory.update',
|
||||
async (request, ctx, { params }: { params: Promise<{ id: string }> }) => {
|
||||
const { id } = await params
|
||||
const { supabase, user, log } = ctx
|
||||
|
||||
const { id } = await params
|
||||
const validation = await validateBody(request, PatchSchema, {
|
||||
log,
|
||||
operation: 'agent.memory.update',
|
||||
})
|
||||
if (!validation.success) return validation.response
|
||||
const body = validation.data
|
||||
|
||||
let body: z.infer<typeof PatchSchema>
|
||||
try {
|
||||
body = PatchSchema.parse(await request.json())
|
||||
} catch (err) {
|
||||
return NextResponse.json(
|
||||
{ error: err instanceof Error ? err.message : 'Invalid body' },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
const update: Record<string, unknown> = {}
|
||||
if (body.content !== undefined) update.content = body.content
|
||||
if (body.is_pinned !== undefined) update.is_pinned = body.is_pinned
|
||||
if (body.is_active !== undefined) update.is_active = body.is_active
|
||||
|
||||
const update: Record<string, unknown> = {}
|
||||
if (body.content !== undefined) update.content = body.content
|
||||
if (body.is_pinned !== undefined) update.is_pinned = body.is_pinned
|
||||
if (body.is_active !== undefined) update.is_active = body.is_active
|
||||
// Look up the row's company_id and re-check membership before mutating.
|
||||
const { data: existing } = await supabase
|
||||
.from('agent_memory')
|
||||
.select('company_id')
|
||||
.eq('id', id)
|
||||
.maybeSingle()
|
||||
if (!existing) return notFound()
|
||||
|
||||
// Look up the row's company_id and re-check membership before mutating.
|
||||
const { data: existing } = await supabase
|
||||
.from('agent_memory')
|
||||
.select('company_id')
|
||||
.eq('id', id)
|
||||
.maybeSingle()
|
||||
if (!existing) return NextResponse.json({ error: 'Memory not found' }, { status: 404 })
|
||||
const { data: membership } = await supabase
|
||||
.from('company_members')
|
||||
.select('role')
|
||||
.eq('company_id', existing.company_id)
|
||||
.eq('user_id', user.id)
|
||||
.maybeSingle()
|
||||
if (!membership) return notFound()
|
||||
|
||||
const { data: membership } = await supabase
|
||||
.from('company_members')
|
||||
.select('role')
|
||||
.eq('company_id', existing.company_id)
|
||||
.eq('user_id', user.id)
|
||||
.maybeSingle()
|
||||
if (!membership) return NextResponse.json({ error: 'Memory not found' }, { status: 404 })
|
||||
const { data, error } = await supabase
|
||||
.from('agent_memory')
|
||||
.update(update)
|
||||
.eq('id', id)
|
||||
.eq('company_id', existing.company_id)
|
||||
.select(
|
||||
'id, kind, content, source, source_ref, relevance_score, is_pinned, is_active, last_accessed_at, created_at, updated_at',
|
||||
)
|
||||
.maybeSingle()
|
||||
if (error) throw error
|
||||
if (!data) return notFound()
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('agent_memory')
|
||||
.update(update)
|
||||
.eq('id', id)
|
||||
.eq('company_id', existing.company_id)
|
||||
.select(
|
||||
'id, kind, content, source, source_ref, relevance_score, is_pinned, is_active, last_accessed_at, created_at, updated_at',
|
||||
)
|
||||
.maybeSingle()
|
||||
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
if (!data) return NextResponse.json({ error: 'Memory not found' }, { status: 404 })
|
||||
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
return NextResponse.json({ data })
|
||||
},
|
||||
{ requireWrite: true },
|
||||
)
|
||||
|
||||
@@ -37,7 +37,7 @@ beforeEach(() => {
|
||||
describe('GET /api/agent/memory', () => {
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } })
|
||||
const response = await GET(createMockRequest('/api/agent/memory'))
|
||||
const response = await GET(createMockRequest('/api/agent/memory'), createMockRouteParams({}))
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(response)
|
||||
expect(status).toBe(401)
|
||||
expect(body.error).toBe('Unauthorized')
|
||||
@@ -45,7 +45,7 @@ describe('GET /api/agent/memory', () => {
|
||||
|
||||
it('returns 400 when no active company', async () => {
|
||||
getActiveCompanyIdMock.mockResolvedValue(null)
|
||||
const response = await GET(createMockRequest('/api/agent/memory'))
|
||||
const response = await GET(createMockRequest('/api/agent/memory'), createMockRouteParams({}))
|
||||
const { status } = await parseJsonResponse(response)
|
||||
expect(status).toBe(400)
|
||||
})
|
||||
@@ -67,7 +67,7 @@ describe('GET /api/agent/memory', () => {
|
||||
},
|
||||
]
|
||||
enqueue({ data: rows })
|
||||
const response = await GET(createMockRequest('/api/agent/memory'))
|
||||
const response = await GET(createMockRequest('/api/agent/memory'), createMockRouteParams({}))
|
||||
const { status, body } = await parseJsonResponse<{ data: typeof rows }>(response)
|
||||
expect(status).toBe(200)
|
||||
expect(body.data).toHaveLength(1)
|
||||
@@ -76,7 +76,7 @@ describe('GET /api/agent/memory', () => {
|
||||
|
||||
it('does not require write permission for read', async () => {
|
||||
enqueue({ data: [] })
|
||||
await GET(createMockRequest('/api/agent/memory'))
|
||||
await GET(createMockRequest('/api/agent/memory'), createMockRouteParams({}))
|
||||
expect(requireWritePermissionMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -89,6 +89,7 @@ describe('POST /api/agent/memory', () => {
|
||||
method: 'POST',
|
||||
body: { content: 'hello world' },
|
||||
}),
|
||||
createMockRouteParams({}),
|
||||
)
|
||||
expect(response.status).toBe(401)
|
||||
})
|
||||
@@ -104,6 +105,7 @@ describe('POST /api/agent/memory', () => {
|
||||
method: 'POST',
|
||||
body: { content: 'hello world' },
|
||||
}),
|
||||
createMockRouteParams({}),
|
||||
)
|
||||
expect(response.status).toBe(403)
|
||||
})
|
||||
@@ -114,6 +116,7 @@ describe('POST /api/agent/memory', () => {
|
||||
method: 'POST',
|
||||
body: { content: 'x' },
|
||||
}),
|
||||
createMockRouteParams({}),
|
||||
)
|
||||
expect(response.status).toBe(400)
|
||||
})
|
||||
@@ -142,6 +145,7 @@ describe('POST /api/agent/memory', () => {
|
||||
method: 'POST',
|
||||
body: { content: 'En sak att komma ihåg' },
|
||||
}),
|
||||
createMockRouteParams({}),
|
||||
)
|
||||
const { status, body } = await parseJsonResponse<{ data: typeof inserted }>(response)
|
||||
expect(status).toBe(200)
|
||||
@@ -155,6 +159,7 @@ describe('POST /api/agent/memory', () => {
|
||||
method: 'POST',
|
||||
body: { content: 'En sak att komma ihåg' },
|
||||
}),
|
||||
createMockRouteParams({}),
|
||||
)
|
||||
expect(response.status).toBe(403)
|
||||
})
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { getActiveCompanyId } from '@/lib/company/context'
|
||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { validateBody, validateQuery } from '@/lib/api/validate'
|
||||
|
||||
// GET /api/agent/memory
|
||||
//
|
||||
@@ -27,6 +26,15 @@ import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
const KIND = ['fact', 'preference', 'pattern', 'correction'] as const
|
||||
const SOURCE = ['composer', 'user_taught', 'agent_learned', 'derived'] as const
|
||||
|
||||
const MEMORY_COLUMNS =
|
||||
'id, kind, content, source, source_ref, relevance_score, is_pinned, is_active, last_accessed_at, created_at, updated_at'
|
||||
|
||||
const ListQuerySchema = z.object({
|
||||
include_dismissed: z.enum(['true', 'false']).optional(),
|
||||
kind: z.enum(KIND).optional(),
|
||||
limit: z.coerce.number().int().min(1).max(200).default(200),
|
||||
})
|
||||
|
||||
const BodySchema = z.object({
|
||||
company_id: z.string().uuid().optional(),
|
||||
content: z.string().min(2).max(2000),
|
||||
@@ -38,30 +46,22 @@ const BodySchema = z.object({
|
||||
relevance_score: z.number().min(0).max(1).default(1.0),
|
||||
})
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
export const GET = withRouteContext('agent.memory.list', async (request, ctx) => {
|
||||
const { supabase, companyId, log } = ctx
|
||||
|
||||
const companyId = await getActiveCompanyId(supabase, user.id)
|
||||
if (!companyId) return NextResponse.json({ error: 'No active company' }, { status: 400 })
|
||||
|
||||
const url = new URL(request.url)
|
||||
const includeDismissed = url.searchParams.get('include_dismissed') === 'true'
|
||||
const kindParam = url.searchParams.get('kind')
|
||||
const kind = KIND.includes(kindParam as (typeof KIND)[number])
|
||||
? (kindParam as (typeof KIND)[number])
|
||||
: null
|
||||
const limit = Math.min(Math.max(Number(url.searchParams.get('limit')) || 200, 1), 200)
|
||||
const validated = validateQuery(request, ListQuerySchema, {
|
||||
log,
|
||||
operation: 'agent.memory.list',
|
||||
})
|
||||
if (!validated.success) return validated.response
|
||||
const { include_dismissed, kind, limit } = validated.data
|
||||
|
||||
let query = supabase
|
||||
.from('agent_memory')
|
||||
.select(
|
||||
'id, kind, content, source, source_ref, relevance_score, is_pinned, is_active, last_accessed_at, created_at, updated_at',
|
||||
)
|
||||
.select(MEMORY_COLUMNS)
|
||||
.eq('company_id', companyId)
|
||||
|
||||
if (!includeDismissed) query = query.eq('is_active', true)
|
||||
if (include_dismissed !== 'true') query = query.eq('is_active', true)
|
||||
if (kind) query = query.eq('kind', kind)
|
||||
|
||||
query = query
|
||||
@@ -73,64 +73,63 @@ export async function GET(request: Request) {
|
||||
.limit(limit)
|
||||
|
||||
const { data, error } = await query
|
||||
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
if (error) throw error
|
||||
return NextResponse.json({ data: data ?? [] })
|
||||
}
|
||||
})
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
export const POST = withRouteContext(
|
||||
'agent.memory.create',
|
||||
async (request, ctx) => {
|
||||
const { supabase, companyId: activeCompanyId, user, log } = ctx
|
||||
|
||||
const writeCheck = await requireWritePermission(supabase, user.id)
|
||||
if (!writeCheck.ok) return writeCheck.response
|
||||
|
||||
let body: z.infer<typeof BodySchema>
|
||||
try {
|
||||
body = BodySchema.parse(await request.json())
|
||||
} catch (err) {
|
||||
return NextResponse.json(
|
||||
{ error: err instanceof Error ? err.message : 'Invalid body' },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
|
||||
const companyId = body.company_id ?? (await getActiveCompanyId(supabase, user.id))
|
||||
if (!companyId) return NextResponse.json({ error: 'No active company' }, { status: 400 })
|
||||
|
||||
// requireWritePermission above checks the *active* company's role; if the
|
||||
// caller passes a different company_id in the body, re-check membership +
|
||||
// non-viewer role for THAT company specifically.
|
||||
const { data: bodyMembership } = await supabase
|
||||
.from('company_members')
|
||||
.select('role')
|
||||
.eq('company_id', companyId)
|
||||
.eq('user_id', user.id)
|
||||
.maybeSingle()
|
||||
if (!bodyMembership || bodyMembership.role === 'viewer') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Du har endast läsbehörighet i detta företag.' },
|
||||
{ status: 403 },
|
||||
)
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('agent_memory')
|
||||
.insert({
|
||||
company_id: companyId,
|
||||
kind: body.kind,
|
||||
content: body.content,
|
||||
source: body.source,
|
||||
source_ref: body.source_ref ?? null,
|
||||
relevance_score: body.relevance_score,
|
||||
is_active: true,
|
||||
created_by_user_id: user.id,
|
||||
const validation = await validateBody(request, BodySchema, {
|
||||
log,
|
||||
operation: 'agent.memory.create',
|
||||
})
|
||||
.select(
|
||||
'id, kind, content, source, source_ref, relevance_score, is_pinned, is_active, last_accessed_at, created_at, updated_at',
|
||||
)
|
||||
.single()
|
||||
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
if (!validation.success) return validation.response
|
||||
const body = validation.data
|
||||
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
const companyId = body.company_id ?? activeCompanyId
|
||||
|
||||
// requireWrite (wrapper option) checks the *active* company's role; if
|
||||
// the caller passes a different company_id in the body, re-check
|
||||
// membership + non-viewer role for THAT company specifically.
|
||||
const { data: bodyMembership } = await supabase
|
||||
.from('company_members')
|
||||
.select('role')
|
||||
.eq('company_id', companyId)
|
||||
.eq('user_id', user.id)
|
||||
.maybeSingle()
|
||||
if (!bodyMembership || bodyMembership.role === 'viewer') {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: {
|
||||
code: 'WRITE_PERMISSION_REQUIRED',
|
||||
message: 'Du har endast läsbehörighet i detta företag.',
|
||||
message_en: 'You only have read access in this company.',
|
||||
},
|
||||
},
|
||||
{ status: 403 },
|
||||
)
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('agent_memory')
|
||||
.insert({
|
||||
company_id: companyId,
|
||||
kind: body.kind,
|
||||
content: body.content,
|
||||
source: body.source,
|
||||
source_ref: body.source_ref ?? null,
|
||||
relevance_score: body.relevance_score,
|
||||
is_active: true,
|
||||
created_by_user_id: user.id,
|
||||
})
|
||||
.select(MEMORY_COLUMNS)
|
||||
.single()
|
||||
if (error) throw error
|
||||
|
||||
return NextResponse.json({ data })
|
||||
},
|
||||
{ requireWrite: true },
|
||||
)
|
||||
|
||||
@@ -102,6 +102,14 @@ export async function POST(request: Request) {
|
||||
if (!membership) {
|
||||
return NextResponse.json({ error: 'Not a member of this company' }, { status: 403 })
|
||||
}
|
||||
// The pipeline upserts agent_profiles — a mutation, so viewers are refused
|
||||
// (same rule as /api/agent/profile and /verify).
|
||||
if (membership.role === 'viewer') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Du har endast läsbehörighet i detta företag.' },
|
||||
{ status: 403 },
|
||||
)
|
||||
}
|
||||
|
||||
// No live composer run for sandbox companies: they ship with a pre-built
|
||||
// verified agent_profile so the chrome is visible without burning Bedrock.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { getActiveCompanyId } from '@/lib/company/context'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { validateBody, validateQuery } from '@/lib/api/validate'
|
||||
|
||||
// GET /api/agent/profile?company_id=...
|
||||
// PATCH same path
|
||||
@@ -11,7 +11,8 @@ import { getActiveCompanyId } from '@/lib/company/context'
|
||||
//
|
||||
// PATCH updates field_overrides (timestamped, merged with existing) and
|
||||
// optionally rewrites the atom arrays from the review UI. Does not touch
|
||||
// verified_at: that flows through /verify.
|
||||
// verified_at: that flows through /verify. Requires a non-viewer role in
|
||||
// the target company (same rule as /verify: it mutates the profile).
|
||||
|
||||
const AtomArrays = z.object({
|
||||
horizontal_atoms: z.array(z.string()).optional(),
|
||||
@@ -31,15 +32,38 @@ const PatchBody = z.object({
|
||||
avatar_id: z.string().max(60).nullable().optional(),
|
||||
})
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
const GetQuerySchema = z.object({
|
||||
company_id: z.string().uuid().optional(),
|
||||
})
|
||||
|
||||
const url = new URL(request.url)
|
||||
const companyId =
|
||||
url.searchParams.get('company_id') ?? (await getActiveCompanyId(supabase, user.id))
|
||||
if (!companyId) return NextResponse.json({ error: 'No active company' }, { status: 400 })
|
||||
const forbidden = (code: 'NOT_COMPANY_MEMBER' | 'WRITE_PERMISSION_REQUIRED') =>
|
||||
NextResponse.json(
|
||||
{
|
||||
error:
|
||||
code === 'NOT_COMPANY_MEMBER'
|
||||
? {
|
||||
code,
|
||||
message: 'Du är inte medlem i detta företag.',
|
||||
message_en: 'Not a member of this company.',
|
||||
}
|
||||
: {
|
||||
code,
|
||||
message: 'Du har endast läsbehörighet i detta företag.',
|
||||
message_en: 'You only have read access in this company.',
|
||||
},
|
||||
},
|
||||
{ status: 403 },
|
||||
)
|
||||
|
||||
export const GET = withRouteContext('agent.profile.get', async (request, ctx) => {
|
||||
const { supabase, companyId: activeCompanyId, user, log } = ctx
|
||||
|
||||
const validated = validateQuery(request, GetQuerySchema, {
|
||||
log,
|
||||
operation: 'agent.profile.get',
|
||||
})
|
||||
if (!validated.success) return validated.response
|
||||
const companyId = validated.data.company_id ?? activeCompanyId
|
||||
|
||||
// Defense in depth alongside RLS: confirm membership before reading.
|
||||
const { data: membership } = await supabase
|
||||
@@ -48,7 +72,7 @@ export async function GET(request: Request) {
|
||||
.eq('company_id', companyId)
|
||||
.eq('user_id', user.id)
|
||||
.maybeSingle()
|
||||
if (!membership) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
if (!membership) return forbidden('NOT_COMPANY_MEMBER')
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('agent_profiles')
|
||||
@@ -57,38 +81,35 @@ export async function GET(request: Request) {
|
||||
)
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle()
|
||||
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
if (error) throw error
|
||||
if (!data) return NextResponse.json({ data: null })
|
||||
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
})
|
||||
|
||||
export async function PATCH(request: Request) {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
export const PATCH = withRouteContext('agent.profile.update', async (request, ctx) => {
|
||||
const { supabase, companyId: activeCompanyId, user, log } = ctx
|
||||
|
||||
let body: z.infer<typeof PatchBody>
|
||||
try {
|
||||
body = PatchBody.parse(await request.json())
|
||||
} catch (err) {
|
||||
return NextResponse.json(
|
||||
{ error: err instanceof Error ? err.message : 'Invalid body' },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
const validation = await validateBody(request, PatchBody, {
|
||||
log,
|
||||
operation: 'agent.profile.update',
|
||||
})
|
||||
if (!validation.success) return validation.response
|
||||
const body = validation.data
|
||||
|
||||
const companyId = body.company_id ?? (await getActiveCompanyId(supabase, user.id))
|
||||
if (!companyId) return NextResponse.json({ error: 'No active company' }, { status: 400 })
|
||||
const companyId = body.company_id ?? activeCompanyId
|
||||
|
||||
// RLS guards reads/updates by company_id; defense in depth: confirm membership.
|
||||
// RLS guards reads/updates by company_id; defense in depth: confirm
|
||||
// membership AND a non-viewer role (this mutates the company's profile;
|
||||
// same rule /verify already enforces).
|
||||
const { data: membership } = await supabase
|
||||
.from('company_members')
|
||||
.select('role')
|
||||
.eq('company_id', companyId)
|
||||
.eq('user_id', user.id)
|
||||
.maybeSingle()
|
||||
if (!membership) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
if (!membership) return forbidden('NOT_COMPANY_MEMBER')
|
||||
if (membership.role === 'viewer') return forbidden('WRITE_PERMISSION_REQUIRED')
|
||||
|
||||
// Load current overrides to merge timestamp-stamped entries. Avoids round-trip
|
||||
// when caller sends only an atom-array change.
|
||||
@@ -98,7 +119,16 @@ export async function PATCH(request: Request) {
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
if (!current) {
|
||||
return NextResponse.json({ error: 'agent_profile not found for this company' }, { status: 404 })
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: {
|
||||
code: 'AGENT_PROFILE_NOT_FOUND',
|
||||
message: 'Det finns ingen agentprofil för detta företag.',
|
||||
message_en: 'agent_profile not found for this company.',
|
||||
},
|
||||
},
|
||||
{ status: 404 },
|
||||
)
|
||||
}
|
||||
|
||||
const update: Record<string, unknown> = {}
|
||||
@@ -120,7 +150,16 @@ export async function PATCH(request: Request) {
|
||||
if (body.avatar_id !== undefined) update.avatar_id = body.avatar_id
|
||||
|
||||
if (Object.keys(update).length === 0) {
|
||||
return NextResponse.json({ error: 'Nothing to update' }, { status: 400 })
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: {
|
||||
code: 'NOTHING_TO_UPDATE',
|
||||
message: 'Inget att uppdatera.',
|
||||
message_en: 'Nothing to update.',
|
||||
},
|
||||
},
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
@@ -131,7 +170,7 @@ export async function PATCH(request: Request) {
|
||||
'company_id, horizontal_atoms, vertical_atoms, modifier_atoms, profile_summary, field_overrides',
|
||||
)
|
||||
.single()
|
||||
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
if (error) throw error
|
||||
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { getActiveCompanyId } from '@/lib/company/context'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
|
||||
// POST /api/agent/profile/verify
|
||||
//
|
||||
@@ -13,23 +12,29 @@ const BodySchema = z.object({
|
||||
company_id: z.string().uuid().optional(),
|
||||
})
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
export const POST = withRouteContext('agent.profile.verify', async (request, ctx) => {
|
||||
const { supabase, companyId: activeCompanyId, user } = ctx
|
||||
|
||||
let body: z.infer<typeof BodySchema>
|
||||
try {
|
||||
body = BodySchema.parse(await request.json().catch(() => ({})))
|
||||
} catch (err) {
|
||||
// Tolerant parse: the review card POSTs with an empty body when verifying
|
||||
// the active company.
|
||||
const raw = await request.json().catch(() => ({}))
|
||||
const parsed = BodySchema.safeParse(raw)
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: err instanceof Error ? err.message : 'Invalid body' },
|
||||
{
|
||||
error: 'Validation failed',
|
||||
type: 'validation_error',
|
||||
errors: parsed.error.issues.map((issue) => ({
|
||||
field: issue.path.join('.'),
|
||||
message: issue.message,
|
||||
code: issue.code,
|
||||
})),
|
||||
},
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
|
||||
const companyId = body.company_id ?? (await getActiveCompanyId(supabase, user.id))
|
||||
if (!companyId) return NextResponse.json({ error: 'No active company' }, { status: 400 })
|
||||
const companyId = parsed.data.company_id ?? activeCompanyId
|
||||
|
||||
// Defense in depth alongside RLS: confirm membership for the target
|
||||
// company; a non-viewer role is required to stamp verified_at.
|
||||
@@ -41,7 +46,13 @@ export async function POST(request: Request) {
|
||||
.maybeSingle()
|
||||
if (!membership || membership.role === 'viewer') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Du har endast läsbehörighet i detta företag.' },
|
||||
{
|
||||
error: {
|
||||
code: 'WRITE_PERMISSION_REQUIRED',
|
||||
message: 'Du har endast läsbehörighet i detta företag.',
|
||||
message_en: 'You only have read access in this company.',
|
||||
},
|
||||
},
|
||||
{ status: 403 },
|
||||
)
|
||||
}
|
||||
@@ -55,7 +66,7 @@ export async function POST(request: Request) {
|
||||
.eq('company_id', companyId)
|
||||
.select('company_id, verified_at, verified_by_user_id')
|
||||
.single()
|
||||
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
if (error) throw error
|
||||
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getActiveCompanyId } from '@/lib/company/context'
|
||||
import { z } from 'zod'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { validateQuery } from '@/lib/api/validate'
|
||||
|
||||
// GET /api/agent/skills
|
||||
//
|
||||
@@ -32,27 +33,41 @@ interface AtomMeta {
|
||||
active: boolean
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
const QuerySchema = z.object({
|
||||
slug: z.string().min(1).optional(),
|
||||
})
|
||||
|
||||
const companyId = await getActiveCompanyId(supabase, user.id)
|
||||
if (!companyId) return NextResponse.json({ error: 'No active company' }, { status: 400 })
|
||||
export const GET = withRouteContext('agent.skills.list', async (request, ctx) => {
|
||||
const { supabase, companyId, log } = ctx
|
||||
|
||||
const url = new URL(request.url)
|
||||
const slug = url.searchParams.get('slug')
|
||||
const validated = validateQuery(request, QuerySchema, {
|
||||
log,
|
||||
operation: 'agent.skills.list',
|
||||
})
|
||||
if (!validated.success) return validated.response
|
||||
const { slug } = validated.data
|
||||
|
||||
// Detail: one atom's body, fetched lazily when the user expands a card.
|
||||
// The atom registry is global product content (not tenant data), so no
|
||||
// company filter applies here.
|
||||
if (slug) {
|
||||
const { data, error } = await supabase
|
||||
.from('agent_atom_registry')
|
||||
.select('id, title, body, is_active, mcp_exposed')
|
||||
.eq('id', slug)
|
||||
.maybeSingle()
|
||||
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
if (error) throw error
|
||||
if (!data || !data.is_active || !data.mcp_exposed) {
|
||||
return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: {
|
||||
code: 'SKILL_NOT_FOUND',
|
||||
message: 'Kunskapen hittades inte.',
|
||||
message_en: 'Skill not found.',
|
||||
},
|
||||
},
|
||||
{ status: 404 },
|
||||
)
|
||||
}
|
||||
return NextResponse.json({ data: { id: data.id, title: data.title, body: data.body ?? '' } })
|
||||
}
|
||||
@@ -66,7 +81,7 @@ export async function GET(request: Request) {
|
||||
.is('parent_atom_id', null) // show top-level skills only; reference children are internal
|
||||
.order('tier', { ascending: true })
|
||||
.order('title', { ascending: true })
|
||||
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
if (error) throw error
|
||||
|
||||
const { data: profile } = await supabase
|
||||
.from('agent_profiles')
|
||||
@@ -89,4 +104,4 @@ export async function GET(request: Request) {
|
||||
})
|
||||
|
||||
return NextResponse.json({ data: result })
|
||||
}
|
||||
})
|
||||
|
||||
+18
-14
@@ -7,7 +7,8 @@ import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { ensureArticleNumber } from '@/lib/articles/ensure-article-number'
|
||||
import { checkRevenueAccount } from '@/lib/articles/validate-revenue-account'
|
||||
import { AccountsNotInChartError, accountsNotInChartResponse } from '@/lib/bookkeeping/errors'
|
||||
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
||||
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
||||
import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
||||
import type { Article } from '@/types'
|
||||
|
||||
ensureInitialized()
|
||||
@@ -17,22 +18,25 @@ ensureInitialized()
|
||||
export const GET = withRouteContext(
|
||||
'article.list',
|
||||
async (request, ctx) => {
|
||||
const { supabase, companyId, log, requestId } = ctx
|
||||
const { supabase, companyId } = ctx
|
||||
|
||||
const includeInactive = new URL(request.url).searchParams.get('include_inactive') === '1'
|
||||
|
||||
let query = supabase
|
||||
.from('articles')
|
||||
.select('*')
|
||||
.eq('company_id', companyId)
|
||||
if (!includeInactive) query = query.eq('active', true)
|
||||
|
||||
const { data, error } = await query.order('name', { ascending: true })
|
||||
|
||||
if (error) {
|
||||
log.error('article list failed', error)
|
||||
return errorResponse(error, log, { requestId })
|
||||
}
|
||||
// Article registers can exceed PostgREST's silent 1000-row cap (imported
|
||||
// product catalogs), so paginate. The secondary order on id gives the
|
||||
// stable total order .range() paging requires — name alone is not unique.
|
||||
// Errors thrown here surface via the wrapper's canonical envelope.
|
||||
const data = await fetchAllRows(({ from, to }) => {
|
||||
let query = supabase
|
||||
.from('articles')
|
||||
.select('*')
|
||||
.eq('company_id', companyId)
|
||||
if (!includeInactive) query = query.eq('active', true)
|
||||
return query
|
||||
.order('name', { ascending: true })
|
||||
.order('id', { ascending: true })
|
||||
.range(from, to)
|
||||
})
|
||||
|
||||
return NextResponse.json({ data })
|
||||
},
|
||||
|
||||
@@ -51,8 +51,8 @@ const UpdateAssetSchema = z
|
||||
// K3 component depreciation. Accepting `null` lets the caller clear an
|
||||
// existing breakdown (the engine then falls back to depreciation_method).
|
||||
// Per-component validation runs whenever the field is set to a non-null
|
||||
// value; the cross-sum check needs acquisition_cost so it's deferred to
|
||||
// updateAsset() which can read the existing row.
|
||||
// value; the cross-sum check needs the asset's acquisition_cost so it runs
|
||||
// in the PATCH handler below, which can read the existing row.
|
||||
k3_components: z.array(K3ComponentSchema).nullable().optional(),
|
||||
})
|
||||
.superRefine((value, ctx) => {
|
||||
@@ -136,8 +136,11 @@ export const PATCH = withRouteContext(
|
||||
if (!existing) {
|
||||
return NextResponse.json({ error: { code: 'ASSET_NOT_FOUND' } }, { status: 404 })
|
||||
}
|
||||
// Validate against the cost that will be in effect after this PATCH —
|
||||
// a body that changes acquisition_cost and k3_components together must
|
||||
// sum to the NEW cost, not the stored one.
|
||||
const { errors } = validateComponents({
|
||||
acquisition_cost: Number(existing.acquisition_cost),
|
||||
acquisition_cost: validation.data.acquisition_cost ?? Number(existing.acquisition_cost),
|
||||
k3_components: validation.data.k3_components,
|
||||
})
|
||||
if (errors.length > 0) {
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* Tests for GET/PATCH /api/assets/[id].
|
||||
*
|
||||
* Exercises the routes through the real withRouteContext wrapper, mocking the
|
||||
* asset service and auth/company dependencies. The K3 component cross-sum
|
||||
* validation runs the REAL validateComponents so the regression case (body
|
||||
* changes acquisition_cost and k3_components together — sum must match the
|
||||
* NEW cost) is covered end to end.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { createQueuedMockSupabase, createMockRequest, parseJsonResponse } from '@/tests/helpers'
|
||||
|
||||
const { supabase, enqueue, reset } = createQueuedMockSupabase()
|
||||
|
||||
const requireAuthMock = vi.fn()
|
||||
vi.mock('@/lib/auth/require-auth', () => ({
|
||||
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/company/context', () => ({
|
||||
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
}))
|
||||
|
||||
const requireWriteMock = vi.fn()
|
||||
vi.mock('@/lib/auth/require-write', () => ({
|
||||
requireWritePermission: (...args: unknown[]) => requireWriteMock(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/bokslut/assets/asset-service', () => ({
|
||||
getAsset: vi.fn(),
|
||||
updateAsset: vi.fn(),
|
||||
}))
|
||||
|
||||
import { getAsset, updateAsset } from '@/lib/bokslut/assets/asset-service'
|
||||
import { GET, PATCH } from '../[id]/route'
|
||||
|
||||
const mockGetAsset = vi.mocked(getAsset)
|
||||
const mockUpdateAsset = vi.mocked(updateAsset)
|
||||
const routeParams = { params: Promise.resolve({ id: 'asset-1' }) }
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase, error: null })
|
||||
requireWriteMock.mockResolvedValue({ ok: true })
|
||||
})
|
||||
|
||||
describe('GET /api/assets/[id]', () => {
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
requireAuthMock.mockResolvedValue({
|
||||
user: null,
|
||||
supabase,
|
||||
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
|
||||
})
|
||||
|
||||
const res = await GET(createMockRequest('/api/assets/asset-1'), routeParams)
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
it('returns 404 when the asset does not exist', async () => {
|
||||
mockGetAsset.mockResolvedValue(null)
|
||||
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(
|
||||
await GET(createMockRequest('/api/assets/asset-1'), routeParams)
|
||||
)
|
||||
|
||||
expect(status).toBe(404)
|
||||
expect(body.error.code).toBe('ASSET_NOT_FOUND')
|
||||
})
|
||||
})
|
||||
|
||||
describe('PATCH /api/assets/[id]', () => {
|
||||
it('rejects an invalid body (non-positive acquisition_cost) with 400', async () => {
|
||||
const req = createMockRequest('/api/assets/asset-1', {
|
||||
method: 'PATCH',
|
||||
body: { acquisition_cost: -5 },
|
||||
})
|
||||
|
||||
const { status } = await parseJsonResponse(await PATCH(req, routeParams))
|
||||
|
||||
expect(status).toBe(400)
|
||||
expect(mockUpdateAsset).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects k3_components for a K2 company with 422', async () => {
|
||||
enqueue({ data: { accounting_framework: 'k2' } })
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
mockGetAsset.mockResolvedValue({ id: 'asset-1', acquisition_cost: 100000 } as any)
|
||||
|
||||
const req = createMockRequest('/api/assets/asset-1', {
|
||||
method: 'PATCH',
|
||||
body: {
|
||||
k3_components: [{ name: 'Stomme', cost: 100000, useful_life_months: 600 }],
|
||||
},
|
||||
})
|
||||
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(
|
||||
await PATCH(req, routeParams)
|
||||
)
|
||||
|
||||
expect(status).toBe(422)
|
||||
expect(body.error.code).toBe('K3_REQUIRED_FOR_COMPONENTS')
|
||||
expect(mockUpdateAsset).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('validates the component sum against the NEW acquisition_cost when both change', async () => {
|
||||
// Regression: stored cost is 100 000 but the PATCH raises it to 120 000.
|
||||
// Components summing to 120 000 must pass — previously they were checked
|
||||
// against the stale stored cost and wrongly rejected.
|
||||
enqueue({ data: { accounting_framework: 'k3' } })
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
mockGetAsset.mockResolvedValue({ id: 'asset-1', acquisition_cost: 100000 } as any)
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
mockUpdateAsset.mockResolvedValue({ id: 'asset-1', acquisition_cost: 120000 } as any)
|
||||
|
||||
const req = createMockRequest('/api/assets/asset-1', {
|
||||
method: 'PATCH',
|
||||
body: {
|
||||
acquisition_cost: 120000,
|
||||
k3_components: [
|
||||
{ name: 'Stomme', cost: 90000, useful_life_months: 600 },
|
||||
{ name: 'Tak', cost: 30000, useful_life_months: 240 },
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
const { status } = await parseJsonResponse(await PATCH(req, routeParams))
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(mockUpdateAsset).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects components that sum to the OLD cost when the PATCH changes the cost', async () => {
|
||||
enqueue({ data: { accounting_framework: 'k3' } })
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
mockGetAsset.mockResolvedValue({ id: 'asset-1', acquisition_cost: 100000 } as any)
|
||||
|
||||
const req = createMockRequest('/api/assets/asset-1', {
|
||||
method: 'PATCH',
|
||||
body: {
|
||||
acquisition_cost: 120000,
|
||||
k3_components: [{ name: 'Stomme', cost: 100000, useful_life_months: 600 }],
|
||||
},
|
||||
})
|
||||
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(
|
||||
await PATCH(req, routeParams)
|
||||
)
|
||||
|
||||
expect(status).toBe(400)
|
||||
expect(body.error.code).toBe('INVALID_K3_COMPONENTS')
|
||||
expect(mockUpdateAsset).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,52 +1,75 @@
|
||||
/**
|
||||
* Tests for GET /api/audit-trail.
|
||||
*
|
||||
* Exercises the route through the real withRouteContext wrapper, mocking its
|
||||
* auth/company dependencies and the audit service. Covers: auth 401, query
|
||||
* validation 400, filter passthrough, and the canonical 500 envelope.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { createMockRequest, parseJsonResponse } from '@/tests/helpers'
|
||||
|
||||
vi.mock('@/lib/supabase/server', () => ({
|
||||
createClient: vi.fn(),
|
||||
const requireAuthMock = vi.fn()
|
||||
vi.mock('@/lib/auth/require-auth', () => ({
|
||||
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/company/context', () => ({
|
||||
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/core/audit/audit-service', () => ({
|
||||
getAuditLog: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/company/context', () => ({
|
||||
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
}))
|
||||
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { getAuditLog } from '@/lib/core/audit/audit-service'
|
||||
import { GET } from '../route'
|
||||
|
||||
const mockCreateClient = vi.mocked(createClient)
|
||||
const mockGetAuditLog = vi.mocked(getAuditLog)
|
||||
|
||||
function mockAuth(userId: string | null) {
|
||||
mockCreateClient.mockResolvedValue({
|
||||
auth: {
|
||||
getUser: vi.fn().mockResolvedValue({
|
||||
data: { user: userId ? { id: userId } : null },
|
||||
}),
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any)
|
||||
}
|
||||
const routeParams = { params: Promise.resolve({}) }
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase: {}, error: null })
|
||||
})
|
||||
|
||||
describe('GET /api/audit-trail', () => {
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
mockAuth(null)
|
||||
requireAuthMock.mockResolvedValue({
|
||||
user: null,
|
||||
supabase: {},
|
||||
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
|
||||
})
|
||||
|
||||
const req = createMockRequest('/api/audit-trail')
|
||||
const { status, body } = await parseJsonResponse(await GET(req))
|
||||
const { status, body } = await parseJsonResponse(await GET(req, routeParams))
|
||||
|
||||
expect(status).toBe(401)
|
||||
expect(body).toEqual({ error: 'Unauthorized' })
|
||||
})
|
||||
|
||||
it('returns audit log with data and count', async () => {
|
||||
mockAuth('user-1')
|
||||
it('returns 400 for an unknown action filter', async () => {
|
||||
const req = createMockRequest('/api/audit-trail', {
|
||||
searchParams: { action: 'NOT_AN_ACTION' },
|
||||
})
|
||||
const { status } = await parseJsonResponse(await GET(req, routeParams))
|
||||
|
||||
expect(status).toBe(400)
|
||||
expect(mockGetAuditLog).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns 400 for a non-numeric page', async () => {
|
||||
const req = createMockRequest('/api/audit-trail', {
|
||||
searchParams: { page: 'abc' },
|
||||
})
|
||||
const { status } = await parseJsonResponse(await GET(req, routeParams))
|
||||
|
||||
expect(status).toBe(400)
|
||||
expect(mockGetAuditLog).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns audit log with data and count, defaulting pagination', async () => {
|
||||
const entries = [
|
||||
{ id: '1', action: 'INSERT', table_name: 'journal_entries', created_at: '2024-01-01T00:00:00Z' },
|
||||
{ id: '2', action: 'COMMIT', table_name: 'journal_entries', created_at: '2024-01-02T00:00:00Z' },
|
||||
@@ -56,7 +79,9 @@ describe('GET /api/audit-trail', () => {
|
||||
|
||||
const req = createMockRequest('/api/audit-trail')
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const { status, body } = await parseJsonResponse<{ data: any[]; count: number }>(await GET(req))
|
||||
const { status, body } = await parseJsonResponse<{ data: any[]; count: number }>(
|
||||
await GET(req, routeParams)
|
||||
)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data).toHaveLength(2)
|
||||
@@ -64,12 +89,11 @@ describe('GET /api/audit-trail', () => {
|
||||
expect(mockGetAuditLog).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'company-1',
|
||||
expect.objectContaining({})
|
||||
expect.objectContaining({ page: 1, pageSize: 50 })
|
||||
)
|
||||
})
|
||||
|
||||
it('passes query param filters to getAuditLog', async () => {
|
||||
mockAuth('user-1')
|
||||
mockGetAuditLog.mockResolvedValue({ data: [], count: 0 })
|
||||
|
||||
const req = createMockRequest('/api/audit-trail', {
|
||||
@@ -84,7 +108,7 @@ describe('GET /api/audit-trail', () => {
|
||||
},
|
||||
})
|
||||
|
||||
await GET(req)
|
||||
await GET(req, routeParams)
|
||||
|
||||
expect(mockGetAuditLog).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
@@ -101,14 +125,15 @@ describe('GET /api/audit-trail', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('returns 500 on service error', async () => {
|
||||
mockAuth('user-1')
|
||||
it('returns the canonical error envelope on service failure', async () => {
|
||||
mockGetAuditLog.mockRejectedValue(new Error('DB error'))
|
||||
|
||||
const req = createMockRequest('/api/audit-trail')
|
||||
const { status, body } = await parseJsonResponse(await GET(req))
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(
|
||||
await GET(req, routeParams)
|
||||
)
|
||||
|
||||
expect(status).toBe(500)
|
||||
expect(body).toEqual({ error: 'DB error' })
|
||||
expect(body.error.code).toBe('INTERNAL_ERROR')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,38 +1,30 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { validateQuery } from '@/lib/api/validate'
|
||||
import { AuditTrailQuerySchema } from '@/lib/api/schemas'
|
||||
import { getAuditLog } from '@/lib/core/audit/audit-service'
|
||||
import type { AuditAction } from '@/types'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
// GET /api/audit-trail — paginated audit log for the active company.
|
||||
// The audit log is written exclusively by SECURITY DEFINER triggers; this
|
||||
// endpoint is read-only.
|
||||
export const GET = withRouteContext(
|
||||
'audit_trail.list',
|
||||
async (request, ctx) => {
|
||||
const { supabase, companyId, log } = ctx
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
const query = validateQuery(request, AuditTrailQuerySchema, {
|
||||
log,
|
||||
operation: 'audit_trail.list',
|
||||
})
|
||||
if (!query.success) return query.response
|
||||
const { page, page_size, ...filters } = query.data
|
||||
|
||||
const companyId = await requireCompanyId(supabase, user.id)
|
||||
const result = await getAuditLog(supabase, companyId, {
|
||||
...filters,
|
||||
page,
|
||||
pageSize: page_size,
|
||||
})
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
|
||||
const filters = {
|
||||
action: (searchParams.get('action') as AuditAction) || undefined,
|
||||
table_name: searchParams.get('table_name') || undefined,
|
||||
record_id: searchParams.get('record_id') || undefined,
|
||||
from_date: searchParams.get('from_date') || undefined,
|
||||
to_date: searchParams.get('to_date') || undefined,
|
||||
page: searchParams.has('page') ? Number(searchParams.get('page')) : undefined,
|
||||
pageSize: searchParams.has('page_size') ? Number(searchParams.get('page_size')) : undefined,
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await getAuditLog(supabase, companyId, filters)
|
||||
return NextResponse.json({ data: result.data, count: result.count })
|
||||
} catch (err) {
|
||||
return NextResponse.json(
|
||||
{ error: err instanceof Error ? err.message : 'Failed to fetch audit log' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* Tests for POST /api/billing/checkout.
|
||||
*
|
||||
* Exercises the route through the real withRouteContext wrapper, mocking
|
||||
* auth/company, the Stripe client, and the service-role Supabase client.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { createQueuedMockSupabase, createMockRequest, parseJsonResponse } from '@/tests/helpers'
|
||||
|
||||
const { supabase: serviceSupabase, enqueue, reset } = createQueuedMockSupabase()
|
||||
|
||||
const requireAuthMock = vi.fn()
|
||||
vi.mock('@/lib/auth/require-auth', () => ({
|
||||
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/company/context', () => ({
|
||||
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/supabase/server', () => ({
|
||||
createServiceClient: () => serviceSupabase,
|
||||
}))
|
||||
|
||||
const customersCreate = vi.fn()
|
||||
const sessionsCreate = vi.fn()
|
||||
vi.mock('@/lib/stripe/client', () => ({
|
||||
getStripe: () => ({
|
||||
customers: { create: customersCreate },
|
||||
checkout: { sessions: { create: sessionsCreate } },
|
||||
}),
|
||||
priceIdForPlan: vi.fn().mockReturnValue('price_123'),
|
||||
}))
|
||||
|
||||
import { POST } from '../checkout/route'
|
||||
|
||||
const routeParams = { params: Promise.resolve({}) }
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
requireAuthMock.mockResolvedValue({
|
||||
user: { id: 'user-1', email: 'u@example.com' },
|
||||
supabase: {},
|
||||
error: null,
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /api/billing/checkout', () => {
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
requireAuthMock.mockResolvedValue({
|
||||
user: null,
|
||||
supabase: {},
|
||||
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
|
||||
})
|
||||
|
||||
const req = createMockRequest('/api/billing/checkout', { method: 'POST', body: {} })
|
||||
const res = await POST(req, routeParams)
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
it('rejects an unknown plan with 400', async () => {
|
||||
const req = createMockRequest('/api/billing/checkout', {
|
||||
method: 'POST',
|
||||
body: { plan: 'weekly' },
|
||||
})
|
||||
|
||||
const { status } = await parseJsonResponse(await POST(req, routeParams))
|
||||
|
||||
expect(status).toBe(400)
|
||||
expect(sessionsCreate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reuses an existing Stripe customer and returns the checkout URL', async () => {
|
||||
enqueue({ data: { stripe_customer_id: 'cus_existing' } })
|
||||
sessionsCreate.mockResolvedValue({ url: 'https://stripe.test/session' })
|
||||
|
||||
const req = createMockRequest('/api/billing/checkout', {
|
||||
method: 'POST',
|
||||
body: { plan: 'yearly' },
|
||||
})
|
||||
|
||||
const { status, body } = await parseJsonResponse<{ url: string }>(await POST(req, routeParams))
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.url).toBe('https://stripe.test/session')
|
||||
expect(customersCreate).not.toHaveBeenCalled()
|
||||
expect(sessionsCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
customer: 'cus_existing',
|
||||
client_reference_id: 'company-1',
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('creates a Stripe customer when none exists yet', async () => {
|
||||
enqueue({ data: null }) // no existing subscription row
|
||||
enqueue({ data: null }) // upsert result
|
||||
customersCreate.mockResolvedValue({ id: 'cus_new' })
|
||||
sessionsCreate.mockResolvedValue({ url: 'https://stripe.test/session' })
|
||||
|
||||
const req = createMockRequest('/api/billing/checkout', { method: 'POST', body: {} })
|
||||
const { status, body } = await parseJsonResponse<{ url: string }>(await POST(req, routeParams))
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.url).toBe('https://stripe.test/session')
|
||||
expect(customersCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ metadata: { company_id: 'company-1' } })
|
||||
)
|
||||
expect(sessionsCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ customer: 'cus_new' })
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Tests for POST /api/billing/portal.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { createQueuedMockSupabase, createMockRequest, parseJsonResponse } from '@/tests/helpers'
|
||||
|
||||
const { supabase: serviceSupabase, enqueue, reset } = createQueuedMockSupabase()
|
||||
|
||||
const requireAuthMock = vi.fn()
|
||||
vi.mock('@/lib/auth/require-auth', () => ({
|
||||
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/company/context', () => ({
|
||||
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/supabase/server', () => ({
|
||||
createServiceClient: () => serviceSupabase,
|
||||
}))
|
||||
|
||||
const portalCreate = vi.fn()
|
||||
vi.mock('@/lib/stripe/client', () => ({
|
||||
getStripe: () => ({
|
||||
billingPortal: { sessions: { create: portalCreate } },
|
||||
}),
|
||||
}))
|
||||
|
||||
import { POST } from '../portal/route'
|
||||
|
||||
const routeParams = { params: Promise.resolve({}) }
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase: {}, error: null })
|
||||
})
|
||||
|
||||
describe('POST /api/billing/portal', () => {
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
requireAuthMock.mockResolvedValue({
|
||||
user: null,
|
||||
supabase: {},
|
||||
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
|
||||
})
|
||||
|
||||
const req = createMockRequest('/api/billing/portal', { method: 'POST', body: {} })
|
||||
const res = await POST(req, routeParams)
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
it('returns 400 with NO_SUBSCRIPTION when the company has no Stripe customer', async () => {
|
||||
enqueue({ data: null })
|
||||
|
||||
const req = createMockRequest('/api/billing/portal', { method: 'POST', body: {} })
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(
|
||||
await POST(req, routeParams)
|
||||
)
|
||||
|
||||
expect(status).toBe(400)
|
||||
expect(body.error.code).toBe('NO_SUBSCRIPTION')
|
||||
expect(portalCreate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns the portal URL for a company with a Stripe customer', async () => {
|
||||
enqueue({ data: { stripe_customer_id: 'cus_1' } })
|
||||
portalCreate.mockResolvedValue({ url: 'https://stripe.test/portal' })
|
||||
|
||||
const req = createMockRequest('/api/billing/portal', { method: 'POST', body: {} })
|
||||
const { status, body } = await parseJsonResponse<{ url: string }>(await POST(req, routeParams))
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.url).toBe('https://stripe.test/portal')
|
||||
expect(portalCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ customer: 'cus_1' })
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,30 +1,45 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { requireAuth } from '@/lib/auth/require-auth'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { z } from 'zod'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { createServiceClient } from '@/lib/supabase/server'
|
||||
import { getStripe, priceIdForPlan, type BillingPlan } from '@/lib/stripe/client'
|
||||
import { getStripe, priceIdForPlan } from '@/lib/stripe/client'
|
||||
|
||||
const CheckoutSchema = z.object({
|
||||
plan: z.enum(['monthly', 'yearly']).default('monthly'),
|
||||
})
|
||||
|
||||
/**
|
||||
* Create a Stripe subscription Checkout Session and return its hosted URL.
|
||||
* The client redirects to it; provisioning happens via the webhook on
|
||||
* checkout.session.completed (never trust the success redirect for fulfilment).
|
||||
*
|
||||
* company_subscriptions is read/written via the service client on purpose —
|
||||
* the row is webhook-owned and not member-readable under RLS; every query
|
||||
* still filters by the membership-validated companyId.
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
const { user, supabase, error } = await requireAuth()
|
||||
if (error) return error
|
||||
export const POST = withRouteContext('billing.checkout', async (request, ctx) => {
|
||||
const { user, companyId, log } = ctx
|
||||
|
||||
let companyId: string
|
||||
try {
|
||||
companyId = await requireCompanyId(supabase, user.id)
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'No company context' }, { status: 400 })
|
||||
}
|
||||
const validation = await validateBody(request, CheckoutSchema, {
|
||||
log,
|
||||
operation: 'billing.checkout',
|
||||
})
|
||||
if (!validation.success) return validation.response
|
||||
const { plan } = validation.data
|
||||
|
||||
const body = (await request.json().catch(() => ({}))) as { plan?: string }
|
||||
const plan: BillingPlan = body.plan === 'yearly' ? 'yearly' : 'monthly'
|
||||
const priceId = priceIdForPlan(plan)
|
||||
if (!priceId) {
|
||||
return NextResponse.json({ error: 'Stripe price not configured' }, { status: 500 })
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: {
|
||||
code: 'STRIPE_NOT_CONFIGURED',
|
||||
message: 'Betalning är inte konfigurerad. Kontakta supporten.',
|
||||
message_en: 'Stripe price not configured.',
|
||||
},
|
||||
},
|
||||
{ status: 500 },
|
||||
)
|
||||
}
|
||||
|
||||
const stripe = getStripe()
|
||||
@@ -63,4 +78,4 @@ export async function POST(request: Request) {
|
||||
})
|
||||
|
||||
return NextResponse.json({ url: session.url })
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { requireAuth } from '@/lib/auth/require-auth'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { createServiceClient } from '@/lib/supabase/server'
|
||||
import { getStripe } from '@/lib/stripe/client'
|
||||
|
||||
@@ -8,17 +7,13 @@ import { getStripe } from '@/lib/stripe/client'
|
||||
* Create a Stripe Billing Customer Portal session so the user can manage,
|
||||
* upgrade/downgrade, or cancel their subscription. Stripe handles all the
|
||||
* compliance/PCI surface: we never build those flows ourselves.
|
||||
*
|
||||
* company_subscriptions is read via the service client on purpose: the row
|
||||
* is webhook-owned and not member-readable under RLS; the query still filters
|
||||
* by the membership-validated companyId.
|
||||
*/
|
||||
export async function POST() {
|
||||
const { user, supabase, error } = await requireAuth()
|
||||
if (error) return error
|
||||
|
||||
let companyId: string
|
||||
try {
|
||||
companyId = await requireCompanyId(supabase, user.id)
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'No company context' }, { status: 400 })
|
||||
}
|
||||
export const POST = withRouteContext('billing.portal', async (_request, ctx) => {
|
||||
const { companyId } = ctx
|
||||
|
||||
const service = createServiceClient()
|
||||
const { data: sub } = await service
|
||||
@@ -29,7 +24,16 @@ export async function POST() {
|
||||
|
||||
const customerId = (sub as { stripe_customer_id: string | null } | null)?.stripe_customer_id
|
||||
if (!customerId) {
|
||||
return NextResponse.json({ error: 'No subscription to manage' }, { status: 400 })
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: {
|
||||
code: 'NO_SUBSCRIPTION',
|
||||
message: 'Det finns inget abonnemang att hantera.',
|
||||
message_en: 'No subscription to manage.',
|
||||
},
|
||||
},
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
|
||||
const appUrl = process.env.NEXT_PUBLIC_APP_URL ?? ''
|
||||
@@ -39,4 +43,4 @@ export async function POST() {
|
||||
})
|
||||
|
||||
return NextResponse.json({ url: portal.url })
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { validateQuery } from '@/lib/api/validate'
|
||||
import { AccountBalancesQuerySchema } from '@/lib/api/schemas'
|
||||
import { getOpeningBalances } from '@/lib/reports/opening-balances'
|
||||
import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
|
||||
const log = createLogger('api.bookkeeping.account-balances')
|
||||
|
||||
/**
|
||||
* Per-account saldo as of a date. Used by the journal-entry form to show
|
||||
@@ -24,20 +20,16 @@ const log = createLogger('api.bookkeeping.account-balances')
|
||||
* companies behave identically. The opening-balance entry is excluded from
|
||||
* period activity to avoid double-counting its lines.
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
export const GET = withRouteContext('bookkeeping.account_balances', async (request, ctx) => {
|
||||
const { supabase, companyId, log } = ctx
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const params = validateQuery(request, AccountBalancesQuerySchema)
|
||||
const params = validateQuery(request, AccountBalancesQuerySchema, {
|
||||
log,
|
||||
operation: 'bookkeeping.account_balances',
|
||||
})
|
||||
if (!params.success) return params.response
|
||||
const { accounts, as_of } = params.data
|
||||
|
||||
const companyId = await requireCompanyId(supabase, user.id)
|
||||
|
||||
// Find the fiscal period containing as_of (any state: we want a reference
|
||||
// saldo even for closed/locked periods).
|
||||
const { data: period, error: periodError } = await supabase
|
||||
@@ -159,4 +151,4 @@ export async function GET(request: Request) {
|
||||
}
|
||||
}),
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,59 +1,57 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { z } from 'zod'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { validateQuery } from '@/lib/api/validate'
|
||||
import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
// GET /api/bookkeeping/account-totals?from=3000&to=3999[&date_from=..&date_to=..&group_by=month]
|
||||
//
|
||||
// Sums posted debit/credit per account in an account-number range, optionally
|
||||
// bucketed by month. Both the entry list and the per-batch line fetches are
|
||||
// paginated — PostgREST caps unpaginated selects at 1000 rows, which would
|
||||
// silently under-count totals for companies with large journals.
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
const QuerySchema = z.object({
|
||||
from: z.string().regex(/^\d{4}$/, 'from must be a 4-digit account number'),
|
||||
to: z.string().regex(/^\d{4}$/, 'to must be a 4-digit account number'),
|
||||
date_from: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
|
||||
date_to: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
|
||||
group_by: z.enum(['month']).optional(),
|
||||
})
|
||||
|
||||
const companyId = await requireCompanyId(supabase, user.id)
|
||||
export const GET = withRouteContext('bookkeeping.account_totals', async (request, ctx) => {
|
||||
const { supabase, companyId, log } = ctx
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const from = searchParams.get('from')
|
||||
const to = searchParams.get('to')
|
||||
const dateFrom = searchParams.get('date_from')
|
||||
const dateTo = searchParams.get('date_to')
|
||||
const groupBy = searchParams.get('group_by')
|
||||
const validated = validateQuery(request, QuerySchema, {
|
||||
log,
|
||||
operation: 'bookkeeping.account_totals',
|
||||
})
|
||||
if (!validated.success) return validated.response
|
||||
const { from, to, date_from: dateFrom, date_to: dateTo, group_by: groupBy } = validated.data
|
||||
|
||||
if (!from || !to) {
|
||||
return NextResponse.json(
|
||||
{ error: 'from and to account numbers are required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
// Posted entries in range — paginated (large journals exceed 1000 entries).
|
||||
const entries = await fetchAllRows<{ id: string; entry_date: string }>(({ from: f, to: t }) => {
|
||||
let query = supabase
|
||||
.from('journal_entries')
|
||||
.select('id, entry_date')
|
||||
.eq('company_id', companyId)
|
||||
.eq('status', 'posted')
|
||||
|
||||
// Get posted journal entries within date range
|
||||
let entriesQuery = supabase
|
||||
.from('journal_entries')
|
||||
.select('id, entry_date')
|
||||
.eq('company_id', companyId)
|
||||
.eq('status', 'posted')
|
||||
if (dateFrom) query = query.gte('entry_date', dateFrom)
|
||||
if (dateTo) query = query.lte('entry_date', dateTo)
|
||||
|
||||
if (dateFrom) {
|
||||
entriesQuery = entriesQuery.gte('entry_date', dateFrom)
|
||||
}
|
||||
if (dateTo) {
|
||||
entriesQuery = entriesQuery.lte('entry_date', dateTo)
|
||||
}
|
||||
return query.order('id', { ascending: true }).range(f, t)
|
||||
})
|
||||
|
||||
const { data: entries, error: entriesError } = await entriesQuery
|
||||
|
||||
if (entriesError) {
|
||||
return NextResponse.json({ error: entriesError.message }, { status: 500 })
|
||||
}
|
||||
|
||||
if (!entries || entries.length === 0) {
|
||||
if (entries.length === 0) {
|
||||
return NextResponse.json({ totals: [], monthly: groupBy === 'month' ? [] : undefined })
|
||||
}
|
||||
|
||||
const entryIds = entries.map((e) => e.id)
|
||||
const entryDateMap = new Map(entries.map((e) => [e.id, e.entry_date]))
|
||||
|
||||
// Fetch lines in batches to avoid URL length limits
|
||||
// Fetch lines in id-batches to avoid URL length limits; each batch is
|
||||
// itself paginated (200 entries can easily carry >1000 lines).
|
||||
const batchSize = 200
|
||||
const allLines: Array<{
|
||||
journal_entry_id: string
|
||||
@@ -64,19 +62,22 @@ export async function GET(request: Request) {
|
||||
|
||||
for (let i = 0; i < entryIds.length; i += batchSize) {
|
||||
const batch = entryIds.slice(i, i + batchSize)
|
||||
const { data: lines, error: linesError } = await supabase
|
||||
.from('journal_entry_lines')
|
||||
.select('journal_entry_id, account_number, debit_amount, credit_amount')
|
||||
.in('journal_entry_id', batch)
|
||||
.gte('account_number', from)
|
||||
.lte('account_number', to)
|
||||
|
||||
if (linesError) {
|
||||
return NextResponse.json({ error: linesError.message }, { status: 500 })
|
||||
}
|
||||
if (lines) {
|
||||
allLines.push(...lines)
|
||||
}
|
||||
const lines = await fetchAllRows<{
|
||||
journal_entry_id: string
|
||||
account_number: string
|
||||
debit_amount: number
|
||||
credit_amount: number
|
||||
}>(({ from: f, to: t }) =>
|
||||
supabase
|
||||
.from('journal_entry_lines')
|
||||
.select('journal_entry_id, account_number, debit_amount, credit_amount')
|
||||
.in('journal_entry_id', batch)
|
||||
.gte('account_number', from)
|
||||
.lte('account_number', to)
|
||||
.order('id', { ascending: true })
|
||||
.range(f, t)
|
||||
)
|
||||
allLines.push(...lines)
|
||||
}
|
||||
|
||||
// Aggregate by account
|
||||
@@ -132,4 +133,4 @@ export async function GET(request: Request) {
|
||||
}
|
||||
|
||||
return NextResponse.json({ totals })
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,104 +1,104 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { UpdateAccountSchema } from '@/lib/api/schemas'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
|
||||
export async function DELETE(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ number: string }> }
|
||||
) {
|
||||
const { number } = await params
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
// DELETE hard-deletes an unused, non-system account; accounts referenced by
|
||||
// this company's journal entries must be deactivated instead (PUT is_active).
|
||||
// Response shapes are legacy `{ error: string }` — the kontoplan UI renders
|
||||
// them directly.
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
export const DELETE = withRouteContext(
|
||||
'bookkeeping.accounts.delete',
|
||||
async (_request, ctx, { params }: { params: Promise<{ number: string }> }) => {
|
||||
const { number } = await params
|
||||
const { supabase, companyId } = ctx
|
||||
|
||||
const writeCheck = await requireWritePermission(supabase, user.id)
|
||||
if (!writeCheck.ok) return writeCheck.response
|
||||
// Fetch the account to check if it's a system account
|
||||
const { data: account, error: fetchError } = await supabase
|
||||
.from('chart_of_accounts')
|
||||
.select('id, is_system_account')
|
||||
.eq('company_id', companyId)
|
||||
.eq('account_number', number)
|
||||
.single()
|
||||
|
||||
const companyId = await requireCompanyId(supabase, user.id)
|
||||
if (fetchError || !account) {
|
||||
return NextResponse.json({ error: 'Kontot hittades inte' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Fetch the account to check if it's a system account
|
||||
const { data: account, error: fetchError } = await supabase
|
||||
.from('chart_of_accounts')
|
||||
.select('id, is_system_account')
|
||||
.eq('company_id', companyId)
|
||||
.eq('account_number', number)
|
||||
.single()
|
||||
if (account.is_system_account) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Systemkonton kan inte tas bort' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
if (fetchError || !account) {
|
||||
return NextResponse.json({ error: 'Kontot hittades inte' }, { status: 404 })
|
||||
}
|
||||
// Check if the account is referenced in THIS company's journal entries.
|
||||
// journal_entry_lines has no company_id column, so scope via the parent
|
||||
// entry — a user can be a member of several companies, and another
|
||||
// company's usage of the same BAS number must not block deletion here.
|
||||
const { count } = await supabase
|
||||
.from('journal_entry_lines')
|
||||
.select('id, journal_entries!inner(company_id)', { count: 'exact', head: true })
|
||||
.eq('journal_entries.company_id', companyId)
|
||||
.eq('account_number', number)
|
||||
|
||||
if (account.is_system_account) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Systemkonton kan inte tas bort' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
if (count && count > 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Kontot kan inte tas bort eftersom det används i bokförda verifikationer. Inaktivera det istället.' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Check if account is referenced in posted journal entries
|
||||
const { count } = await supabase
|
||||
.from('journal_entry_lines')
|
||||
.select('id', { count: 'exact', head: true })
|
||||
.eq('account_number', number)
|
||||
const { error: deleteError } = await supabase
|
||||
.from('chart_of_accounts')
|
||||
.delete()
|
||||
.eq('id', account.id)
|
||||
.eq('company_id', companyId)
|
||||
|
||||
if (count && count > 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Kontot kan inte tas bort eftersom det används i bokförda verifikationer. Inaktivera det istället.' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
if (deleteError) {
|
||||
return NextResponse.json({ error: deleteError.message }, { status: 500 })
|
||||
}
|
||||
|
||||
const { error: deleteError } = await supabase
|
||||
.from('chart_of_accounts')
|
||||
.delete()
|
||||
.eq('id', account.id)
|
||||
.eq('company_id', companyId)
|
||||
return NextResponse.json({ success: true })
|
||||
},
|
||||
{ requireWrite: true },
|
||||
)
|
||||
|
||||
if (deleteError) {
|
||||
return NextResponse.json({ error: deleteError.message }, { status: 500 })
|
||||
}
|
||||
export const PUT = withRouteContext(
|
||||
'bookkeeping.accounts.update',
|
||||
async (request, ctx, { params }: { params: Promise<{ number: string }> }) => {
|
||||
const { number } = await params
|
||||
const { supabase, companyId, log } = ctx
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
const validation = await validateBody(request, UpdateAccountSchema, {
|
||||
log,
|
||||
operation: 'bookkeeping.accounts.update',
|
||||
})
|
||||
if (!validation.success) return validation.response
|
||||
const body = validation.data
|
||||
|
||||
export async function PUT(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ number: string }> }
|
||||
) {
|
||||
const { number } = await params
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (Object.keys(body).length === 0) {
|
||||
return NextResponse.json({ error: 'Inget att uppdatera' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
const { data, error } = await supabase
|
||||
.from('chart_of_accounts')
|
||||
.update(body)
|
||||
.eq('company_id', companyId)
|
||||
.eq('account_number', number)
|
||||
.select()
|
||||
.single()
|
||||
|
||||
const writeCheck = await requireWritePermission(supabase, user.id)
|
||||
if (!writeCheck.ok) return writeCheck.response
|
||||
if (error) {
|
||||
// PGRST116 = zero rows — the account doesn't exist in this company.
|
||||
if (error.code === 'PGRST116') {
|
||||
return NextResponse.json({ error: 'Kontot hittades inte' }, { status: 404 })
|
||||
}
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
const companyId = await requireCompanyId(supabase, user.id)
|
||||
|
||||
const validation = await validateBody(request, UpdateAccountSchema)
|
||||
if (!validation.success) return validation.response
|
||||
const body = validation.data
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('chart_of_accounts')
|
||||
.update(body)
|
||||
.eq('company_id', companyId)
|
||||
.eq('account_number', number)
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
return NextResponse.json({ data })
|
||||
},
|
||||
{ requireWrite: true },
|
||||
)
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
/**
|
||||
* Tests for /api/bookkeeping/accounts (list/create), /[number] (update/delete)
|
||||
* and /activate.
|
||||
*
|
||||
* The DELETE usage check is asserted with a call-capturing mock: the count
|
||||
* query must be scoped to the caller's company via the journal_entries join —
|
||||
* without it, another company's use of the same BAS number (same user,
|
||||
* multiple memberships under RLS) wrongly blocks deletion.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { createMockRequest, parseJsonResponse } from '@/tests/helpers'
|
||||
|
||||
const requireAuthMock = vi.fn()
|
||||
vi.mock('@/lib/auth/require-auth', () => ({
|
||||
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/company/context', () => ({
|
||||
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
}))
|
||||
|
||||
const requireWriteMock = vi.fn()
|
||||
vi.mock('@/lib/auth/require-write', () => ({
|
||||
requireWritePermission: (...args: unknown[]) => requireWriteMock(...args),
|
||||
}))
|
||||
|
||||
import { GET as listGET, POST as createPOST } from '../route'
|
||||
import { DELETE, PUT } from '../[number]/route'
|
||||
import { POST as activatePOST } from '../activate/route'
|
||||
|
||||
interface CapturedCall {
|
||||
method: string
|
||||
args: unknown[]
|
||||
}
|
||||
|
||||
/** Chainable builder recording calls; resolves queued {data,error,count} per from(). */
|
||||
function createCapturingSupabase(
|
||||
results: { data?: unknown; error?: unknown; count?: number | null }[]
|
||||
) {
|
||||
const calls: CapturedCall[] = []
|
||||
let idx = 0
|
||||
const makeBuilder = () => {
|
||||
const result = results[idx++] ?? { data: null, error: null, count: null }
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const b: any = {}
|
||||
for (const m of ['select', 'eq', 'in', 'gte', 'lte', 'is', 'order', 'limit', 'range', 'insert', 'update', 'delete', 'maybeSingle', 'single']) {
|
||||
b[m] = (...args: unknown[]) => {
|
||||
calls.push({ method: m, args })
|
||||
return b
|
||||
}
|
||||
}
|
||||
b.then = (resolve: (v: unknown) => void) =>
|
||||
resolve({ data: result.data ?? null, error: result.error ?? null, count: result.count ?? null })
|
||||
return b
|
||||
}
|
||||
const supabase = {
|
||||
from: (table: string) => {
|
||||
calls.push({ method: 'from', args: [table] })
|
||||
return makeBuilder()
|
||||
},
|
||||
}
|
||||
return { supabase, calls }
|
||||
}
|
||||
|
||||
const routeParams = { params: Promise.resolve({}) }
|
||||
const numberParams = { params: Promise.resolve({ number: '5010' }) }
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
requireWriteMock.mockResolvedValue({ ok: true })
|
||||
})
|
||||
|
||||
function auth(supabase: unknown) {
|
||||
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase, error: null })
|
||||
}
|
||||
|
||||
describe('GET /api/bookkeeping/accounts', () => {
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
requireAuthMock.mockResolvedValue({
|
||||
user: null,
|
||||
supabase: {},
|
||||
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
|
||||
})
|
||||
const res = await listGET(createMockRequest('/api/bookkeeping/accounts'), routeParams)
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
it('returns 400 for a non-numeric class filter', async () => {
|
||||
const { supabase } = createCapturingSupabase([])
|
||||
auth(supabase)
|
||||
const req = createMockRequest('/api/bookkeeping/accounts', { searchParams: { class: 'abc' } })
|
||||
const { status } = await parseJsonResponse(await listGET(req, routeParams))
|
||||
expect(status).toBe(400)
|
||||
})
|
||||
|
||||
it('lists accounts for the company', async () => {
|
||||
const { supabase, calls } = createCapturingSupabase([
|
||||
{ data: [{ account_number: '1930', account_name: 'Företagskonto' }] },
|
||||
])
|
||||
auth(supabase)
|
||||
const { status, body } = await parseJsonResponse<{ data: unknown[] }>(
|
||||
await listGET(createMockRequest('/api/bookkeeping/accounts'), routeParams)
|
||||
)
|
||||
expect(status).toBe(200)
|
||||
expect(body.data).toHaveLength(1)
|
||||
expect(calls.filter((c) => c.method === 'eq').map((c) => c.args)).toContainEqual([
|
||||
'company_id',
|
||||
'company-1',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /api/bookkeeping/accounts', () => {
|
||||
it('returns 409 with a Swedish message on duplicate account number', async () => {
|
||||
const { supabase } = createCapturingSupabase([{ error: { code: '23505', message: 'dup' } }])
|
||||
auth(supabase)
|
||||
const req = createMockRequest('/api/bookkeeping/accounts', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
account_number: '5010',
|
||||
account_name: 'Lokalhyra',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
},
|
||||
})
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(
|
||||
await createPOST(req, routeParams)
|
||||
)
|
||||
expect(status).toBe(409)
|
||||
expect(body.error).toContain('5010')
|
||||
})
|
||||
})
|
||||
|
||||
describe('DELETE /api/bookkeeping/accounts/[number]', () => {
|
||||
it('scopes the usage check to the company via the journal_entries join', async () => {
|
||||
const { supabase, calls } = createCapturingSupabase([
|
||||
{ data: { id: 'acc-1', is_system_account: false } }, // account fetch
|
||||
{ count: 0 }, // usage count
|
||||
{ data: null }, // delete
|
||||
])
|
||||
auth(supabase)
|
||||
|
||||
const { status } = await parseJsonResponse(
|
||||
await DELETE(createMockRequest('/api/bookkeeping/accounts/5010'), numberParams)
|
||||
)
|
||||
|
||||
expect(status).toBe(200)
|
||||
const selectArgs = calls.filter((c) => c.method === 'select').map((c) => c.args[0])
|
||||
expect(selectArgs).toContain('id, journal_entries!inner(company_id)')
|
||||
const eqCalls = calls.filter((c) => c.method === 'eq').map((c) => c.args)
|
||||
expect(eqCalls).toContainEqual(['journal_entries.company_id', 'company-1'])
|
||||
})
|
||||
|
||||
it('refuses deleting an account used in this company with 400', async () => {
|
||||
const { supabase } = createCapturingSupabase([
|
||||
{ data: { id: 'acc-1', is_system_account: false } },
|
||||
{ count: 3 },
|
||||
])
|
||||
auth(supabase)
|
||||
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(
|
||||
await DELETE(createMockRequest('/api/bookkeeping/accounts/5010'), numberParams)
|
||||
)
|
||||
expect(status).toBe(400)
|
||||
expect(body.error).toContain('Inaktivera')
|
||||
})
|
||||
|
||||
it('refuses deleting a system account', async () => {
|
||||
const { supabase } = createCapturingSupabase([
|
||||
{ data: { id: 'acc-1', is_system_account: true } },
|
||||
])
|
||||
auth(supabase)
|
||||
|
||||
const { status } = await parseJsonResponse(
|
||||
await DELETE(createMockRequest('/api/bookkeeping/accounts/5010'), numberParams)
|
||||
)
|
||||
expect(status).toBe(400)
|
||||
})
|
||||
})
|
||||
|
||||
describe('PUT /api/bookkeeping/accounts/[number]', () => {
|
||||
it('returns 400 when the body has nothing to update', async () => {
|
||||
const { supabase } = createCapturingSupabase([])
|
||||
auth(supabase)
|
||||
const req = createMockRequest('/api/bookkeeping/accounts/5010', { method: 'PUT', body: {} })
|
||||
const { status } = await parseJsonResponse(await PUT(req, numberParams))
|
||||
expect(status).toBe(400)
|
||||
})
|
||||
|
||||
it('maps zero-rows (PGRST116) to 404', async () => {
|
||||
const { supabase } = createCapturingSupabase([
|
||||
{ error: { code: 'PGRST116', message: 'no rows' } },
|
||||
])
|
||||
auth(supabase)
|
||||
const req = createMockRequest('/api/bookkeeping/accounts/5010', {
|
||||
method: 'PUT',
|
||||
body: { account_name: 'Nytt namn' },
|
||||
})
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(await PUT(req, numberParams))
|
||||
expect(status).toBe(404)
|
||||
expect(body.error).toBe('Kontot hittades inte')
|
||||
})
|
||||
|
||||
it('updates the account', async () => {
|
||||
const { supabase } = createCapturingSupabase([
|
||||
{ data: { account_number: '5010', account_name: 'Nytt namn' } },
|
||||
])
|
||||
auth(supabase)
|
||||
const req = createMockRequest('/api/bookkeeping/accounts/5010', {
|
||||
method: 'PUT',
|
||||
body: { account_name: 'Nytt namn' },
|
||||
})
|
||||
const { status, body } = await parseJsonResponse<{ data: { account_name: string } }>(
|
||||
await PUT(req, numberParams)
|
||||
)
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.account_name).toBe('Nytt namn')
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /api/bookkeeping/accounts/activate', () => {
|
||||
it('returns 400 (not a crash) on invalid JSON', async () => {
|
||||
const { supabase } = createCapturingSupabase([])
|
||||
auth(supabase)
|
||||
const req = new Request('http://localhost/api/bookkeeping/accounts/activate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: '{not json',
|
||||
})
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(
|
||||
await activatePOST(req, routeParams)
|
||||
)
|
||||
expect(status).toBe(400)
|
||||
expect(body.error).toBe('account_numbers array required')
|
||||
})
|
||||
|
||||
it('returns 400 when account_numbers is missing or empty', async () => {
|
||||
const { supabase } = createCapturingSupabase([])
|
||||
auth(supabase)
|
||||
const req = createMockRequest('/api/bookkeeping/accounts/activate', {
|
||||
method: 'POST',
|
||||
body: { account_numbers: [] },
|
||||
})
|
||||
const { status } = await parseJsonResponse(await activatePOST(req, routeParams))
|
||||
expect(status).toBe(400)
|
||||
})
|
||||
|
||||
it('activates a known BAS account and buckets unknown numbers', async () => {
|
||||
const { supabase } = createCapturingSupabase([
|
||||
{ data: [] }, // existing lookup — none in chart
|
||||
{ data: [{ account_number: '1930' }] }, // insert result
|
||||
])
|
||||
auth(supabase)
|
||||
const req = createMockRequest('/api/bookkeeping/accounts/activate', {
|
||||
method: 'POST',
|
||||
body: { account_numbers: ['1930', '0000'] },
|
||||
})
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
activated: number
|
||||
unknown: string[]
|
||||
}>(await activatePOST(req, routeParams))
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.activated).toBe(1)
|
||||
expect(body.unknown).toEqual(['0000'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,257 @@
|
||||
/**
|
||||
* Tests for /api/bookkeeping/accounts/usage and /prune.
|
||||
*
|
||||
* The prune execute phase is the safety-critical part: the client's
|
||||
* account_numbers list is a selection, not an authority. The tests assert
|
||||
* that used accounts, system accounts, and unknown numbers sent by the
|
||||
* client are skipped/reported — only freshly re-verified unused accounts
|
||||
* reach the DELETE.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { createMockRequest, parseJsonResponse } from '@/tests/helpers'
|
||||
|
||||
const requireAuthMock = vi.fn()
|
||||
vi.mock('@/lib/auth/require-auth', () => ({
|
||||
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/company/context', () => ({
|
||||
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
}))
|
||||
|
||||
const requireWriteMock = vi.fn()
|
||||
vi.mock('@/lib/auth/require-write', () => ({
|
||||
requireWritePermission: (...args: unknown[]) => requireWriteMock(...args),
|
||||
}))
|
||||
|
||||
import { GET as usageGET } from '../usage/route'
|
||||
import { POST as prunePOST } from '../prune/route'
|
||||
|
||||
interface CapturedCall {
|
||||
method: string
|
||||
args: unknown[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Chainable builder recording calls; resolves queued {data,error} per
|
||||
* from()/rpc() invocation, in call order.
|
||||
*/
|
||||
function createCapturingSupabase(results: { data?: unknown; error?: unknown }[]) {
|
||||
const calls: CapturedCall[] = []
|
||||
let idx = 0
|
||||
const nextResult = () => results[idx++] ?? { data: null, error: null }
|
||||
const makeBuilder = () => {
|
||||
const result = nextResult()
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const b: any = {}
|
||||
for (const m of ['select', 'eq', 'in', 'order', 'range', 'insert', 'update', 'delete']) {
|
||||
b[m] = (...args: unknown[]) => {
|
||||
calls.push({ method: m, args })
|
||||
return b
|
||||
}
|
||||
}
|
||||
b.then = (resolve: (v: unknown) => void) =>
|
||||
resolve({ data: result.data ?? null, error: result.error ?? null })
|
||||
return b
|
||||
}
|
||||
const supabase = {
|
||||
from: (table: string) => {
|
||||
calls.push({ method: 'from', args: [table] })
|
||||
return makeBuilder()
|
||||
},
|
||||
rpc: (fn: string, params: unknown) => {
|
||||
calls.push({ method: 'rpc', args: [fn, params] })
|
||||
const result = nextResult()
|
||||
return Promise.resolve({ data: result.data ?? null, error: result.error ?? null })
|
||||
},
|
||||
}
|
||||
return { supabase, calls }
|
||||
}
|
||||
|
||||
const routeParams = { params: Promise.resolve({}) }
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
requireWriteMock.mockResolvedValue({ ok: true })
|
||||
})
|
||||
|
||||
function auth(supabase: unknown) {
|
||||
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase, error: null })
|
||||
}
|
||||
|
||||
function unauthenticated() {
|
||||
requireAuthMock.mockResolvedValue({
|
||||
user: null,
|
||||
supabase: {},
|
||||
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
|
||||
})
|
||||
}
|
||||
|
||||
// Chart fixture: one system account, one used BAS account, one unused BAS
|
||||
// account from the seed, one unused imported custom account.
|
||||
const chartAccounts = [
|
||||
{ account_number: '1930', account_name: 'Företagskonto', account_class: 1, plan_type: 'k1', is_active: true, is_system_account: true },
|
||||
{ account_number: '3001', account_name: 'Försäljning', account_class: 3, plan_type: 'k1', is_active: true, is_system_account: false },
|
||||
{ account_number: '5410', account_name: 'Förbrukningsinventarier', account_class: 5, plan_type: 'k1', is_active: true, is_system_account: false },
|
||||
{ account_number: '19301', account_name: 'Sparkonto (import)', account_class: 1, plan_type: 'full_bas', is_active: true, is_system_account: false },
|
||||
]
|
||||
|
||||
const usageRows = [
|
||||
{ account_number: '1930', usage_count: 12 },
|
||||
{ account_number: '3001', usage_count: 4 },
|
||||
]
|
||||
|
||||
describe('GET /api/bookkeeping/accounts/usage', () => {
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
unauthenticated()
|
||||
const res = await usageGET(createMockRequest('/api/bookkeeping/accounts/usage'), routeParams)
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
it('returns per-account usage counts from the RPC, company-scoped', async () => {
|
||||
const { supabase, calls } = createCapturingSupabase([{ data: usageRows }])
|
||||
auth(supabase)
|
||||
|
||||
const { status, body } = await parseJsonResponse<{ data: typeof usageRows }>(
|
||||
await usageGET(createMockRequest('/api/bookkeeping/accounts/usage'), routeParams),
|
||||
)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data).toEqual(usageRows)
|
||||
expect(calls.filter((c) => c.method === 'rpc').map((c) => c.args)).toContainEqual([
|
||||
'get_account_usage_counts',
|
||||
{ p_company_id: 'company-1' },
|
||||
])
|
||||
})
|
||||
|
||||
it('returns 500 when the RPC fails', async () => {
|
||||
const { supabase } = createCapturingSupabase([{ error: { message: 'boom' } }])
|
||||
auth(supabase)
|
||||
const res = await usageGET(createMockRequest('/api/bookkeeping/accounts/usage'), routeParams)
|
||||
expect(res.status).toBe(500)
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /api/bookkeeping/accounts/prune', () => {
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
unauthenticated()
|
||||
const req = createMockRequest('/api/bookkeeping/accounts/prune', {
|
||||
method: 'POST',
|
||||
body: { dry_run: true },
|
||||
})
|
||||
const res = await prunePOST(req, routeParams)
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
it('returns 400 when dry_run is false and account_numbers is missing', async () => {
|
||||
const { supabase } = createCapturingSupabase([])
|
||||
auth(supabase)
|
||||
const req = createMockRequest('/api/bookkeeping/accounts/prune', {
|
||||
method: 'POST',
|
||||
body: { dry_run: false },
|
||||
})
|
||||
const { status } = await parseJsonResponse(await prunePOST(req, routeParams))
|
||||
expect(status).toBe(400)
|
||||
})
|
||||
|
||||
it('dry_run returns unused non-system accounts as deletable, the rest as used', async () => {
|
||||
const { supabase } = createCapturingSupabase([
|
||||
{ data: chartAccounts }, // chart_of_accounts page
|
||||
{ data: usageRows }, // usage RPC
|
||||
])
|
||||
auth(supabase)
|
||||
|
||||
const req = createMockRequest('/api/bookkeeping/accounts/prune', {
|
||||
method: 'POST',
|
||||
body: { dry_run: true },
|
||||
})
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
data: {
|
||||
deletable: Array<{ account_number: string; in_bas_reference: boolean }>
|
||||
used: Array<{ account_number: string; usage_count: number }>
|
||||
}
|
||||
}>(await prunePOST(req, routeParams))
|
||||
|
||||
expect(status).toBe(200)
|
||||
const deletableNumbers = body.data.deletable.map((a) => a.account_number).sort()
|
||||
// Unused + non-system: the seeded 5410 and the imported 19301.
|
||||
expect(deletableNumbers).toEqual(['19301', '5410'])
|
||||
// BAS-vs-custom marker drives the dialog's default selection.
|
||||
expect(body.data.deletable.find((a) => a.account_number === '5410')?.in_bas_reference).toBe(true)
|
||||
expect(body.data.deletable.find((a) => a.account_number === '19301')?.in_bas_reference).toBe(false)
|
||||
// Used + system accounts land in the informational remainder.
|
||||
const usedNumbers = body.data.used.map((a) => a.account_number)
|
||||
expect(usedNumbers).toContain('1930')
|
||||
expect(usedNumbers).toContain('3001')
|
||||
expect(body.data.used.find((a) => a.account_number === '3001')?.usage_count).toBe(4)
|
||||
})
|
||||
|
||||
it('execute deletes only re-verified unused accounts and skips the rest', async () => {
|
||||
const { supabase, calls } = createCapturingSupabase([
|
||||
{ data: chartAccounts },
|
||||
{ data: usageRows },
|
||||
{ data: null }, // delete chunk
|
||||
])
|
||||
auth(supabase)
|
||||
|
||||
// Client asks for a used account (3001), a system account (1930), an
|
||||
// unknown number (9999) and two legitimately deletable ones.
|
||||
const req = createMockRequest('/api/bookkeeping/accounts/prune', {
|
||||
method: 'POST',
|
||||
body: { dry_run: false, account_numbers: ['3001', '1930', '9999', '5410', '19301'] },
|
||||
})
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
data: { deleted: string[]; skipped: string[]; not_found: string[] }
|
||||
}>(await prunePOST(req, routeParams))
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.deleted.sort()).toEqual(['19301', '5410'])
|
||||
expect(body.data.skipped.sort()).toEqual(['1930', '3001'])
|
||||
expect(body.data.not_found).toEqual(['9999'])
|
||||
|
||||
// The DELETE is company-scoped, guards system accounts, and only carries
|
||||
// the re-verified numbers.
|
||||
const inCalls = calls.filter((c) => c.method === 'in').map((c) => c.args)
|
||||
expect(inCalls).toContainEqual(['account_number', ['5410', '19301']])
|
||||
const eqCalls = calls.filter((c) => c.method === 'eq').map((c) => c.args)
|
||||
expect(eqCalls).toContainEqual(['is_system_account', false])
|
||||
})
|
||||
|
||||
it('execute with nothing deletable deletes nothing and reports the skips', async () => {
|
||||
const { supabase, calls } = createCapturingSupabase([
|
||||
{ data: chartAccounts },
|
||||
{ data: usageRows },
|
||||
])
|
||||
auth(supabase)
|
||||
|
||||
const req = createMockRequest('/api/bookkeeping/accounts/prune', {
|
||||
method: 'POST',
|
||||
body: { dry_run: false, account_numbers: ['3001'] },
|
||||
})
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
data: { deleted: string[]; skipped: string[] }
|
||||
}>(await prunePOST(req, routeParams))
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.deleted).toEqual([])
|
||||
expect(body.data.skipped).toEqual(['3001'])
|
||||
expect(calls.filter((c) => c.method === 'delete')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('returns 403 when the member lacks write permission', async () => {
|
||||
const { supabase } = createCapturingSupabase([])
|
||||
auth(supabase)
|
||||
requireWriteMock.mockResolvedValue({
|
||||
ok: false,
|
||||
response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }),
|
||||
})
|
||||
const req = createMockRequest('/api/bookkeeping/accounts/prune', {
|
||||
method: 'POST',
|
||||
body: { dry_run: true },
|
||||
})
|
||||
const res = await prunePOST(req, routeParams)
|
||||
expect(res.status).toBe(403)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* Tests for GET /api/bookkeeping/accounts/reference and /bas-lookup.
|
||||
*
|
||||
* reference: the chart query must carry a stable unique order — a full-BAS
|
||||
* chart exceeds fetchAllRows' 1000-row page size and unordered .range()
|
||||
* paging can duplicate/skip rows on page boundaries.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { createMockRequest, parseJsonResponse } from '@/tests/helpers'
|
||||
|
||||
const requireAuthMock = vi.fn()
|
||||
vi.mock('@/lib/auth/require-auth', () => ({
|
||||
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/company/context', () => ({
|
||||
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
}))
|
||||
|
||||
import { GET as referenceGET } from '../reference/route'
|
||||
import { GET as basLookupGET } from '../bas-lookup/route'
|
||||
|
||||
const routeParams = { params: Promise.resolve({}) }
|
||||
|
||||
function createCapturingSupabase(results: { data?: unknown; error?: unknown }[]) {
|
||||
const calls: { method: string; args: unknown[] }[] = []
|
||||
let idx = 0
|
||||
const makeBuilder = () => {
|
||||
const result = results[idx++] ?? { data: null, error: null }
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const b: any = {}
|
||||
for (const m of ['select', 'eq', 'order', 'range', 'maybeSingle', 'single']) {
|
||||
b[m] = (...args: unknown[]) => {
|
||||
calls.push({ method: m, args })
|
||||
return b
|
||||
}
|
||||
}
|
||||
b.then = (resolve: (v: unknown) => void) =>
|
||||
resolve({ data: result.data ?? null, error: result.error ?? null, count: null })
|
||||
return b
|
||||
}
|
||||
return {
|
||||
supabase: { from: () => makeBuilder() },
|
||||
calls,
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('GET /api/bookkeeping/accounts/reference', () => {
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
requireAuthMock.mockResolvedValue({
|
||||
user: null,
|
||||
supabase: {},
|
||||
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
|
||||
})
|
||||
const res = await referenceGET(createMockRequest('/api/bookkeeping/accounts/reference'), routeParams)
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
it('pages the chart with a stable account_number order and merges activation status', async () => {
|
||||
const { supabase, calls } = createCapturingSupabase([
|
||||
{ data: [{ account_number: '1930', is_active: true, is_system_account: false }] },
|
||||
])
|
||||
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase, error: null })
|
||||
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
data: Array<{ account_number: string; is_activated: boolean }>
|
||||
}>(await referenceGET(createMockRequest('/api/bookkeeping/accounts/reference'), routeParams))
|
||||
|
||||
expect(status).toBe(200)
|
||||
const activated = body.data.find((a) => a.account_number === '1930')
|
||||
expect(activated?.is_activated).toBe(true)
|
||||
// Paging-stability regression guard.
|
||||
expect(calls.filter((c) => c.method === 'order').map((c) => c.args[0])).toContain(
|
||||
'account_number'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('GET /api/bookkeeping/accounts/bas-lookup', () => {
|
||||
beforeEach(() => {
|
||||
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase: {}, error: null })
|
||||
})
|
||||
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
requireAuthMock.mockResolvedValue({
|
||||
user: null,
|
||||
supabase: {},
|
||||
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
|
||||
})
|
||||
const res = await basLookupGET(createMockRequest('/api/bookkeeping/accounts/bas-lookup'))
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
it('resolves known BAS numbers and flags unknown ones', async () => {
|
||||
const req = createMockRequest('/api/bookkeeping/accounts/bas-lookup', {
|
||||
searchParams: { numbers: '1930,0000' },
|
||||
})
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
data: Array<{ account_number: string; known: boolean }>
|
||||
}>(await basLookupGET(req))
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.find((a) => a.account_number === '1930')?.known).toBe(true)
|
||||
expect(body.data.find((a) => a.account_number === '0000')?.known).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects an oversized numbers list with 400', async () => {
|
||||
const many = Array.from({ length: 2001 }, (_, i) => String(10000 + i)).join(',')
|
||||
const req = createMockRequest('/api/bookkeeping/accounts/bas-lookup', {
|
||||
searchParams: { numbers: many },
|
||||
})
|
||||
const { status } = await parseJsonResponse(await basLookupGET(req))
|
||||
expect(status).toBe(400)
|
||||
})
|
||||
})
|
||||
@@ -1,8 +1,7 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { getBASReference } from '@/lib/bookkeeping/bas-reference'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
|
||||
/**
|
||||
* POST /api/bookkeeping/accounts/activate
|
||||
@@ -12,100 +11,101 @@ import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
* - Reactivates (is_active=true) accounts that already exist but are inactive.
|
||||
* - Skips anything already active.
|
||||
* - Returns { activated, reactivated, skipped, unknown } so callers can react.
|
||||
*
|
||||
* Strings that aren't known BAS numbers are reported in `unknown` (not
|
||||
* rejected) so activate-and-retry flows can surface them; the schema only
|
||||
* bounds type and size.
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
const ActivateSchema = z.object({
|
||||
account_numbers: z.array(z.string().min(1).max(10)).min(1).max(2000),
|
||||
})
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
export const POST = withRouteContext(
|
||||
'bookkeeping.accounts.activate',
|
||||
async (request, ctx) => {
|
||||
const { supabase, companyId, user } = ctx
|
||||
|
||||
const writeCheck = await requireWritePermission(supabase, user.id)
|
||||
if (!writeCheck.ok) return writeCheck.response
|
||||
|
||||
const companyId = await requireCompanyId(supabase, user.id)
|
||||
|
||||
const body = await request.json()
|
||||
const accountNumbers: string[] = body.account_numbers
|
||||
|
||||
if (!Array.isArray(accountNumbers) || accountNumbers.length === 0) {
|
||||
return NextResponse.json({ error: 'account_numbers array required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const uniqueNumbers = [...new Set(accountNumbers)]
|
||||
|
||||
// Fetch existing rows with current is_active state
|
||||
const { data: existing, error: fetchError } = await supabase
|
||||
.from('chart_of_accounts')
|
||||
.select('account_number, is_active')
|
||||
.eq('company_id', companyId)
|
||||
.in('account_number', uniqueNumbers)
|
||||
|
||||
if (fetchError) {
|
||||
return NextResponse.json({ error: fetchError.message }, { status: 500 })
|
||||
}
|
||||
|
||||
const existingByNumber = new Map<string, boolean>(
|
||||
(existing || []).map((a) => [a.account_number, a.is_active])
|
||||
)
|
||||
|
||||
const toReactivate: string[] = []
|
||||
const toInsert: Array<ReturnType<typeof buildInsertRow>> = []
|
||||
const unknown: string[] = []
|
||||
let skipped = 0
|
||||
|
||||
for (const num of uniqueNumbers) {
|
||||
if (existingByNumber.has(num)) {
|
||||
if (existingByNumber.get(num) === true) {
|
||||
skipped += 1
|
||||
} else {
|
||||
toReactivate.push(num)
|
||||
}
|
||||
continue
|
||||
const raw = await request.json().catch(() => null)
|
||||
const parsed = ActivateSchema.safeParse(raw)
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: 'account_numbers array required' }, { status: 400 })
|
||||
}
|
||||
const row = buildInsertRow(num, user.id, companyId)
|
||||
if (row) {
|
||||
toInsert.push(row)
|
||||
} else {
|
||||
unknown.push(num)
|
||||
}
|
||||
}
|
||||
|
||||
let reactivatedRows: { account_number: string }[] = []
|
||||
if (toReactivate.length > 0) {
|
||||
const { data, error } = await supabase
|
||||
const uniqueNumbers = [...new Set(parsed.data.account_numbers)]
|
||||
|
||||
// Fetch existing rows with current is_active state
|
||||
const { data: existing, error: fetchError } = await supabase
|
||||
.from('chart_of_accounts')
|
||||
.update({ is_active: true })
|
||||
.select('account_number, is_active')
|
||||
.eq('company_id', companyId)
|
||||
.in('account_number', toReactivate)
|
||||
.select('account_number')
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
reactivatedRows = data || []
|
||||
}
|
||||
.in('account_number', uniqueNumbers)
|
||||
|
||||
let insertedRows: { account_number: string }[] = []
|
||||
if (toInsert.length > 0) {
|
||||
const { data, error } = await supabase
|
||||
.from('chart_of_accounts')
|
||||
.insert(toInsert)
|
||||
.select('account_number')
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
if (fetchError) {
|
||||
return NextResponse.json({ error: fetchError.message }, { status: 500 })
|
||||
}
|
||||
insertedRows = data || []
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
data: [...insertedRows, ...reactivatedRows],
|
||||
activated: insertedRows.length,
|
||||
reactivated: reactivatedRows.length,
|
||||
skipped,
|
||||
unknown,
|
||||
})
|
||||
}
|
||||
const existingByNumber = new Map<string, boolean>(
|
||||
(existing || []).map((a) => [a.account_number, a.is_active])
|
||||
)
|
||||
|
||||
const toReactivate: string[] = []
|
||||
const toInsert: Array<ReturnType<typeof buildInsertRow>> = []
|
||||
const unknown: string[] = []
|
||||
let skipped = 0
|
||||
|
||||
for (const num of uniqueNumbers) {
|
||||
if (existingByNumber.has(num)) {
|
||||
if (existingByNumber.get(num) === true) {
|
||||
skipped += 1
|
||||
} else {
|
||||
toReactivate.push(num)
|
||||
}
|
||||
continue
|
||||
}
|
||||
const row = buildInsertRow(num, user.id, companyId)
|
||||
if (row) {
|
||||
toInsert.push(row)
|
||||
} else {
|
||||
unknown.push(num)
|
||||
}
|
||||
}
|
||||
|
||||
let reactivatedRows: { account_number: string }[] = []
|
||||
if (toReactivate.length > 0) {
|
||||
const { data, error } = await supabase
|
||||
.from('chart_of_accounts')
|
||||
.update({ is_active: true })
|
||||
.eq('company_id', companyId)
|
||||
.in('account_number', toReactivate)
|
||||
.select('account_number')
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
reactivatedRows = data || []
|
||||
}
|
||||
|
||||
let insertedRows: { account_number: string }[] = []
|
||||
if (toInsert.length > 0) {
|
||||
const { data, error } = await supabase
|
||||
.from('chart_of_accounts')
|
||||
.insert(toInsert)
|
||||
.select('account_number')
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
insertedRows = data || []
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
data: [...insertedRows, ...reactivatedRows],
|
||||
activated: insertedRows.length,
|
||||
reactivated: reactivatedRows.length,
|
||||
skipped,
|
||||
unknown,
|
||||
})
|
||||
},
|
||||
{ requireWrite: true },
|
||||
)
|
||||
|
||||
function buildInsertRow(accountNumber: string, userId: string, companyId: string) {
|
||||
const ref = getBASReference(accountNumber)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { requireAuth } from '@/lib/auth/require-auth'
|
||||
import { getBASReference } from '@/lib/bookkeeping/bas-reference'
|
||||
|
||||
/**
|
||||
@@ -9,13 +9,13 @@ import { getBASReference } from '@/lib/bookkeeping/bas-reference'
|
||||
* numbers. Used by ActivateAccountsDialog to render human-readable labels
|
||||
* before the user confirms activation. Unknown numbers are returned with
|
||||
* account_name=null so the UI can flag them as non-BAS.
|
||||
*
|
||||
* Pure in-memory reference lookup — no tenant data, so no company context is
|
||||
* resolved; requireAuth() keeps it behind auth (MFA on hosted).
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
const auth = await requireAuth()
|
||||
if (auth.error) return auth.error
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const raw = searchParams.get('numbers') || ''
|
||||
@@ -23,6 +23,10 @@ export async function GET(request: Request) {
|
||||
if (numbers.length === 0) {
|
||||
return NextResponse.json({ data: [] })
|
||||
}
|
||||
// The BAS catalogue is ~1,276 accounts — anything past that is abuse.
|
||||
if (numbers.length > 2000) {
|
||||
return NextResponse.json({ error: 'Too many account numbers' }, { status: 400 })
|
||||
}
|
||||
|
||||
const data = numbers.map((num) => {
|
||||
const ref = getBASReference(num)
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { PruneAccountsSchema } from '@/lib/api/schemas'
|
||||
import { isStandardBASAccount } from '@/lib/bookkeeping/bas-reference'
|
||||
import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
||||
|
||||
// POST /api/bookkeeping/accounts/prune — bulk cleanup of unused accounts
|
||||
// ("Rensa oanvända konton"), for charts bloated by an import from an old
|
||||
// system.
|
||||
//
|
||||
// Two phases:
|
||||
// { dry_run: true }
|
||||
// → returns the deletable set (non-system accounts with zero journal
|
||||
// usage) plus the used remainder, without changing anything.
|
||||
// { dry_run: false, account_numbers: [...] }
|
||||
// → deletes the requested accounts, re-verifying every guard server-side.
|
||||
// The client list is a selection from the preview, not an authority:
|
||||
// anything failing re-check at execute time is skipped and reported,
|
||||
// never deleted and never an error.
|
||||
//
|
||||
// A used account can never be deleted through this path — its verifikat are
|
||||
// immutable under BFL and their lines must keep resolving to an account.
|
||||
// Deactivation (PUT is_active=false on the single-account route) remains the
|
||||
// only way to hide those. Draft usage also blocks deletion: a draft line
|
||||
// still references the account. Opening balances need no separate check —
|
||||
// IB is booked as a verifikat (source_type 'opening_balance'), so the journal
|
||||
// usage count covers it. (The account_balances cache table was dropped in
|
||||
// migration 20240101000027.)
|
||||
//
|
||||
// Like the sibling single-account DELETE, there is a small window between
|
||||
// the usage re-check and the delete where a concurrent posting could slip
|
||||
// in; journal_entry_lines reference accounts by number (account_id is ON
|
||||
// DELETE SET NULL), so the entry itself is never damaged — the account row
|
||||
// would just need re-adding from the BAS catalog.
|
||||
//
|
||||
// Response shapes are legacy `{ data }` / `{ error: string }` — consumed by
|
||||
// the kontoplan UI alongside the sibling account routes.
|
||||
|
||||
interface ChartAccountRow {
|
||||
account_number: string
|
||||
account_name: string
|
||||
account_class: number
|
||||
plan_type: string | null
|
||||
is_active: boolean
|
||||
is_system_account: boolean
|
||||
}
|
||||
|
||||
const DELETE_CHUNK_SIZE = 200
|
||||
|
||||
export const POST = withRouteContext(
|
||||
'bookkeeping.accounts.prune',
|
||||
async (request, ctx) => {
|
||||
const { supabase, companyId, log } = ctx
|
||||
|
||||
const validation = await validateBody(request, PruneAccountsSchema, {
|
||||
log,
|
||||
operation: 'bookkeeping.accounts.prune',
|
||||
})
|
||||
if (!validation.success) return validation.response
|
||||
const { dry_run, account_numbers } = validation.data
|
||||
|
||||
try {
|
||||
// A full imported chart can exceed PostgREST's 1000-row page — paginate.
|
||||
const accounts = (await fetchAllRows(({ from, to }) =>
|
||||
supabase
|
||||
.from('chart_of_accounts')
|
||||
.select(
|
||||
'account_number, account_name, account_class, plan_type, is_active, is_system_account',
|
||||
)
|
||||
.eq('company_id', companyId)
|
||||
.order('account_number')
|
||||
.range(from, to),
|
||||
)) as ChartAccountRow[]
|
||||
|
||||
const { data: usage, error: usageError } = await supabase.rpc(
|
||||
'get_account_usage_counts',
|
||||
{ p_company_id: companyId },
|
||||
)
|
||||
if (usageError) {
|
||||
return NextResponse.json({ error: usageError.message }, { status: 500 })
|
||||
}
|
||||
|
||||
const usageByAccount = new Map<string, number>(
|
||||
(usage ?? []).map((u: { account_number: string; usage_count: number }) => [
|
||||
u.account_number,
|
||||
Number(u.usage_count),
|
||||
]),
|
||||
)
|
||||
|
||||
const isDeletable = (a: ChartAccountRow) =>
|
||||
!a.is_system_account && !usageByAccount.has(a.account_number)
|
||||
|
||||
if (dry_run) {
|
||||
const deletable = accounts.filter(isDeletable).map((a) => ({
|
||||
account_number: a.account_number,
|
||||
account_name: a.account_name,
|
||||
account_class: a.account_class,
|
||||
plan_type: a.plan_type,
|
||||
is_active: a.is_active,
|
||||
in_bas_reference: isStandardBASAccount(a.account_number),
|
||||
}))
|
||||
const used = accounts
|
||||
.filter((a) => !isDeletable(a))
|
||||
.map((a) => ({
|
||||
account_number: a.account_number,
|
||||
account_name: a.account_name,
|
||||
is_system_account: a.is_system_account,
|
||||
usage_count: usageByAccount.get(a.account_number) ?? 0,
|
||||
}))
|
||||
return NextResponse.json({ data: { deletable, used } })
|
||||
}
|
||||
|
||||
// Execute: intersect the requested selection with the freshly computed
|
||||
// deletable set — guards are re-verified here, not trusted from the
|
||||
// preview the client saw.
|
||||
const requested = [...new Set(account_numbers ?? [])]
|
||||
const deletableSet = new Set(accounts.filter(isDeletable).map((a) => a.account_number))
|
||||
const existingSet = new Set(accounts.map((a) => a.account_number))
|
||||
|
||||
const toDelete = requested.filter((n) => deletableSet.has(n))
|
||||
const skipped = requested.filter((n) => existingSet.has(n) && !deletableSet.has(n))
|
||||
const notFound = requested.filter((n) => !existingSet.has(n))
|
||||
|
||||
for (let i = 0; i < toDelete.length; i += DELETE_CHUNK_SIZE) {
|
||||
const chunk = toDelete.slice(i, i + DELETE_CHUNK_SIZE)
|
||||
const { error: deleteError } = await supabase
|
||||
.from('chart_of_accounts')
|
||||
.delete()
|
||||
.eq('company_id', companyId)
|
||||
.eq('is_system_account', false)
|
||||
.in('account_number', chunk)
|
||||
if (deleteError) {
|
||||
// Report what was already deleted so the UI can refresh honestly.
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: deleteError.message,
|
||||
data: { deleted: toDelete.slice(0, i), skipped, not_found: notFound },
|
||||
},
|
||||
{ status: 500 },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
log.info('unused accounts pruned', {
|
||||
deleted: toDelete.length,
|
||||
skipped: skipped.length,
|
||||
notFound: notFound.length,
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
data: { deleted: toDelete, skipped, not_found: notFound },
|
||||
})
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Failed to prune accounts' },
|
||||
{ status: 500 },
|
||||
)
|
||||
}
|
||||
},
|
||||
{ requireWrite: true },
|
||||
)
|
||||
@@ -1,8 +1,7 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { BAS_REFERENCE } from '@/lib/bookkeeping/bas-reference'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
|
||||
/**
|
||||
* GET /api/bookkeeping/accounts/reference
|
||||
@@ -10,23 +9,19 @@ import { requireCompanyId } from '@/lib/company/context'
|
||||
* Returns the full BAS reference catalog merged with the user's activation status.
|
||||
* Each reference account includes: is_activated (exists in user's chart), is_active, is_system_account, is_custom.
|
||||
*/
|
||||
export async function GET() {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
export const GET = withRouteContext('bookkeeping.accounts.reference', async (_request, ctx) => {
|
||||
const { supabase, companyId } = ctx
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const companyId = await requireCompanyId(supabase, user.id)
|
||||
|
||||
// Fetch user's chart of accounts (paginated to avoid 1000-row limit)
|
||||
// Paginated with a stable unique order — a full-BAS chart exceeds the
|
||||
// 1000-row page size, and unordered .range() paging can duplicate or skip
|
||||
// rows on page boundaries (see fetch-all.ts ordering invariant).
|
||||
try {
|
||||
const userAccounts = await fetchAllRows<{ account_number: string; is_active: boolean; is_system_account: boolean }>(({ from, to }) =>
|
||||
supabase
|
||||
.from('chart_of_accounts')
|
||||
.select('account_number, is_active, is_system_account')
|
||||
.eq('company_id', companyId)
|
||||
.order('account_number', { ascending: true })
|
||||
.range(from, to)
|
||||
)
|
||||
|
||||
@@ -62,4 +57,4 @@ export async function GET() {
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: error instanceof Error ? error.message : 'Failed to fetch accounts' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,24 +1,28 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { z } from 'zod'
|
||||
import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { validateBody, validateQuery } from '@/lib/api/validate'
|
||||
import { CreateAccountSchema } from '@/lib/api/schemas'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
// Response shapes are legacy `{ data }` / `{ error: string }` — several pages
|
||||
// (import, supplier-invoices, article form) consume the list directly.
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
const ListQuerySchema = z.object({
|
||||
class: z.coerce.number().int().min(1).max(8).optional(),
|
||||
active: z.enum(['true', 'false']).optional(),
|
||||
})
|
||||
|
||||
const companyId = await requireCompanyId(supabase, user.id)
|
||||
export const GET = withRouteContext('bookkeeping.accounts.list', async (request, ctx) => {
|
||||
const { supabase, companyId, log } = ctx
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const accountClass = searchParams.get('class')
|
||||
const activeOnly = searchParams.get('active') !== 'false'
|
||||
const validated = validateQuery(request, ListQuerySchema, {
|
||||
log,
|
||||
operation: 'bookkeeping.accounts.list',
|
||||
})
|
||||
if (!validated.success) return validated.response
|
||||
const accountClass = validated.data.class
|
||||
const activeOnly = validated.data.active !== 'false'
|
||||
|
||||
try {
|
||||
const data = await fetchAllRows(({ from, to }) => {
|
||||
@@ -32,8 +36,8 @@ export async function GET(request: Request) {
|
||||
query = query.eq('is_active', true)
|
||||
}
|
||||
|
||||
if (accountClass) {
|
||||
query = query.eq('account_class', parseInt(accountClass))
|
||||
if (accountClass !== undefined) {
|
||||
query = query.eq('account_class', accountClass)
|
||||
}
|
||||
|
||||
return query.range(from, to)
|
||||
@@ -41,57 +45,57 @@ export async function GET(request: Request) {
|
||||
|
||||
return NextResponse.json({ data })
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: error instanceof Error ? error.message : 'Failed to fetch accounts' }, { status: 500 })
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Failed to fetch accounts' },
|
||||
{ status: 500 },
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
export const POST = withRouteContext(
|
||||
'bookkeeping.accounts.create',
|
||||
async (request, ctx) => {
|
||||
const { supabase, companyId, user, log } = ctx
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const writeCheck = await requireWritePermission(supabase, user.id)
|
||||
if (!writeCheck.ok) return writeCheck.response
|
||||
|
||||
const validation = await validateBody(request, CreateAccountSchema)
|
||||
if (!validation.success) return validation.response
|
||||
const body = validation.data
|
||||
|
||||
const companyId = await requireCompanyId(supabase, user.id)
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('chart_of_accounts')
|
||||
.insert({
|
||||
user_id: user.id,
|
||||
company_id: companyId,
|
||||
account_number: body.account_number,
|
||||
account_name: body.account_name,
|
||||
account_class: parseInt(body.account_number[0]),
|
||||
account_group: body.account_number.substring(0, 2),
|
||||
account_type: body.account_type,
|
||||
normal_balance: body.normal_balance,
|
||||
plan_type: body.plan_type || 'k1',
|
||||
is_system_account: false,
|
||||
description: body.description || null,
|
||||
default_vat_code: body.default_vat_code || null,
|
||||
sru_code: body.sru_code || null,
|
||||
sort_order: parseInt(body.account_number),
|
||||
const validation = await validateBody(request, CreateAccountSchema, {
|
||||
log,
|
||||
operation: 'bookkeeping.accounts.create',
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
if (!validation.success) return validation.response
|
||||
const body = validation.data
|
||||
|
||||
if (error) {
|
||||
if (error.code === '23505') {
|
||||
return NextResponse.json(
|
||||
{ error: `Kontonummer ${body.account_number} finns redan i din kontoplan.` },
|
||||
{ status: 409 },
|
||||
)
|
||||
const { data, error } = await supabase
|
||||
.from('chart_of_accounts')
|
||||
.insert({
|
||||
user_id: user.id,
|
||||
company_id: companyId,
|
||||
account_number: body.account_number,
|
||||
account_name: body.account_name,
|
||||
account_class: parseInt(body.account_number[0]),
|
||||
account_group: body.account_number.substring(0, 2),
|
||||
account_type: body.account_type,
|
||||
normal_balance: body.normal_balance,
|
||||
plan_type: body.plan_type || 'k1',
|
||||
is_system_account: false,
|
||||
description: body.description || null,
|
||||
default_vat_code: body.default_vat_code || null,
|
||||
sru_code: body.sru_code || null,
|
||||
sort_order: parseInt(body.account_number),
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
if (error.code === '23505') {
|
||||
return NextResponse.json(
|
||||
{ error: `Kontonummer ${body.account_number} finns redan i din kontoplan.` },
|
||||
{ status: 409 },
|
||||
)
|
||||
}
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
return NextResponse.json({ data })
|
||||
},
|
||||
{ requireWrite: true },
|
||||
)
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
|
||||
// GET /api/bookkeeping/accounts/usage — per-account posting counts for the
|
||||
// active company, from the get_account_usage_counts RPC. Accounts that have
|
||||
// never been posted to are absent from the result; that absence is the
|
||||
// "unused" signal the kontoplan UI and the prune flow key on.
|
||||
//
|
||||
// Response shapes are legacy `{ data }` / `{ error: string }` — consumed by
|
||||
// the kontoplan UI alongside the sibling account routes.
|
||||
|
||||
export const GET = withRouteContext('bookkeeping.accounts.usage', async (_request, ctx) => {
|
||||
const { supabase, companyId } = ctx
|
||||
|
||||
const { data, error } = await supabase.rpc('get_account_usage_counts', {
|
||||
p_company_id: companyId,
|
||||
})
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data: data ?? [] })
|
||||
})
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Tests for GET /api/bookkeeping/accruals — status filter validation and the
|
||||
* due_count derivation.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { createQueuedMockSupabase, createMockRequest, parseJsonResponse } from '@/tests/helpers'
|
||||
|
||||
const { supabase, enqueue, reset } = createQueuedMockSupabase()
|
||||
|
||||
const requireAuthMock = vi.fn()
|
||||
vi.mock('@/lib/auth/require-auth', () => ({
|
||||
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/company/context', () => ({
|
||||
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() }))
|
||||
|
||||
import { GET } from '../route'
|
||||
|
||||
const routeParams = { params: Promise.resolve({}) }
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase, error: null })
|
||||
})
|
||||
|
||||
describe('GET /api/bookkeeping/accruals', () => {
|
||||
it('returns 400 for an unknown status filter', async () => {
|
||||
const req = createMockRequest('/api/bookkeeping/accruals', {
|
||||
searchParams: { status: 'garbage' },
|
||||
})
|
||||
const { status } = await parseJsonResponse(await GET(req, routeParams))
|
||||
expect(status).toBe(400)
|
||||
})
|
||||
|
||||
it('lists schedules and counts due pending installments', async () => {
|
||||
enqueue({
|
||||
data: [
|
||||
{
|
||||
id: 'sched-1',
|
||||
status: 'active',
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
installments: [
|
||||
{ id: 'i1', period_month: '2020-01-01', status: 'pending' },
|
||||
{ id: 'i2', period_month: '2099-01-01', status: 'pending' },
|
||||
{ id: 'i3', period_month: '2020-02-01', status: 'posted' },
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const { status, body } = await parseJsonResponse<{ data: unknown[]; due_count: number }>(
|
||||
await GET(createMockRequest('/api/bookkeeping/accruals'), routeParams)
|
||||
)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data).toHaveLength(1)
|
||||
// Only the past-month pending installment counts as due.
|
||||
expect(body.due_count).toBe(1)
|
||||
})
|
||||
})
|
||||
@@ -1,12 +1,18 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { validateQuery } from '@/lib/api/validate'
|
||||
import { errorResponse } from '@/lib/errors/get-structured-error'
|
||||
import { firstOfMonth } from '@/lib/bookkeeping/accruals/compute'
|
||||
import type { AccrualSchedule, AccrualScheduleInstallment } from '@/types'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
const ListQuerySchema = z.object({
|
||||
status: z.enum(['active', 'completed', 'cancelled', 'all']).default('active'),
|
||||
})
|
||||
|
||||
/**
|
||||
* GET /api/bookkeeping/accruals?status=active|completed|cancelled|all
|
||||
*
|
||||
@@ -19,8 +25,12 @@ export const GET = withRouteContext(
|
||||
async (request, ctx) => {
|
||||
const { supabase, companyId, log, requestId } = ctx
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const status = searchParams.get('status') || 'active'
|
||||
const validated = validateQuery(request, ListQuerySchema, {
|
||||
log,
|
||||
operation: 'accruals.list',
|
||||
})
|
||||
if (!validated.success) return validated.response
|
||||
const { status } = validated.data
|
||||
|
||||
let query = supabase
|
||||
.from('accrual_schedules')
|
||||
|
||||
@@ -6,6 +6,7 @@ vi.mock('@/lib/supabase/server', () => ({
|
||||
}))
|
||||
vi.mock('@/lib/company/context', () => ({
|
||||
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
}))
|
||||
vi.mock('@/lib/auth/require-write', () => ({
|
||||
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Tests for POST /api/bookkeeping/fiscal-periods/[id]/bokslutsdispositioner —
|
||||
* input-bound validation. The schablonintäkt rate feeds the avsättning cap
|
||||
* base (IL 30 kap 25 % limit), so an unbounded rate would let a caller
|
||||
* inflate the legal ceiling; these tests lock the bounds in.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { createMockRequest, parseJsonResponse } from '@/tests/helpers'
|
||||
|
||||
const requireAuthMock = vi.fn()
|
||||
vi.mock('@/lib/auth/require-auth', () => ({
|
||||
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/company/context', () => ({
|
||||
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
}))
|
||||
|
||||
const requireWriteMock = vi.fn()
|
||||
vi.mock('@/lib/auth/require-write', () => ({
|
||||
requireWritePermission: (...args: unknown[]) => requireWriteMock(...args),
|
||||
}))
|
||||
|
||||
import { POST } from '../route'
|
||||
|
||||
const idParams = { params: Promise.resolve({ id: 'period-1' }) }
|
||||
|
||||
function post(body: unknown) {
|
||||
return POST(
|
||||
createMockRequest('/api/bookkeeping/fiscal-periods/period-1/bokslutsdispositioner', {
|
||||
method: 'POST',
|
||||
body,
|
||||
}),
|
||||
idParams,
|
||||
)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase: {}, error: null })
|
||||
requireWriteMock.mockResolvedValue({ ok: true })
|
||||
})
|
||||
|
||||
describe('POST /api/bookkeeping/fiscal-periods/[id]/bokslutsdispositioner', () => {
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
requireAuthMock.mockResolvedValue({
|
||||
user: null,
|
||||
supabase: {},
|
||||
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
|
||||
})
|
||||
const res = await post({ items: [{ kind: 'bolagsskatt' }] })
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
it('rejects an inflated schablonintäkt rate (cap-base attack) with 400', async () => {
|
||||
const { status } = await parseJsonResponse(
|
||||
await post({
|
||||
items: [{ kind: 'periodiseringsfond_avsattning', schablonintaktRate: 100 }],
|
||||
}),
|
||||
)
|
||||
expect(status).toBe(400)
|
||||
})
|
||||
|
||||
it('rejects a negative desiredAmount with 400', async () => {
|
||||
const { status } = await parseJsonResponse(
|
||||
await post({
|
||||
items: [{ kind: 'periodiseringsfond_avsattning', desiredAmount: -50000 }],
|
||||
}),
|
||||
)
|
||||
expect(status).toBe(400)
|
||||
})
|
||||
|
||||
it('rejects negative återföring amounts with 400', async () => {
|
||||
const { status } = await parseJsonResponse(
|
||||
await post({
|
||||
items: [{ kind: 'periodiseringsfond_ateforing', returns: { '2129': -10000 } }],
|
||||
}),
|
||||
)
|
||||
expect(status).toBe(400)
|
||||
})
|
||||
|
||||
it('rejects an empty items array with 400', async () => {
|
||||
const { status } = await parseJsonResponse(await post({ items: [] }))
|
||||
expect(status).toBe(400)
|
||||
})
|
||||
})
|
||||
@@ -103,14 +103,16 @@ const ItemSchema = z.discriminatedUnion('kind', [
|
||||
kind: z.literal('periodiseringsfond_avsattning'),
|
||||
/** Optional override for the SLR-based schablonintäkt rate; defaults to
|
||||
* the server-side constant. Used both to compute the cap base and to
|
||||
* feed back into bolagsskatt's adjustment if present in the same batch. */
|
||||
schablonintaktRate: z.number().optional(),
|
||||
desiredAmount: z.number().optional(),
|
||||
* feed back into bolagsskatt's adjustment if present in the same batch.
|
||||
* Bounded to a sane range — an inflated rate would inflate the cap base
|
||||
* and let the caller exceed the legal 25 % avsättning limit (IL 30 kap). */
|
||||
schablonintaktRate: z.number().min(0).max(0.2).optional(),
|
||||
desiredAmount: z.number().positive().optional(),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal('periodiseringsfond_ateforing'),
|
||||
returns: z.record(z.string(), z.number()).default({}),
|
||||
schablonintaktRate: z.number().default(DEFAULT_SCHABLONINTAKT_RATE),
|
||||
returns: z.record(z.string(), z.number().nonnegative()).default({}),
|
||||
schablonintaktRate: z.number().min(0).max(0.2).default(DEFAULT_SCHABLONINTAKT_RATE),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal('overavskrivningar'),
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Tests for POST /api/bookkeeping/fiscal-periods/[id]/close.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { createMockRequest, parseJsonResponse } from '@/tests/helpers'
|
||||
|
||||
const requireAuthMock = vi.fn()
|
||||
vi.mock('@/lib/auth/require-auth', () => ({
|
||||
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/company/context', () => ({
|
||||
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
}))
|
||||
|
||||
const requireWriteMock = vi.fn()
|
||||
vi.mock('@/lib/auth/require-write', () => ({
|
||||
requireWritePermission: (...args: unknown[]) => requireWriteMock(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/core/bookkeeping/period-service', () => ({
|
||||
closePeriod: vi.fn(),
|
||||
}))
|
||||
|
||||
import { closePeriod } from '@/lib/core/bookkeeping/period-service'
|
||||
import { POST } from '../route'
|
||||
|
||||
const mockClosePeriod = vi.mocked(closePeriod)
|
||||
const idParams = { params: Promise.resolve({ id: 'period-1' }) }
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase: {}, error: null })
|
||||
requireWriteMock.mockResolvedValue({ ok: true })
|
||||
})
|
||||
|
||||
describe('POST /api/bookkeeping/fiscal-periods/[id]/close', () => {
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
requireAuthMock.mockResolvedValue({
|
||||
user: null,
|
||||
supabase: {},
|
||||
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
|
||||
})
|
||||
const res = await POST(createMockRequest('/x', { method: 'POST', body: {} }), idParams)
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
it('returns 403 when the caller lacks write permission', async () => {
|
||||
requireWriteMock.mockResolvedValue({
|
||||
ok: false,
|
||||
response: NextResponse.json({ error: 'forbidden' }, { status: 403 }),
|
||||
})
|
||||
const res = await POST(createMockRequest('/x', { method: 'POST', body: {} }), idParams)
|
||||
expect(res.status).toBe(403)
|
||||
expect(mockClosePeriod).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('maps a service refusal to 400 with the message', async () => {
|
||||
mockClosePeriod.mockRejectedValue(new Error('Period contains draft entries'))
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(
|
||||
await POST(createMockRequest('/x', { method: 'POST', body: {} }), idParams)
|
||||
)
|
||||
expect(status).toBe(400)
|
||||
expect(body.error).toBe('Period contains draft entries')
|
||||
})
|
||||
|
||||
it('closes the period on the happy path', async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
mockClosePeriod.mockResolvedValue({ id: 'period-1', is_closed: true } as any)
|
||||
const { status, body } = await parseJsonResponse<{ data: { is_closed: boolean } }>(
|
||||
await POST(createMockRequest('/x', { method: 'POST', body: {} }), idParams)
|
||||
)
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.is_closed).toBe(true)
|
||||
expect(mockClosePeriod).toHaveBeenCalledWith(expect.anything(), 'company-1', 'user-1', 'period-1')
|
||||
})
|
||||
})
|
||||
@@ -1,33 +1,25 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { closePeriod } from '@/lib/core/bookkeeping/period-service'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
// Response shapes are legacy `{ error: string }` — kept for the year-end UI.
|
||||
// closePeriod throws plain Errors for every refusal (period not found, drafts
|
||||
// remaining, already closed); they all map to 400 as before.
|
||||
export const POST = withRouteContext(
|
||||
'period.close',
|
||||
async (_request, ctx, { params }: { params: Promise<{ id: string }> }) => {
|
||||
const { id } = await params
|
||||
const { user, supabase, companyId } = ctx
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const writeCheck = await requireWritePermission(supabase, user.id)
|
||||
if (!writeCheck.ok) return writeCheck.response
|
||||
|
||||
const companyId = await requireCompanyId(supabase, user.id)
|
||||
|
||||
try {
|
||||
const period = await closePeriod(supabase, companyId, user.id, id)
|
||||
return NextResponse.json({ data: period })
|
||||
} catch (err) {
|
||||
return NextResponse.json(
|
||||
{ error: err instanceof Error ? err.message : 'Failed to close period' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
}
|
||||
try {
|
||||
const period = await closePeriod(supabase, companyId, user.id, id)
|
||||
return NextResponse.json({ data: period })
|
||||
} catch (err) {
|
||||
return NextResponse.json(
|
||||
{ error: err instanceof Error ? err.message : 'Failed to close period' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
},
|
||||
{ requireWrite: true },
|
||||
)
|
||||
|
||||
@@ -5,6 +5,7 @@ vi.mock('@/lib/supabase/server', () => ({
|
||||
}))
|
||||
vi.mock('@/lib/company/context', () => ({
|
||||
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
}))
|
||||
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
|
||||
@@ -1,42 +1,34 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
export const GET = withRouteContext(
|
||||
'period.entry_count',
|
||||
async (_request, ctx, { params }: { params: Promise<{ id: string }> }) => {
|
||||
const { id } = await params
|
||||
const { supabase, companyId } = ctx
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
const { data: period, error: fetchError } = await supabase
|
||||
.from('fiscal_periods')
|
||||
.select('id')
|
||||
.eq('id', id)
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle()
|
||||
|
||||
const companyId = await requireCompanyId(supabase, user.id)
|
||||
if (fetchError || !period) {
|
||||
return NextResponse.json({ error: 'Räkenskapsår hittades inte' }, { status: 404 })
|
||||
}
|
||||
|
||||
const { data: period, error: fetchError } = await supabase
|
||||
.from('fiscal_periods')
|
||||
.select('id')
|
||||
.eq('id', id)
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle()
|
||||
const { count, error: countError } = await supabase
|
||||
.from('journal_entries')
|
||||
.select('id', { count: 'exact', head: true })
|
||||
.eq('company_id', companyId)
|
||||
.eq('fiscal_period_id', id)
|
||||
.in('status', ['posted', 'reversed'])
|
||||
|
||||
if (fetchError || !period) {
|
||||
return NextResponse.json({ error: 'Räkenskapsår hittades inte' }, { status: 404 })
|
||||
}
|
||||
if (countError) {
|
||||
return NextResponse.json({ error: countError.message }, { status: 500 })
|
||||
}
|
||||
|
||||
const { count, error: countError } = await supabase
|
||||
.from('journal_entries')
|
||||
.select('id', { count: 'exact', head: true })
|
||||
.eq('company_id', companyId)
|
||||
.eq('fiscal_period_id', id)
|
||||
.in('status', ['posted', 'reversed'])
|
||||
|
||||
if (countError) {
|
||||
return NextResponse.json({ error: countError.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data: { posted_count: count ?? 0 } })
|
||||
}
|
||||
return NextResponse.json({ data: { posted_count: count ?? 0 } })
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { validatePeriodDuration, parseDateParts } from '@/lib/bookkeeping/validate-period-duration'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
import { z } from 'zod'
|
||||
|
||||
const UpdateFiscalPeriodSchema = z.object({
|
||||
@@ -12,22 +10,14 @@ const UpdateFiscalPeriodSchema = z.object({
|
||||
period_end: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Slutdatum måste vara i format ÅÅÅÅ-MM-DD').optional(),
|
||||
})
|
||||
|
||||
export async function PATCH(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
// Response shapes are legacy `{ error: string }` (Swedish) — the fiscal-year
|
||||
// settings UI renders them directly. Only the auth/company layer was moved
|
||||
// into withRouteContext.
|
||||
export const PATCH = withRouteContext(
|
||||
'period.update',
|
||||
async (request, ctx, { params }: { params: Promise<{ id: string }> }) => {
|
||||
const { id } = await params
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const writeCheck = await requireWritePermission(supabase, user.id)
|
||||
if (!writeCheck.ok) return writeCheck.response
|
||||
|
||||
const companyId = await requireCompanyId(supabase, user.id)
|
||||
const { supabase, companyId } = ctx
|
||||
|
||||
const validation = await validateBody(request, UpdateFiscalPeriodSchema)
|
||||
if (!validation.success) return validation.response
|
||||
@@ -166,4 +156,6 @@ export async function PATCH(
|
||||
}
|
||||
|
||||
return NextResponse.json({ data: updated })
|
||||
}
|
||||
},
|
||||
{ requireWrite: true },
|
||||
)
|
||||
|
||||
@@ -6,6 +6,7 @@ vi.mock('@/lib/supabase/server', () => ({
|
||||
}))
|
||||
vi.mock('@/lib/company/context', () => ({
|
||||
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
}))
|
||||
vi.mock('@/lib/auth/require-write', () => ({
|
||||
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Tests for GET /api/bookkeeping/fiscal-periods/period-status.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { createQueuedMockSupabase, createMockRequest, parseJsonResponse } from '@/tests/helpers'
|
||||
|
||||
const { supabase, enqueue, reset } = createQueuedMockSupabase()
|
||||
|
||||
const requireAuthMock = vi.fn()
|
||||
vi.mock('@/lib/auth/require-auth', () => ({
|
||||
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/company/context', () => ({
|
||||
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/core/bookkeeping/period-service', () => ({
|
||||
resolvePeriodStatusForDate: vi.fn(),
|
||||
}))
|
||||
|
||||
import { resolvePeriodStatusForDate } from '@/lib/core/bookkeeping/period-service'
|
||||
import { GET } from '../route'
|
||||
|
||||
const mockResolve = vi.mocked(resolvePeriodStatusForDate)
|
||||
const routeParams = { params: Promise.resolve({}) }
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase, error: null })
|
||||
})
|
||||
|
||||
describe('GET /api/bookkeeping/fiscal-periods/period-status', () => {
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
requireAuthMock.mockResolvedValue({
|
||||
user: null,
|
||||
supabase,
|
||||
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
|
||||
})
|
||||
const req = createMockRequest('/api/bookkeeping/fiscal-periods/period-status', {
|
||||
searchParams: { date: '2026-01-15' },
|
||||
})
|
||||
const res = await GET(req, routeParams)
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
it('returns 400 for a malformed date', async () => {
|
||||
const req = createMockRequest('/api/bookkeeping/fiscal-periods/period-status', {
|
||||
searchParams: { date: '15/01/2026' },
|
||||
})
|
||||
const { status } = await parseJsonResponse(await GET(req, routeParams))
|
||||
expect(status).toBe(400)
|
||||
expect(mockResolve).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns the status with the covering period name', async () => {
|
||||
mockResolve.mockResolvedValue({
|
||||
status: 'open',
|
||||
period_id: 'period-1',
|
||||
lock_date: null,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any)
|
||||
enqueue({ data: { name: 'Räkenskapsår 2026' } })
|
||||
|
||||
const req = createMockRequest('/api/bookkeeping/fiscal-periods/period-status', {
|
||||
searchParams: { date: '2026-01-15' },
|
||||
})
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
data: { status: string; period_name: string }
|
||||
}>(await GET(req, routeParams))
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.status).toBe('open')
|
||||
expect(body.data.period_name).toBe('Räkenskapsår 2026')
|
||||
expect(mockResolve).toHaveBeenCalledWith(expect.anything(), 'company-1', '2026-01-15')
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,6 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { resolvePeriodStatusForDate } from '@/lib/core/bookkeeping/period-service'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
|
||||
/**
|
||||
* GET /api/bookkeeping/fiscal-periods/period-status?date=YYYY-MM-DD
|
||||
@@ -11,20 +10,14 @@ import { requireCompanyId } from '@/lib/company/context'
|
||||
* the covering period's label so the UI can show "flyttas till <år>" before a
|
||||
* write is attempted. Mirrors resolvePeriodStatusForDate / the DB triggers.
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
export const GET = withRouteContext('period.status_for_date', async (request, ctx) => {
|
||||
const { supabase, companyId } = ctx
|
||||
|
||||
const date = new URL(request.url).searchParams.get('date')
|
||||
if (!date || !/^\d{4}-\d{2}-\d{2}$/.test(date)) {
|
||||
return NextResponse.json({ error: 'Ogiltigt datum (förväntat ÅÅÅÅ-MM-DD)' }, { status: 400 })
|
||||
}
|
||||
|
||||
const companyId = await requireCompanyId(supabase, user.id)
|
||||
|
||||
try {
|
||||
const status = await resolvePeriodStatusForDate(supabase, companyId, date)
|
||||
|
||||
@@ -58,4 +51,4 @@ export async function GET(request: Request) {
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,24 +1,15 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { validatePeriodDuration } from '@/lib/bookkeeping/validate-period-duration'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { CreateFiscalPeriodSchema } from '@/lib/api/schemas'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
|
||||
const log = createLogger('api/bookkeeping/fiscal-periods')
|
||||
// Response shapes are legacy `{ error: string }` (plus one envelope code for
|
||||
// the blocked-by-open-periods dialog) — kept for the räkenskapsår UI.
|
||||
|
||||
export async function GET() {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const companyId = await requireCompanyId(supabase, user.id)
|
||||
export const GET = withRouteContext('period.list', async (_request, ctx) => {
|
||||
const { supabase, companyId } = ctx
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('fiscal_periods')
|
||||
@@ -31,20 +22,12 @@ export async function GET() {
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
})
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const writeCheck = await requireWritePermission(supabase, user.id)
|
||||
if (!writeCheck.ok) return writeCheck.response
|
||||
|
||||
const companyId = await requireCompanyId(supabase, user.id)
|
||||
export const POST = withRouteContext(
|
||||
'period.create',
|
||||
async (request, ctx) => {
|
||||
const { supabase, companyId, user, log } = ctx
|
||||
|
||||
const validation = await validateBody(request, CreateFiscalPeriodSchema)
|
||||
if (!validation.success) return validation.response
|
||||
@@ -231,13 +214,23 @@ export async function POST(request: Request) {
|
||||
const isPrepend = body.period_end < earliest.period_start
|
||||
const periodToRelink = isPrepend ? earliest : successor
|
||||
if (periodToRelink) {
|
||||
await supabase
|
||||
const { error: relinkError } = await supabase
|
||||
.from('fiscal_periods')
|
||||
.update({ previous_period_id: data.id })
|
||||
.eq('id', periodToRelink.id)
|
||||
.eq('company_id', companyId)
|
||||
if (relinkError) {
|
||||
// The period WAS created — don't fail the request, but a broken
|
||||
// continuity chain (BFNAR 2013:2) must never be silent.
|
||||
log.error('failed to relink continuity chain after period create', relinkError, {
|
||||
createdPeriodId: data.id,
|
||||
relinkPeriodId: periodToRelink.id,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
},
|
||||
{ requireWrite: true },
|
||||
)
|
||||
|
||||
@@ -1,20 +1,10 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
export const GET = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
'bookkeeping.journal_entry.chain',
|
||||
async (_request, { supabase, companyId }, { params }) => {
|
||||
const { id } = await params
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const companyId = await requireCompanyId(supabase, user.id)
|
||||
|
||||
// Fetch the requested entry with lines
|
||||
const { data: entry, error } = await supabase
|
||||
@@ -127,4 +117,5 @@ export async function GET(
|
||||
}
|
||||
|
||||
return NextResponse.json({ data: { entry, chain, is_last_in_series: isLastInSeries } })
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { z } from 'zod'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
|
||||
@@ -9,22 +7,10 @@ const SetNoDocSchema = z.object({
|
||||
reason: z.string().trim().max(200).nullable().optional(),
|
||||
})
|
||||
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
'bookkeeping.journal_entry.no_doc_required.set',
|
||||
async (request, { supabase, companyId, user }, { params }) => {
|
||||
const { id } = await params
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const writeCheck = await requireWritePermission(supabase, user.id)
|
||||
if (!writeCheck.ok) return writeCheck.response
|
||||
|
||||
const companyId = await requireCompanyId(supabase, user.id)
|
||||
|
||||
const result = await validateBody(request, SetNoDocSchema)
|
||||
if (!result.success) return result.response
|
||||
@@ -57,24 +43,14 @@ export async function POST(
|
||||
}
|
||||
|
||||
return NextResponse.json({ data: { exempted: true } })
|
||||
}
|
||||
},
|
||||
{ requireWrite: true },
|
||||
)
|
||||
|
||||
export async function DELETE(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
export const DELETE = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
'bookkeeping.journal_entry.no_doc_required.unset',
|
||||
async (_request, { supabase, companyId }, { params }) => {
|
||||
const { id } = await params
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const writeCheck = await requireWritePermission(supabase, user.id)
|
||||
if (!writeCheck.ok) return writeCheck.response
|
||||
|
||||
const companyId = await requireCompanyId(supabase, user.id)
|
||||
|
||||
// Authorization is company-scoped, not user-scoped: any non-viewer member
|
||||
// of the active company may revoke any exemption in that company. The flag
|
||||
@@ -92,4 +68,6 @@ export async function DELETE(
|
||||
}
|
||||
|
||||
return NextResponse.json({ data: { exempted: false } })
|
||||
}
|
||||
},
|
||||
{ requireWrite: true },
|
||||
)
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Tests for PATCH /api/bookkeeping/journal-entries/[id]/notes.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { createQueuedMockSupabase, createMockRequest, parseJsonResponse } from '@/tests/helpers'
|
||||
|
||||
const { supabase, enqueue, reset } = createQueuedMockSupabase()
|
||||
|
||||
const requireAuthMock = vi.fn()
|
||||
vi.mock('@/lib/auth/require-auth', () => ({
|
||||
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/company/context', () => ({
|
||||
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
}))
|
||||
|
||||
const requireWriteMock = vi.fn()
|
||||
vi.mock('@/lib/auth/require-write', () => ({
|
||||
requireWritePermission: (...args: unknown[]) => requireWriteMock(...args),
|
||||
}))
|
||||
|
||||
import { PATCH } from '../route'
|
||||
|
||||
const idParams = { params: Promise.resolve({ id: 'entry-1' }) }
|
||||
|
||||
function patch(body: unknown) {
|
||||
return PATCH(
|
||||
createMockRequest('/api/bookkeeping/journal-entries/entry-1/notes', {
|
||||
method: 'PATCH',
|
||||
body,
|
||||
}),
|
||||
idParams,
|
||||
)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase, error: null })
|
||||
requireWriteMock.mockResolvedValue({ ok: true })
|
||||
})
|
||||
|
||||
describe('PATCH /api/bookkeeping/journal-entries/[id]/notes', () => {
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
requireAuthMock.mockResolvedValue({
|
||||
user: null,
|
||||
supabase,
|
||||
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
|
||||
})
|
||||
const res = await patch({ notes: 'hej' })
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
it('rejects an over-long note with 400', async () => {
|
||||
const { status } = await parseJsonResponse(await patch({ notes: 'x'.repeat(2001) }))
|
||||
expect(status).toBe(400)
|
||||
})
|
||||
|
||||
it('returns 404 instead of phantom success when no row matches', async () => {
|
||||
enqueue({ data: null }) // update matched zero rows
|
||||
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(
|
||||
await patch({ notes: 'En anteckning' })
|
||||
)
|
||||
expect(status).toBe(404)
|
||||
expect(body.error).toBe('Verifikationen hittades inte.')
|
||||
})
|
||||
|
||||
it('updates the note on the happy path', async () => {
|
||||
enqueue({ data: { id: 'entry-1' } })
|
||||
|
||||
const { status, body } = await parseJsonResponse<{ data: { updated: boolean } }>(
|
||||
await patch({ notes: 'En anteckning' })
|
||||
)
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.updated).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,5 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { z } from 'zod'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
|
||||
@@ -9,35 +7,35 @@ const UpdateNotesSchema = z.object({
|
||||
notes: z.string().max(2000).nullable(),
|
||||
})
|
||||
|
||||
export async function PATCH(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
// Notes are annotation metadata alongside the verifikat (not räkenskaps-
|
||||
// information) — the immutability trigger governs what may change on posted
|
||||
// entries; this route just scopes and validates.
|
||||
export const PATCH = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
'bookkeeping.journal_entry.notes',
|
||||
async (request, { supabase, companyId }, { params }) => {
|
||||
const { id } = await params
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
const result = await validateBody(request, UpdateNotesSchema)
|
||||
if (!result.success) return result.response
|
||||
|
||||
const writeCheck = await requireWritePermission(supabase, user.id)
|
||||
if (!writeCheck.ok) return writeCheck.response
|
||||
const { data, error } = await supabase
|
||||
.from('journal_entries')
|
||||
.update({ notes: result.data.notes })
|
||||
.eq('id', id)
|
||||
.eq('company_id', companyId)
|
||||
.select('id')
|
||||
.maybeSingle()
|
||||
|
||||
const companyId = await requireCompanyId(supabase, user.id)
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 400 })
|
||||
}
|
||||
// Zero rows = the entry doesn't exist in this company — report it instead
|
||||
// of a phantom success.
|
||||
if (!data) {
|
||||
return NextResponse.json({ error: 'Verifikationen hittades inte.' }, { status: 404 })
|
||||
}
|
||||
|
||||
const result = await validateBody(request, UpdateNotesSchema)
|
||||
if (!result.success) return result.response
|
||||
|
||||
const { error } = await supabase
|
||||
.from('journal_entries')
|
||||
.update({ notes: result.data.notes })
|
||||
.eq('id', id)
|
||||
.eq('company_id', companyId)
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 400 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data: { updated: true } })
|
||||
}
|
||||
return NextResponse.json({ data: { updated: true } })
|
||||
},
|
||||
{ requireWrite: true },
|
||||
)
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { eventBus } from '@/lib/events/bus'
|
||||
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
||||
@@ -17,50 +14,30 @@ const logger = createLogger('journal-entries')
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
export const GET = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
'bookkeeping.journal_entry.get',
|
||||
async (_request, { supabase, companyId }, { params }) => {
|
||||
const { id } = await params
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
const { data, error } = await supabase
|
||||
.from('journal_entries')
|
||||
.select('*, lines:journal_entry_lines(*)')
|
||||
.eq('id', id)
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
|
||||
const companyId = await requireCompanyId(supabase, user.id)
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 404 })
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('journal_entries')
|
||||
.select('*, lines:journal_entry_lines(*)')
|
||||
.eq('id', id)
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
return NextResponse.json({ data })
|
||||
},
|
||||
)
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 404 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const writeCheck = await requireWritePermission(supabase, user.id)
|
||||
if (!writeCheck.ok) return writeCheck.response
|
||||
|
||||
const companyId = await requireCompanyId(supabase, user.id)
|
||||
export const DELETE = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
'bookkeeping.journal_entry.delete',
|
||||
async (_request, { supabase, companyId, user }, { params }) => {
|
||||
const { id } = await params
|
||||
|
||||
// Read source_type/source_id BEFORE deleting so we can revert the linked
|
||||
// invoice/supplier_invoice status afterwards. The GL row gets cancelled by
|
||||
@@ -107,13 +84,14 @@ export async function DELETE(
|
||||
})
|
||||
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
},
|
||||
{ requireWrite: true },
|
||||
)
|
||||
|
||||
/**
|
||||
* PATCH: edit a DRAFT verifikat in place (header + lines). Only drafts are
|
||||
* editable; updateDraftEntry rejects committed entries with a 409, and the DB
|
||||
* immutability trigger is the backstop. Uses withRouteContext (MFA + write gate):
|
||||
* the GET/DELETE above predate that wrapper and are intentionally left as-is.
|
||||
* immutability trigger is the backstop.
|
||||
*/
|
||||
export const PATCH = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
'bookkeeping.journal_entry.update',
|
||||
|
||||
@@ -1,25 +1,19 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { createDraftEntry, createJournalEntry } from '@/lib/bookkeeping/engine'
|
||||
import { bookkeepingErrorResponse } from '@/lib/bookkeeping/errors'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { CreateJournalEntrySchema } from '@/lib/api/schemas'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
import { escapeLikePattern } from '@/lib/invoices/duplicate-payment-guard'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const companyId = await requireCompanyId(supabase, user.id)
|
||||
// Query params are hand-parsed with per-param clamping/regex validation (see
|
||||
// each param's comment) rather than a Zod schema; response shapes are legacy
|
||||
// `{ data, count }` / `{ error: string }` for the verifikat list UI.
|
||||
export const GET = withRouteContext('bookkeeping.journal_entries.list', async (request, ctx) => {
|
||||
const { supabase, companyId } = ctx
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const periodId = searchParams.get('period_id')
|
||||
@@ -187,20 +181,12 @@ export async function GET(request: Request) {
|
||||
}
|
||||
|
||||
return NextResponse.json({ data, count })
|
||||
}
|
||||
})
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const writeCheck = await requireWritePermission(supabase, user.id)
|
||||
if (!writeCheck.ok) return writeCheck.response
|
||||
|
||||
const companyId = await requireCompanyId(supabase, user.id)
|
||||
export const POST = withRouteContext(
|
||||
'bookkeeping.journal_entries.create',
|
||||
async (request, ctx) => {
|
||||
const { supabase, companyId, user } = ctx
|
||||
|
||||
const validation = await validateBody(request, CreateJournalEntrySchema)
|
||||
if (!validation.success) return validation.response
|
||||
@@ -222,4 +208,6 @@ export async function POST(request: Request) {
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
{ requireWrite: true },
|
||||
)
|
||||
|
||||
@@ -1,22 +1,17 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { evaluateMappingRules } from '@/lib/bookkeeping/mapping-engine'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { EvaluateMappingRulesSchema } from '@/lib/api/schemas'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import type { Transaction } from '@/types'
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
export const POST = withRouteContext('mapping_rules.evaluate', async (request, ctx) => {
|
||||
const { supabase, companyId, log } = ctx
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const companyId = await requireCompanyId(supabase, user.id)
|
||||
|
||||
const validation = await validateBody(request, EvaluateMappingRulesSchema)
|
||||
const validation = await validateBody(request, EvaluateMappingRulesSchema, {
|
||||
log,
|
||||
operation: 'mapping_rules.evaluate',
|
||||
})
|
||||
if (!validation.success) return validation.response
|
||||
const body = validation.data
|
||||
|
||||
@@ -37,6 +32,8 @@ export async function POST(request: Request) {
|
||||
|
||||
transaction = data as Transaction
|
||||
} else {
|
||||
// Schema-validated (amount required, passthrough for optional signal
|
||||
// fields) — the mapping engine only reads the fields it knows.
|
||||
transaction = body as unknown as Transaction
|
||||
}
|
||||
|
||||
@@ -49,4 +46,4 @@ export async function POST(request: Request) {
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,19 +1,10 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { CreateMappingRuleSchema } from '@/lib/api/schemas'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
|
||||
export async function GET() {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const companyId = await requireCompanyId(supabase, user.id)
|
||||
export const GET = withRouteContext('mapping_rules.list', async (_request, ctx) => {
|
||||
const { supabase, companyId } = ctx
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('mapping_rules')
|
||||
@@ -27,52 +18,51 @@ export async function GET() {
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
})
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
export const POST = withRouteContext(
|
||||
'mapping_rules.create',
|
||||
async (request, ctx) => {
|
||||
const { supabase, companyId, user, log } = ctx
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const writeCheck = await requireWritePermission(supabase, user.id)
|
||||
if (!writeCheck.ok) return writeCheck.response
|
||||
|
||||
const companyId = await requireCompanyId(supabase, user.id)
|
||||
|
||||
const result = await validateBody(request, CreateMappingRuleSchema)
|
||||
if (!result.success) return result.response
|
||||
const body = result.data
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('mapping_rules')
|
||||
.insert({
|
||||
user_id: user.id,
|
||||
company_id: companyId,
|
||||
rule_name: body.rule_name,
|
||||
rule_type: body.rule_type,
|
||||
priority: body.priority || 10,
|
||||
mcc_codes: body.mcc_codes || null,
|
||||
merchant_pattern: body.merchant_pattern || null,
|
||||
description_pattern: body.description_pattern || null,
|
||||
amount_min: body.amount_min || null,
|
||||
amount_max: body.amount_max || null,
|
||||
debit_account: body.debit_account,
|
||||
credit_account: body.credit_account,
|
||||
vat_treatment: body.vat_treatment || null,
|
||||
risk_level: body.risk_level || 'NONE',
|
||||
default_private: body.default_private || false,
|
||||
requires_review: body.requires_review || false,
|
||||
confidence_score: body.confidence_score || 0.9,
|
||||
const result = await validateBody(request, CreateMappingRuleSchema, {
|
||||
log,
|
||||
operation: 'mapping_rules.create',
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
if (!result.success) return result.response
|
||||
const body = result.data
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
const { data, error } = await supabase
|
||||
.from('mapping_rules')
|
||||
.insert({
|
||||
user_id: user.id,
|
||||
company_id: companyId,
|
||||
rule_name: body.rule_name,
|
||||
rule_type: body.rule_type,
|
||||
// ?? not || — the schema allows 0 for priority and confidence_score,
|
||||
// and || would silently coerce those to the defaults.
|
||||
priority: body.priority ?? 10,
|
||||
mcc_codes: body.mcc_codes ?? null,
|
||||
merchant_pattern: body.merchant_pattern ?? null,
|
||||
description_pattern: body.description_pattern ?? null,
|
||||
amount_min: body.amount_min ?? null,
|
||||
amount_max: body.amount_max ?? null,
|
||||
debit_account: body.debit_account,
|
||||
credit_account: body.credit_account,
|
||||
vat_treatment: body.vat_treatment ?? null,
|
||||
risk_level: body.risk_level ?? 'NONE',
|
||||
default_private: body.default_private ?? false,
|
||||
requires_review: body.requires_review ?? false,
|
||||
confidence_score: body.confidence_score ?? 0.9,
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
},
|
||||
{ requireWrite: true },
|
||||
)
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Tests for GET /api/bookkeeping/no-doc-required — the exemption-set list.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { createMockRequest, parseJsonResponse } from '@/tests/helpers'
|
||||
|
||||
const requireAuthMock = vi.fn()
|
||||
vi.mock('@/lib/auth/require-auth', () => ({
|
||||
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/company/context', () => ({
|
||||
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
}))
|
||||
|
||||
import { GET } from '../route'
|
||||
|
||||
const routeParams = { params: Promise.resolve({}) }
|
||||
|
||||
function createCapturingSupabase(results: { data?: unknown; error?: unknown }[]) {
|
||||
const calls: { method: string; args: unknown[] }[] = []
|
||||
let idx = 0
|
||||
const makeBuilder = () => {
|
||||
const result = results[idx++] ?? { data: null, error: null }
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const b: any = {}
|
||||
for (const m of ['select', 'eq', 'order', 'range']) {
|
||||
b[m] = (...args: unknown[]) => {
|
||||
calls.push({ method: m, args })
|
||||
return b
|
||||
}
|
||||
}
|
||||
b.then = (resolve: (v: unknown) => void) =>
|
||||
resolve({ data: result.data ?? null, error: result.error ?? null, count: null })
|
||||
return b
|
||||
}
|
||||
return { supabase: { from: () => makeBuilder() }, calls }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('GET /api/bookkeeping/no-doc-required', () => {
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
requireAuthMock.mockResolvedValue({
|
||||
user: null,
|
||||
supabase: {},
|
||||
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
|
||||
})
|
||||
const res = await GET(createMockRequest('/api/bookkeeping/no-doc-required'), routeParams)
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
it('lists exemptions with a stable paging order', async () => {
|
||||
const { supabase, calls } = createCapturingSupabase([
|
||||
{ data: [{ journal_entry_id: 'e1', reason: 'SIE-import' }] },
|
||||
])
|
||||
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase, error: null })
|
||||
|
||||
const { status, body } = await parseJsonResponse<{ data: unknown[] }>(
|
||||
await GET(createMockRequest('/api/bookkeeping/no-doc-required'), routeParams)
|
||||
)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data).toHaveLength(1)
|
||||
// Paging-stability regression guard (fetch-all.ts ordering invariant).
|
||||
expect(calls.filter((c) => c.method === 'order').map((c) => c.args[0])).toContain(
|
||||
'journal_entry_id'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,5 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
||||
|
||||
/**
|
||||
@@ -9,24 +8,20 @@ import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
||||
* - exclude exempted entries from the "Saknade underlag" filter
|
||||
* - show a muted "no doc needed" indicator instead of the warning triangle
|
||||
*/
|
||||
export async function GET() {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const companyId = await requireCompanyId(supabase, user.id)
|
||||
export const GET = withRouteContext('journal_entry.no_doc_required.list', async (_request, ctx) => {
|
||||
const { supabase, companyId } = ctx
|
||||
|
||||
// Stable unique order for .range() paging — bulk exemption after a large
|
||||
// migration can push this table past the 1000-row page size.
|
||||
const rows = await fetchAllRows<{ journal_entry_id: string; reason: string | null }>(
|
||||
({ from, to }) =>
|
||||
supabase
|
||||
.from('journal_entry_no_doc_required')
|
||||
.select('journal_entry_id, reason')
|
||||
.eq('company_id', companyId)
|
||||
.order('journal_entry_id', { ascending: true })
|
||||
.range(from, to)
|
||||
)
|
||||
|
||||
return NextResponse.json({ data: rows })
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,21 +1,19 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { validateBody, validateQuery } from '@/lib/api/validate'
|
||||
import { VoucherGapQuerySchema, SaveGapExplanationSchema } from '@/lib/api/schemas'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
// Voucher gap detection + explanations (BFNAR 2013:2 — gaps in voucher
|
||||
// sequences must be documented). Response shapes are legacy `{ data }` /
|
||||
// `{ error: string }`.
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
export const GET = withRouteContext('voucher_gaps.list', async (request, ctx) => {
|
||||
const { supabase, companyId, log } = ctx
|
||||
|
||||
const companyId = await requireCompanyId(supabase, user.id)
|
||||
|
||||
const validation = validateQuery(request, VoucherGapQuerySchema)
|
||||
const validation = validateQuery(request, VoucherGapQuerySchema, {
|
||||
log,
|
||||
operation: 'voucher_gaps.list',
|
||||
})
|
||||
if (!validation.success) return validation.response
|
||||
const { fiscal_period_id, voucher_series } = validation.data
|
||||
|
||||
@@ -30,7 +28,11 @@ export async function GET(request: Request) {
|
||||
seriesQuery = seriesQuery.eq('voucher_series', voucher_series)
|
||||
}
|
||||
|
||||
const { data: seriesRows } = await seriesQuery
|
||||
const { data: seriesRows, error: seriesError } = await seriesQuery
|
||||
if (seriesError) {
|
||||
log.error('voucher series lookup failed', seriesError)
|
||||
return NextResponse.json({ error: seriesError.message }, { status: 500 })
|
||||
}
|
||||
|
||||
if (!seriesRows || seriesRows.length === 0) {
|
||||
return NextResponse.json({
|
||||
@@ -53,15 +55,20 @@ export async function GET(request: Request) {
|
||||
p_series: row.voucher_series,
|
||||
})
|
||||
|
||||
if (!gapsError && gaps && gaps.length > 0) {
|
||||
for (const gap of gaps as Array<{ gap_start: number; gap_end: number }>) {
|
||||
allGaps.push({
|
||||
series: row.voucher_series,
|
||||
gap_start: gap.gap_start,
|
||||
gap_end: gap.gap_end,
|
||||
explanation: null,
|
||||
})
|
||||
}
|
||||
// A failing detection MUST surface — silently dropping the series would
|
||||
// render "no gaps" on a compliance view when the check didn't run.
|
||||
if (gapsError) {
|
||||
log.error('detect_voucher_gaps failed', gapsError, { series: row.voucher_series })
|
||||
return NextResponse.json({ error: gapsError.message }, { status: 500 })
|
||||
}
|
||||
|
||||
for (const gap of (gaps ?? []) as Array<{ gap_start: number; gap_end: number }>) {
|
||||
allGaps.push({
|
||||
series: row.voucher_series,
|
||||
gap_start: gap.gap_start,
|
||||
gap_end: gap.gap_end,
|
||||
explanation: null,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,52 +109,49 @@ export async function GET(request: Request) {
|
||||
unexplainedGaps: unexplained,
|
||||
},
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
export const POST = withRouteContext(
|
||||
'voucher_gaps.explain',
|
||||
async (request, ctx) => {
|
||||
const { supabase, companyId, user, log } = ctx
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
const validation = await validateBody(request, SaveGapExplanationSchema, {
|
||||
log,
|
||||
operation: 'voucher_gaps.explain',
|
||||
})
|
||||
if (!validation.success) return validation.response
|
||||
const { fiscal_period_id, voucher_series, gap_start, gap_end, explanation } = validation.data
|
||||
|
||||
const writeCheck = await requireWritePermission(supabase, user.id)
|
||||
if (!writeCheck.ok) return writeCheck.response
|
||||
|
||||
const companyId = await requireCompanyId(supabase, user.id)
|
||||
|
||||
const validation = await validateBody(request, SaveGapExplanationSchema)
|
||||
if (!validation.success) return validation.response
|
||||
const { fiscal_period_id, voucher_series, gap_start, gap_end, explanation } = validation.data
|
||||
|
||||
// Upsert explanation (RLS enforces owner/admin role)
|
||||
const { data, error } = await supabase
|
||||
.from('voucher_gap_explanations')
|
||||
.upsert(
|
||||
{
|
||||
company_id: companyId,
|
||||
user_id: user.id,
|
||||
fiscal_period_id,
|
||||
voucher_series,
|
||||
gap_start,
|
||||
gap_end,
|
||||
explanation,
|
||||
},
|
||||
{ onConflict: 'company_id,fiscal_period_id,voucher_series,gap_start,gap_end' }
|
||||
)
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
if (error.code === '42501') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Only company owners and admins can document gap explanations' },
|
||||
{ status: 403 }
|
||||
// Upsert explanation (RLS enforces owner/admin role)
|
||||
const { data, error } = await supabase
|
||||
.from('voucher_gap_explanations')
|
||||
.upsert(
|
||||
{
|
||||
company_id: companyId,
|
||||
user_id: user.id,
|
||||
fiscal_period_id,
|
||||
voucher_series,
|
||||
gap_start,
|
||||
gap_end,
|
||||
explanation,
|
||||
},
|
||||
{ onConflict: 'company_id,fiscal_period_id,voucher_series,gap_start,gap_end' }
|
||||
)
|
||||
}
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
.select()
|
||||
.single()
|
||||
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
if (error) {
|
||||
if (error.code === '42501') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Only company owners and admins can document gap explanations' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
},
|
||||
{ requireWrite: true },
|
||||
)
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { generateCalendarFeed } from '@/lib/calendar/ics-generator'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
|
||||
const log = createLogger('api/calendar/feed-token')
|
||||
|
||||
// In-memory rate limiting: token -> { count, resetAt }
|
||||
const rateLimitMap = new Map<string, { count: number; resetAt: number }>()
|
||||
@@ -141,7 +144,7 @@ export async function GET(
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error generating ICS feed:', error)
|
||||
log.error('Error generating ICS feed', error as Error, { feedId: feed.id })
|
||||
return new NextResponse('Failed to generate calendar feed', { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Tests for /api/calendar/feed (settings CRUD).
|
||||
*
|
||||
* The PUT hardening matters most: the previous implementation passed the raw
|
||||
* JSON body into .update(), letting a caller set feed_token (token fixation
|
||||
* on a public URL). The strict schema must reject any key beyond the two
|
||||
* content toggles.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { createQueuedMockSupabase, createMockRequest, parseJsonResponse } from '@/tests/helpers'
|
||||
|
||||
const { supabase, enqueue, reset } = createQueuedMockSupabase()
|
||||
|
||||
const requireAuthMock = vi.fn()
|
||||
vi.mock('@/lib/auth/require-auth', () => ({
|
||||
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/company/context', () => ({
|
||||
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
}))
|
||||
|
||||
const requireWriteMock = vi.fn()
|
||||
vi.mock('@/lib/auth/require-write', () => ({
|
||||
requireWritePermission: (...args: unknown[]) => requireWriteMock(...args),
|
||||
}))
|
||||
|
||||
import { GET, PUT } from '../route'
|
||||
|
||||
const routeParams = { params: Promise.resolve({}) }
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase, error: null })
|
||||
requireWriteMock.mockResolvedValue({ ok: true })
|
||||
})
|
||||
|
||||
describe('GET /api/calendar/feed', () => {
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
requireAuthMock.mockResolvedValue({
|
||||
user: null,
|
||||
supabase,
|
||||
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
|
||||
})
|
||||
const res = await GET(createMockRequest('/api/calendar/feed'), routeParams)
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
it('returns the feed with generated URLs', async () => {
|
||||
enqueue({ data: { id: 'feed-1', feed_token: 'tok-123', include_invoices: true } })
|
||||
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
data: { webcalUrl: string; httpsUrl: string }
|
||||
}>(await GET(createMockRequest('/api/calendar/feed'), routeParams))
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.httpsUrl).toContain('/api/calendar/feed/tok-123')
|
||||
expect(body.data.webcalUrl).toMatch(/^webcal:\/\//)
|
||||
})
|
||||
})
|
||||
|
||||
describe('PUT /api/calendar/feed', () => {
|
||||
it('rejects an attempt to set feed_token (token fixation) with 400', async () => {
|
||||
const req = createMockRequest('/api/calendar/feed', {
|
||||
method: 'PUT',
|
||||
body: { feed_token: '11111111-1111-1111-1111-111111111111' },
|
||||
})
|
||||
const { status } = await parseJsonResponse(await PUT(req, routeParams))
|
||||
expect(status).toBe(400)
|
||||
})
|
||||
|
||||
it('rejects an empty body with 400', async () => {
|
||||
const req = createMockRequest('/api/calendar/feed', { method: 'PUT', body: {} })
|
||||
const { status } = await parseJsonResponse(await PUT(req, routeParams))
|
||||
expect(status).toBe(400)
|
||||
})
|
||||
|
||||
it('updates the content toggles', async () => {
|
||||
enqueue({ data: { id: 'feed-1', feed_token: 'tok-123', include_invoices: false } })
|
||||
|
||||
const req = createMockRequest('/api/calendar/feed', {
|
||||
method: 'PUT',
|
||||
body: { include_invoices: false },
|
||||
})
|
||||
const { status, body } = await parseJsonResponse<{ data: { include_invoices: boolean } }>(
|
||||
await PUT(req, routeParams)
|
||||
)
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.include_invoices).toBe(false)
|
||||
})
|
||||
})
|
||||
+113
-143
@@ -1,23 +1,37 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
import type { UpdateCalendarFeedInput } from '@/types'
|
||||
import { z } from 'zod'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
|
||||
// Only the two content toggles are user-settable. Strict: the previous
|
||||
// implementation passed the raw JSON body into .update(), which would have
|
||||
// let a caller set feed_token (token fixation on a public URL), expires_at,
|
||||
// or access_count.
|
||||
const UpdateFeedSchema = z
|
||||
.object({
|
||||
include_tax_deadlines: z.boolean().optional(),
|
||||
include_invoices: z.boolean().optional(),
|
||||
})
|
||||
.strict()
|
||||
.refine(
|
||||
(v) => v.include_tax_deadlines !== undefined || v.include_invoices !== undefined,
|
||||
{ message: 'Nothing to update' },
|
||||
)
|
||||
|
||||
function feedUrls(feedToken: string) {
|
||||
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://app.erp-base.se'
|
||||
return {
|
||||
webcalUrl: `webcal://${baseUrl.replace(/^https?:\/\//, '')}/api/calendar/feed/${feedToken}`,
|
||||
httpsUrl: `${baseUrl}/api/calendar/feed/${feedToken}`,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/calendar/feed
|
||||
* Get current user's calendar feed settings
|
||||
*/
|
||||
export async function GET() {
|
||||
const supabase = await createClient()
|
||||
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const companyId = await requireCompanyId(supabase, user.id)
|
||||
export const GET = withRouteContext('calendar_feed.get', async (_request, ctx) => {
|
||||
const { supabase, companyId } = ctx
|
||||
|
||||
const { data: feed, error } = await supabase
|
||||
.from('calendar_feeds')
|
||||
@@ -30,167 +44,123 @@ export async function GET() {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
// Generate the feed URL
|
||||
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://app.erp-base.se'
|
||||
|
||||
if (feed) {
|
||||
return NextResponse.json({
|
||||
data: {
|
||||
...feed,
|
||||
// Generate webcal:// URL for Apple Calendar
|
||||
webcalUrl: `webcal://${baseUrl.replace(/^https?:\/\//, '')}/api/calendar/feed/${feed.feed_token}`,
|
||||
// Generate https:// URL for other calendars
|
||||
httpsUrl: `${baseUrl}/api/calendar/feed/${feed.feed_token}`,
|
||||
},
|
||||
data: { ...feed, ...feedUrls(feed.feed_token) },
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json({ data: null })
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* POST /api/calendar/feed
|
||||
* Create a new calendar feed for the current user
|
||||
*/
|
||||
export async function POST() {
|
||||
const supabase = await createClient()
|
||||
export const POST = withRouteContext(
|
||||
'calendar_feed.create',
|
||||
async (_request, ctx) => {
|
||||
const { supabase, companyId, user } = ctx
|
||||
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
// Check if feed already exists
|
||||
const { data: existingFeed } = await supabase
|
||||
.from('calendar_feeds')
|
||||
.select('id')
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
if (existingFeed) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Calendar feed already exists' },
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
|
||||
const writeCheck = await requireWritePermission(supabase, user.id)
|
||||
if (!writeCheck.ok) return writeCheck.response
|
||||
// Create new feed
|
||||
const { data: feed, error } = await supabase
|
||||
.from('calendar_feeds')
|
||||
.insert({
|
||||
user_id: user.id,
|
||||
company_id: companyId,
|
||||
is_active: true,
|
||||
include_tax_deadlines: true,
|
||||
include_invoices: true,
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
const companyId = await requireCompanyId(supabase, user.id)
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
// Check if feed already exists
|
||||
const { data: existingFeed } = await supabase
|
||||
.from('calendar_feeds')
|
||||
.select('id')
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
|
||||
if (existingFeed) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Calendar feed already exists' },
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
|
||||
// Create new feed
|
||||
const { data: feed, error } = await supabase
|
||||
.from('calendar_feeds')
|
||||
.insert({
|
||||
user_id: user.id,
|
||||
company_id: companyId,
|
||||
is_active: true,
|
||||
include_tax_deadlines: true,
|
||||
include_invoices: true,
|
||||
return NextResponse.json({
|
||||
data: { ...feed, ...feedUrls(feed.feed_token) },
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://app.erp-base.se'
|
||||
|
||||
return NextResponse.json({
|
||||
data: {
|
||||
...feed,
|
||||
webcalUrl: `webcal://${baseUrl.replace(/^https?:\/\//, '')}/api/calendar/feed/${feed.feed_token}`,
|
||||
httpsUrl: `${baseUrl}/api/calendar/feed/${feed.feed_token}`,
|
||||
},
|
||||
})
|
||||
}
|
||||
},
|
||||
{ requireWrite: true },
|
||||
)
|
||||
|
||||
/**
|
||||
* PUT /api/calendar/feed
|
||||
* Update calendar feed settings
|
||||
*/
|
||||
export async function PUT(request: Request) {
|
||||
const supabase = await createClient()
|
||||
export const PUT = withRouteContext(
|
||||
'calendar_feed.update',
|
||||
async (request, ctx) => {
|
||||
const { supabase, companyId, log } = ctx
|
||||
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
const validation = await validateBody(request, UpdateFeedSchema, {
|
||||
log,
|
||||
operation: 'calendar_feed.update',
|
||||
})
|
||||
if (!validation.success) return validation.response
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
const { data: feed, error } = await supabase
|
||||
.from('calendar_feeds')
|
||||
.update(validation.data)
|
||||
.eq('company_id', companyId)
|
||||
.select()
|
||||
.single()
|
||||
|
||||
const writeCheck = await requireWritePermission(supabase, user.id)
|
||||
if (!writeCheck.ok) return writeCheck.response
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
const companyId = await requireCompanyId(supabase, user.id)
|
||||
|
||||
const body: UpdateCalendarFeedInput = await request.json()
|
||||
|
||||
const { data: feed, error } = await supabase
|
||||
.from('calendar_feeds')
|
||||
.update(body)
|
||||
.eq('company_id', companyId)
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://app.erp-base.se'
|
||||
|
||||
return NextResponse.json({
|
||||
data: {
|
||||
...feed,
|
||||
webcalUrl: `webcal://${baseUrl.replace(/^https?:\/\//, '')}/api/calendar/feed/${feed.feed_token}`,
|
||||
httpsUrl: `${baseUrl}/api/calendar/feed/${feed.feed_token}`,
|
||||
},
|
||||
})
|
||||
}
|
||||
return NextResponse.json({
|
||||
data: { ...feed, ...feedUrls(feed.feed_token) },
|
||||
})
|
||||
},
|
||||
{ requireWrite: true },
|
||||
)
|
||||
|
||||
/**
|
||||
* DELETE /api/calendar/feed
|
||||
* Regenerate calendar feed token (invalidates old URL)
|
||||
*/
|
||||
export async function DELETE() {
|
||||
const supabase = await createClient()
|
||||
export const DELETE = withRouteContext(
|
||||
'calendar_feed.rotate_token',
|
||||
async (_request, ctx) => {
|
||||
const { supabase, companyId } = ctx
|
||||
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
// Generate a new token by updating with a new UUID
|
||||
const { data: feed, error } = await supabase
|
||||
.from('calendar_feeds')
|
||||
.update({
|
||||
feed_token: crypto.randomUUID(),
|
||||
access_count: 0,
|
||||
last_accessed_at: null,
|
||||
})
|
||||
.eq('company_id', companyId)
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
const writeCheck = await requireWritePermission(supabase, user.id)
|
||||
if (!writeCheck.ok) return writeCheck.response
|
||||
|
||||
const companyId = await requireCompanyId(supabase, user.id)
|
||||
|
||||
// Generate a new token by updating with a new UUID
|
||||
const { data: feed, error } = await supabase
|
||||
.from('calendar_feeds')
|
||||
.update({
|
||||
feed_token: crypto.randomUUID(),
|
||||
access_count: 0,
|
||||
last_accessed_at: null,
|
||||
return NextResponse.json({
|
||||
data: { ...feed, ...feedUrls(feed.feed_token) },
|
||||
})
|
||||
.eq('company_id', companyId)
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://app.erp-base.se'
|
||||
|
||||
return NextResponse.json({
|
||||
data: {
|
||||
...feed,
|
||||
webcalUrl: `webcal://${baseUrl.replace(/^https?:\/\//, '')}/api/calendar/feed/${feed.feed_token}`,
|
||||
httpsUrl: `${baseUrl}/api/calendar/feed/${feed.feed_token}`,
|
||||
},
|
||||
})
|
||||
}
|
||||
},
|
||||
{ requireWrite: true },
|
||||
)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getActiveCompanyId } from '@/lib/company/context'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { listForCompany } from '@/lib/cash-accounts/service'
|
||||
|
||||
/**
|
||||
@@ -15,19 +14,12 @@ import { listForCompany } from '@/lib/cash-accounts/service'
|
||||
* Query params:
|
||||
* - enabled_only=true → only accounts with enabled=true (default returns all)
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
|
||||
const companyId = await getActiveCompanyId(supabase, user.id)
|
||||
if (!companyId) {
|
||||
return NextResponse.json({ error: 'No company context' }, { status: 400 })
|
||||
}
|
||||
export const GET = withRouteContext('cash_accounts.list', async (request, ctx) => {
|
||||
const { supabase, companyId } = ctx
|
||||
|
||||
const url = new URL(request.url)
|
||||
const enabledOnly = url.searchParams.get('enabled_only') === 'true'
|
||||
|
||||
const accounts = await listForCompany(supabase, companyId, { enabledOnly })
|
||||
return NextResponse.json({ data: accounts })
|
||||
}
|
||||
})
|
||||
|
||||
@@ -25,10 +25,11 @@ const DeleteCompanySchema = z.object({
|
||||
*
|
||||
* Rules:
|
||||
* - Only callers with role='owner' in company_members may delete.
|
||||
* - The body must include confirm_name matching the company's display name.
|
||||
* The UI shows company_settings.company_name (companies.name may be stale),
|
||||
* so we validate against that, falling back to companies.name. Either value
|
||||
* is accepted so the confirm gate never blocks a legitimate deletion.
|
||||
* - The body must include confirm_name matching the company's display name
|
||||
* exactly as the UI shows it: company_settings.company_name, falling back
|
||||
* to companies.name only when no settings row exists. ONLY that single
|
||||
* name is accepted (see step 3) — accepting alternates would weaken the
|
||||
* confirmation gate on an irreversible action.
|
||||
* - Already-archived companies return 404 (treated as not found).
|
||||
*/
|
||||
export async function POST(
|
||||
@@ -137,7 +138,9 @@ export async function POST(
|
||||
|
||||
// 6. Write audit log row. companies has no auto-audit trigger, so do it
|
||||
// explicitly. Service client bypasses audit_log RLS (no INSERT policy).
|
||||
await service.from('audit_log').insert({
|
||||
// The archive already happened — don't fail the request, but an audit
|
||||
// write failing on an irreversible action must never be silent.
|
||||
const { error: auditError } = await service.from('audit_log').insert({
|
||||
user_id: user.id,
|
||||
company_id: companyId,
|
||||
action: 'DELETE',
|
||||
@@ -148,6 +151,12 @@ export async function POST(
|
||||
new_state: { archived_at: archivedAt, archived_by: user.id },
|
||||
description: `Company archived: ${company.name}`,
|
||||
})
|
||||
if (auditError) {
|
||||
log.error('Failed to write audit_log row for company archive', {
|
||||
companyId,
|
||||
error: auditError.message,
|
||||
})
|
||||
}
|
||||
|
||||
// 7. Emit event
|
||||
await eventBus.emit({
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Tests for /api/company/current — GET (cross-tab sync) and PATCH (K2/K3).
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { createQueuedMockSupabase, createMockRequest, parseJsonResponse } from '@/tests/helpers'
|
||||
|
||||
const { supabase, enqueue, reset } = createQueuedMockSupabase()
|
||||
|
||||
const requireAuthMock = vi.fn()
|
||||
vi.mock('@/lib/auth/require-auth', () => ({
|
||||
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
|
||||
}))
|
||||
|
||||
const getActiveCompanyIdMock = vi.fn()
|
||||
vi.mock('@/lib/company/context', () => ({
|
||||
getActiveCompanyId: (...args: unknown[]) => getActiveCompanyIdMock(...args),
|
||||
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
}))
|
||||
|
||||
const requireWriteMock = vi.fn()
|
||||
vi.mock('@/lib/auth/require-write', () => ({
|
||||
requireWritePermission: (...args: unknown[]) => requireWriteMock(...args),
|
||||
}))
|
||||
|
||||
import { GET, PATCH } from '../route'
|
||||
|
||||
const routeParams = { params: Promise.resolve({}) }
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase, error: null })
|
||||
requireWriteMock.mockResolvedValue({ ok: true })
|
||||
getActiveCompanyIdMock.mockResolvedValue('company-1')
|
||||
})
|
||||
|
||||
describe('GET /api/company/current', () => {
|
||||
it('returns 401 with no-store when unauthenticated', async () => {
|
||||
requireAuthMock.mockResolvedValue({
|
||||
user: null,
|
||||
supabase,
|
||||
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
|
||||
})
|
||||
const res = await GET()
|
||||
expect(res.status).toBe(401)
|
||||
expect(res.headers.get('Cache-Control')).toBe('private, no-store')
|
||||
})
|
||||
|
||||
it('returns null companyId when the user has no active company', async () => {
|
||||
getActiveCompanyIdMock.mockResolvedValue(null)
|
||||
const { status, body } = await parseJsonResponse<{ companyId: string | null }>(await GET())
|
||||
expect(status).toBe(200)
|
||||
expect(body.companyId).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('PATCH /api/company/current', () => {
|
||||
it('rejects K3 for enskild firma with 400', async () => {
|
||||
enqueue({ data: { entity_type: 'enskild_firma' } })
|
||||
|
||||
const req = createMockRequest('/api/company/current', {
|
||||
method: 'PATCH',
|
||||
body: { accounting_framework: 'k3' },
|
||||
})
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(
|
||||
await PATCH(req, routeParams)
|
||||
)
|
||||
expect(status).toBe(400)
|
||||
expect(body.error).toContain('aktiebolag')
|
||||
})
|
||||
|
||||
it('updates the framework for an aktiebolag', async () => {
|
||||
enqueue({ data: { entity_type: 'aktiebolag' } }) // entity check
|
||||
enqueue({ data: { id: 'company-1', accounting_framework: 'k3', entity_type: 'aktiebolag' } }) // update
|
||||
enqueue({ data: null }) // K3 latent-tax account upsert
|
||||
|
||||
const req = createMockRequest('/api/company/current', {
|
||||
method: 'PATCH',
|
||||
body: { accounting_framework: 'k3' },
|
||||
})
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
data: { accounting_framework: string }
|
||||
}>(await PATCH(req, routeParams))
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.accounting_framework).toBe('k3')
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { getActiveCompanyId, requireCompanyId } from '@/lib/company/context'
|
||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
import { getActiveCompanyId } from '@/lib/company/context'
|
||||
import { requireAuth } from '@/lib/auth/require-auth'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { AccountingFrameworkSchema } from '@/lib/api/schemas'
|
||||
import { getBASReference } from '@/lib/bookkeeping/bas-reference'
|
||||
@@ -27,20 +27,17 @@ const K3_LATENT_TAX_ACCOUNTS = ['2240', '8940'] as const
|
||||
*
|
||||
* Never cached: the whole point is that the response reflects the current
|
||||
* authoritative value in user_preferences.
|
||||
*
|
||||
* Uses requireAuth() directly (not withRouteContext): a null companyId is a
|
||||
* valid answer here — the wrapper would short-circuit it into an error.
|
||||
*/
|
||||
export async function GET() {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Unauthorized' },
|
||||
{
|
||||
status: 401,
|
||||
headers: { 'Cache-Control': 'private, no-store' },
|
||||
},
|
||||
)
|
||||
const auth = await requireAuth()
|
||||
if (auth.error) {
|
||||
auth.error.headers.set('Cache-Control', 'private, no-store')
|
||||
return auth.error
|
||||
}
|
||||
const { user, supabase } = auth
|
||||
|
||||
const companyId = await getActiveCompanyId(supabase, user.id)
|
||||
|
||||
@@ -72,17 +69,10 @@ const PatchBodySchema = z.object({
|
||||
* entity_type='aktiebolag'. The handler rejects K3 for non-AB to prevent
|
||||
* impossible chart-of-accounts states downstream.
|
||||
*/
|
||||
export async function PATCH(request: Request) {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const writeCheck = await requireWritePermission(supabase, user.id)
|
||||
if (!writeCheck.ok) return writeCheck.response
|
||||
|
||||
const companyId = await requireCompanyId(supabase, user.id)
|
||||
export const PATCH = withRouteContext(
|
||||
'company.update_current',
|
||||
async (request, ctx) => {
|
||||
const { supabase, companyId, user } = ctx
|
||||
|
||||
const validation = await validateBody(request, PatchBodySchema)
|
||||
if (!validation.success) return validation.response
|
||||
@@ -188,4 +178,6 @@ export async function PATCH(request: Request) {
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
},
|
||||
{ requireWrite: true },
|
||||
)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { createClient, createServiceClient } from '@/lib/supabase/server'
|
||||
import { createServiceClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
|
||||
/**
|
||||
* DELETE /api/company/members/[id]
|
||||
@@ -9,18 +8,10 @@ import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
* Only company owners and admins can remove members.
|
||||
* Cannot remove team-sourced members (they must be removed from the team).
|
||||
*/
|
||||
export async function DELETE(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
|
||||
const writeCheck = await requireWritePermission(supabase, user.id)
|
||||
if (!writeCheck.ok) return writeCheck.response
|
||||
|
||||
const companyId = await requireCompanyId(supabase, user.id)
|
||||
export const DELETE = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
'company_members.remove',
|
||||
async (_request, ctx, { params }) => {
|
||||
const { companyId, user } = ctx
|
||||
const { id: memberId } = await params
|
||||
const serviceClient = await createServiceClient()
|
||||
|
||||
@@ -87,4 +78,6 @@ export async function DELETE(
|
||||
}
|
||||
|
||||
return NextResponse.json({ data: { removed: memberId } })
|
||||
}
|
||||
},
|
||||
{ requireWrite: true },
|
||||
)
|
||||
|
||||
@@ -1,25 +1,16 @@
|
||||
import { createClient, createServiceClient } from '@/lib/supabase/server'
|
||||
import { createServiceClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
|
||||
/**
|
||||
* DELETE /api/company/members/invite/[id]
|
||||
* Revoke a pending company invitation.
|
||||
* Only company owners and admins can revoke.
|
||||
*/
|
||||
export async function DELETE(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
|
||||
const writeCheck = await requireWritePermission(supabase, user.id)
|
||||
if (!writeCheck.ok) return writeCheck.response
|
||||
|
||||
const companyId = await requireCompanyId(supabase, user.id)
|
||||
export const DELETE = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
'company_members.revoke_invite',
|
||||
async (_request, ctx, { params }) => {
|
||||
const { companyId, user } = ctx
|
||||
const { id: inviteId } = await params
|
||||
const serviceClient = await createServiceClient()
|
||||
|
||||
@@ -63,4 +54,6 @@ export async function DELETE(
|
||||
}
|
||||
|
||||
return NextResponse.json({ data: { revoked: inviteId } })
|
||||
}
|
||||
},
|
||||
{ requireWrite: true },
|
||||
)
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* Tests for POST /api/company/members/invite.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { createQueuedMockSupabase, createMockRequest, parseJsonResponse } from '@/tests/helpers'
|
||||
|
||||
const { supabase: serviceSupabase, enqueue, reset } = createQueuedMockSupabase()
|
||||
|
||||
const requireAuthMock = vi.fn()
|
||||
vi.mock('@/lib/auth/require-auth', () => ({
|
||||
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/company/context', () => ({
|
||||
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
}))
|
||||
|
||||
const requireWriteMock = vi.fn()
|
||||
vi.mock('@/lib/auth/require-write', () => ({
|
||||
requireWritePermission: (...args: unknown[]) => requireWriteMock(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/supabase/server', () => ({
|
||||
createServiceClient: () => serviceSupabase,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() }))
|
||||
|
||||
vi.mock('@/lib/auth/invite-tokens', () => ({
|
||||
generateInviteToken: () => ({ token: 'tok-plain', hash: 'tok-hash' }),
|
||||
getInviteExpiry: () => new Date('2026-08-01T00:00:00Z'),
|
||||
}))
|
||||
|
||||
const sendEmailMock = vi.fn()
|
||||
const isConfiguredMock = vi.fn()
|
||||
vi.mock('@/lib/email/service', () => ({
|
||||
getEmailService: () => ({ isConfigured: isConfiguredMock, sendEmail: sendEmailMock }),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/email/invite-templates', () => ({
|
||||
generateInviteEmailSubject: () => 'subject',
|
||||
generateInviteEmailHtml: () => '<p>html</p>',
|
||||
generateInviteEmailText: () => 'text',
|
||||
}))
|
||||
|
||||
import { POST } from '../route'
|
||||
|
||||
const routeParams = { params: Promise.resolve({}) }
|
||||
|
||||
function post(body: unknown) {
|
||||
return POST(
|
||||
createMockRequest('/api/company/members/invite', { method: 'POST', body }),
|
||||
routeParams,
|
||||
)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
requireAuthMock.mockResolvedValue({
|
||||
user: { id: 'user-1', email: 'owner@example.com' },
|
||||
supabase: {},
|
||||
error: null,
|
||||
})
|
||||
requireWriteMock.mockResolvedValue({ ok: true })
|
||||
isConfiguredMock.mockReturnValue(true)
|
||||
sendEmailMock.mockResolvedValue({ success: true, messageId: 'msg-1' })
|
||||
})
|
||||
|
||||
describe('POST /api/company/members/invite', () => {
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
requireAuthMock.mockResolvedValue({
|
||||
user: null,
|
||||
supabase: {},
|
||||
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
|
||||
})
|
||||
const res = await post({ email: 'x@y.se' })
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
it('refuses non-admin members with 403', async () => {
|
||||
enqueue({ data: { role: 'member' } }) // caller membership
|
||||
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(
|
||||
await post({ email: 'x@y.se' })
|
||||
)
|
||||
expect(status).toBe(403)
|
||||
expect(body.error).toBe('Behörighet saknas.')
|
||||
})
|
||||
|
||||
it('rejects an invalid email with 400', async () => {
|
||||
enqueue({ data: { role: 'owner' } })
|
||||
const { status } = await parseJsonResponse(await post({ email: 'not-an-email' }))
|
||||
expect(status).toBe(400)
|
||||
})
|
||||
|
||||
it('rejects an unknown role with 400', async () => {
|
||||
enqueue({ data: { role: 'owner' } })
|
||||
const { status } = await parseJsonResponse(
|
||||
await post({ email: 'x@y.se', role: 'superuser' })
|
||||
)
|
||||
expect(status).toBe(400)
|
||||
})
|
||||
|
||||
it('creates the invitation and reports email_sent', async () => {
|
||||
enqueue({ data: { role: 'owner' } }) // caller membership
|
||||
enqueue({ data: [] }) // existing members
|
||||
enqueue({ data: null }) // existing invite
|
||||
enqueue({ data: { name: 'Acme AB' } }) // company name
|
||||
enqueue({ data: null }) // insert invitation
|
||||
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
data: { email: string; email_sent: boolean }
|
||||
}>(await post({ email: 'Client@Example.com', role: 'viewer' }))
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.email).toBe('client@example.com') // normalized
|
||||
expect(body.data.email_sent).toBe(true)
|
||||
expect(sendEmailMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ to: 'client@example.com' })
|
||||
)
|
||||
})
|
||||
|
||||
it('reports email_sent=false when the send fails (invite still created)', async () => {
|
||||
enqueue({ data: { role: 'owner' } })
|
||||
enqueue({ data: [] })
|
||||
enqueue({ data: null })
|
||||
enqueue({ data: { name: 'Acme AB' } })
|
||||
enqueue({ data: null })
|
||||
sendEmailMock.mockResolvedValue({ success: false, error: 'smtp down' })
|
||||
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
data: { email_sent: boolean; status: string }
|
||||
}>(await post({ email: 'client@example.com' }))
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.status).toBe('pending')
|
||||
expect(body.data.email_sent).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,8 +1,9 @@
|
||||
import { createClient, createServiceClient } from '@/lib/supabase/server'
|
||||
import { createServiceClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { generateInviteToken, getInviteExpiry } from '@/lib/auth/invite-tokens'
|
||||
import { getEmailService } from '@/lib/email/service'
|
||||
import {
|
||||
@@ -17,168 +18,163 @@ import {
|
||||
// init'd route in the process.
|
||||
ensureInitialized()
|
||||
|
||||
const InviteSchema = z.object({
|
||||
email: z.string().trim().toLowerCase().pipe(z.string().email('Ogiltig e-postadress.')),
|
||||
role: z.enum(['admin', 'member', 'viewer']).default('viewer'),
|
||||
})
|
||||
|
||||
/**
|
||||
* POST /api/company/members/invite
|
||||
* Invite a user to the current company (e.g., a client as viewer).
|
||||
* Only company owners and admins can invite.
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
export const POST = withRouteContext(
|
||||
'company_members.invite',
|
||||
async (request, ctx) => {
|
||||
const { companyId, user, log } = ctx
|
||||
const serviceClient = await createServiceClient()
|
||||
|
||||
const writeCheck = await requireWritePermission(supabase, user.id)
|
||||
if (!writeCheck.ok) return writeCheck.response
|
||||
// Check caller has permission (owner/admin — stricter than requireWrite)
|
||||
const { data: callerMembership } = await serviceClient
|
||||
.from('company_members')
|
||||
.select('role')
|
||||
.eq('company_id', companyId)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
const companyId = await requireCompanyId(supabase, user.id)
|
||||
const serviceClient = await createServiceClient()
|
||||
|
||||
// Check caller has permission
|
||||
const { data: callerMembership } = await serviceClient
|
||||
.from('company_members')
|
||||
.select('role')
|
||||
.eq('company_id', companyId)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (!callerMembership || !['owner', 'admin'].includes(callerMembership.role)) {
|
||||
return NextResponse.json({ error: 'Behörighet saknas.' }, { status: 403 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const email = (body.email as string || '').trim().toLowerCase()
|
||||
const role = (body.role as string) || 'viewer'
|
||||
|
||||
if (!email || !email.includes('@')) {
|
||||
return NextResponse.json({ error: 'Ogiltig e-postadress.' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (!['admin', 'member', 'viewer'].includes(role)) {
|
||||
return NextResponse.json({ error: 'Ogiltig roll.' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Check if email is already a member of this company
|
||||
const { data: existingMembers } = await serviceClient
|
||||
.from('company_members')
|
||||
.select('id, user_id')
|
||||
.eq('company_id', companyId)
|
||||
|
||||
if (existingMembers && existingMembers.length > 0) {
|
||||
const memberUserIds = existingMembers.map((m) => m.user_id)
|
||||
const { data: memberProfiles } = await serviceClient
|
||||
.from('profiles')
|
||||
.select('id, email')
|
||||
.in('id', memberUserIds)
|
||||
|
||||
const alreadyMember = memberProfiles?.some(
|
||||
(p) => p.email?.toLowerCase() === email
|
||||
)
|
||||
if (alreadyMember) {
|
||||
return NextResponse.json({ error: 'Denna person är redan medlem.' }, { status: 409 })
|
||||
}
|
||||
}
|
||||
|
||||
// Check for existing pending invite
|
||||
const { data: existingInvite } = await serviceClient
|
||||
.from('company_invitations')
|
||||
.select('id, status')
|
||||
.eq('company_id', companyId)
|
||||
.eq('email', email)
|
||||
.single()
|
||||
|
||||
if (existingInvite && existingInvite.status === 'pending') {
|
||||
return NextResponse.json({ error: 'En inbjudan har redan skickats till denna e-post.' }, { status: 409 })
|
||||
}
|
||||
|
||||
// Get company name for the email
|
||||
const { data: company } = await serviceClient
|
||||
.from('companies')
|
||||
.select('name')
|
||||
.eq('id', companyId)
|
||||
.single()
|
||||
|
||||
// Generate token
|
||||
const { token, hash } = generateInviteToken()
|
||||
const expiresAt = getInviteExpiry()
|
||||
|
||||
// Upsert invitation
|
||||
if (existingInvite) {
|
||||
const { error } = await serviceClient
|
||||
.from('company_invitations')
|
||||
.update({
|
||||
token_hash: hash,
|
||||
invited_by: user.id,
|
||||
status: 'pending',
|
||||
expires_at: expiresAt.toISOString(),
|
||||
role,
|
||||
})
|
||||
.eq('id', existingInvite.id)
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: 'Kunde inte skapa inbjudan.' }, { status: 500 })
|
||||
}
|
||||
} else {
|
||||
const { error } = await serviceClient
|
||||
.from('company_invitations')
|
||||
.insert({
|
||||
company_id: companyId,
|
||||
email,
|
||||
role,
|
||||
token_hash: hash,
|
||||
invited_by: user.id,
|
||||
status: 'pending',
|
||||
expires_at: expiresAt.toISOString(),
|
||||
})
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: 'Kunde inte skapa inbjudan.' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
// Send email
|
||||
const appUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'
|
||||
const emailService = getEmailService()
|
||||
if (emailService.isConfigured()) {
|
||||
const inviteUrl = `${appUrl}/invite/${token}`
|
||||
|
||||
const emailData = {
|
||||
companyName: company?.name || 'Företag',
|
||||
inviterEmail: user.email || '',
|
||||
inviteUrl,
|
||||
if (!callerMembership || !['owner', 'admin'].includes(callerMembership.role)) {
|
||||
return NextResponse.json({ error: 'Behörighet saknas.' }, { status: 403 })
|
||||
}
|
||||
|
||||
console.log('[company/members/invite] sending email', {
|
||||
to: email,
|
||||
company: emailData.companyName,
|
||||
from: user.email,
|
||||
const validation = await validateBody(request, InviteSchema, {
|
||||
log,
|
||||
operation: 'company_members.invite',
|
||||
})
|
||||
if (!validation.success) return validation.response
|
||||
const { email, role } = validation.data
|
||||
|
||||
const result = await emailService.sendEmail({
|
||||
to: email,
|
||||
subject: generateInviteEmailSubject(emailData),
|
||||
html: generateInviteEmailHtml(emailData),
|
||||
text: generateInviteEmailText(emailData),
|
||||
})
|
||||
// Check if email is already a member of this company
|
||||
const { data: existingMembers } = await serviceClient
|
||||
.from('company_members')
|
||||
.select('id, user_id')
|
||||
.eq('company_id', companyId)
|
||||
|
||||
if (result.success) {
|
||||
console.log('[company/members/invite] email sent', {
|
||||
to: email,
|
||||
messageId: result.messageId,
|
||||
})
|
||||
if (existingMembers && existingMembers.length > 0) {
|
||||
const memberUserIds = existingMembers.map((m) => m.user_id)
|
||||
const { data: memberProfiles } = await serviceClient
|
||||
.from('profiles')
|
||||
.select('id, email')
|
||||
.in('id', memberUserIds)
|
||||
|
||||
const alreadyMember = memberProfiles?.some(
|
||||
(p) => p.email?.toLowerCase() === email
|
||||
)
|
||||
if (alreadyMember) {
|
||||
return NextResponse.json({ error: 'Denna person är redan medlem.' }, { status: 409 })
|
||||
}
|
||||
}
|
||||
|
||||
// Check for existing pending invite
|
||||
const { data: existingInvite } = await serviceClient
|
||||
.from('company_invitations')
|
||||
.select('id, status')
|
||||
.eq('company_id', companyId)
|
||||
.eq('email', email)
|
||||
.single()
|
||||
|
||||
if (existingInvite && existingInvite.status === 'pending') {
|
||||
return NextResponse.json({ error: 'En inbjudan har redan skickats till denna e-post.' }, { status: 409 })
|
||||
}
|
||||
|
||||
// Get company name for the email
|
||||
const { data: company } = await serviceClient
|
||||
.from('companies')
|
||||
.select('name')
|
||||
.eq('id', companyId)
|
||||
.single()
|
||||
|
||||
// Generate token
|
||||
const { token, hash } = generateInviteToken()
|
||||
const expiresAt = getInviteExpiry()
|
||||
|
||||
// Upsert invitation
|
||||
if (existingInvite) {
|
||||
const { error } = await serviceClient
|
||||
.from('company_invitations')
|
||||
.update({
|
||||
token_hash: hash,
|
||||
invited_by: user.id,
|
||||
status: 'pending',
|
||||
expires_at: expiresAt.toISOString(),
|
||||
role,
|
||||
})
|
||||
.eq('id', existingInvite.id)
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: 'Kunde inte skapa inbjudan.' }, { status: 500 })
|
||||
}
|
||||
} else {
|
||||
console.error('[company/members/invite] email send failed:', result.error)
|
||||
const { error } = await serviceClient
|
||||
.from('company_invitations')
|
||||
.insert({
|
||||
company_id: companyId,
|
||||
email,
|
||||
role,
|
||||
token_hash: hash,
|
||||
invited_by: user.id,
|
||||
status: 'pending',
|
||||
expires_at: expiresAt.toISOString(),
|
||||
})
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: 'Kunde inte skapa inbjudan.' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.warn('[company/members/invite] email service not configured: skipping send', {
|
||||
to: email,
|
||||
|
||||
// Send email. email_sent is surfaced in the response so the UI can tell
|
||||
// the user when the invitation exists but the mail never went out:
|
||||
// previously a send failure was invisible (invite looked sent).
|
||||
const appUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'
|
||||
const emailService = getEmailService()
|
||||
let emailSent = false
|
||||
if (emailService.isConfigured()) {
|
||||
const inviteUrl = `${appUrl}/invite/${token}`
|
||||
|
||||
const emailData = {
|
||||
companyName: company?.name || 'Företag',
|
||||
inviterEmail: user.email || '',
|
||||
inviteUrl,
|
||||
}
|
||||
|
||||
const result = await emailService.sendEmail({
|
||||
to: email,
|
||||
subject: generateInviteEmailSubject(emailData),
|
||||
html: generateInviteEmailHtml(emailData),
|
||||
text: generateInviteEmailText(emailData),
|
||||
})
|
||||
|
||||
if (result.success) {
|
||||
emailSent = true
|
||||
log.info('invite email sent', { to: email, messageId: result.messageId })
|
||||
} else {
|
||||
log.error('invite email send failed', new Error(result.error ?? 'unknown'), { to: email })
|
||||
}
|
||||
} else {
|
||||
log.warn('email service not configured: invite email skipped', { to: email })
|
||||
}
|
||||
|
||||
// In development, return the invite URL directly (no email service)
|
||||
const isDev = process.env.NODE_ENV === 'development'
|
||||
const devInviteUrl = isDev ? `${appUrl}/invite/${token}` : undefined
|
||||
|
||||
return NextResponse.json({
|
||||
data: {
|
||||
email,
|
||||
status: 'pending',
|
||||
email_sent: emailSent,
|
||||
...(isDev && { inviteUrl: devInviteUrl }),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// In development, return the invite URL directly (no email service)
|
||||
const isDev = process.env.NODE_ENV === 'development'
|
||||
const devInviteUrl = isDev ? `${appUrl}/invite/${token}` : undefined
|
||||
|
||||
return NextResponse.json({
|
||||
data: { email, status: 'pending', ...(isDev && { inviteUrl: devInviteUrl }) },
|
||||
})
|
||||
}
|
||||
},
|
||||
{ requireWrite: true },
|
||||
)
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
import { createClient, createServiceClient } from '@/lib/supabase/server'
|
||||
import { createServiceClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
|
||||
/**
|
||||
* GET /api/company/members
|
||||
* Returns members and pending invitations for the current company.
|
||||
* Service client on purpose: profiles/emails of other members aren't readable
|
||||
* through the caller's RLS context; every query still scopes by companyId.
|
||||
*/
|
||||
export async function GET() {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
|
||||
const companyId = await requireCompanyId(supabase, user.id)
|
||||
export const GET = withRouteContext('company_members.list', async (_request, ctx) => {
|
||||
const { companyId, user } = ctx
|
||||
const serviceClient = await createServiceClient()
|
||||
|
||||
// Fetch members (source column may not exist if migration not yet applied)
|
||||
@@ -77,4 +75,4 @@ export async function GET() {
|
||||
canInvite,
|
||||
},
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { requireAuth } from '@/lib/auth/require-auth'
|
||||
|
||||
/**
|
||||
* GET /api/company?owned=true&archived=false
|
||||
@@ -9,12 +9,13 @@ import { NextResponse } from 'next/server'
|
||||
* - archived=false → only non-archived companies (default)
|
||||
*
|
||||
* Used by the account danger zone to show a blockers list before
|
||||
* allowing account deletion.
|
||||
* allowing account deletion. User-level (spans ALL memberships), so it uses
|
||||
* requireAuth() directly — no single active-company context applies.
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
const auth = await requireAuth()
|
||||
if (auth.error) return auth.error
|
||||
const { user, supabase } = auth
|
||||
|
||||
const url = new URL(request.url)
|
||||
const ownedOnly = url.searchParams.get('owned') === 'true'
|
||||
|
||||
@@ -1,26 +1,17 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { fetchExchangeRate } from '@/lib/currency/riksbanken'
|
||||
import { getActiveCompanyId } from '@/lib/company/context'
|
||||
import { guardSandbox } from '@/lib/sandbox/guard'
|
||||
import type { Currency } from '@/types'
|
||||
|
||||
const VALID_CURRENCIES: Currency[] = ['EUR', 'USD', 'GBP', 'NOK', 'DKK']
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
// Riksbanken's open API is IP rate-limited — the sandbox guard keeps demo
|
||||
// traffic from eating that budget (withRouteContext already refuses
|
||||
// sessions without an active company).
|
||||
export const GET = withRouteContext('currency.rate', async (request, ctx) => {
|
||||
const { supabase, companyId } = ctx
|
||||
|
||||
const companyId = await getActiveCompanyId(supabase, user.id)
|
||||
// Refuse the request when no active company resolves rather than letting
|
||||
// a session without one slip past the sandbox guard. Riksbanken's open
|
||||
// API is IP rate-limited; we don't want demo traffic eating that budget.
|
||||
if (!companyId) {
|
||||
return NextResponse.json({ error: 'No active company' }, { status: 400 })
|
||||
}
|
||||
const blocked = await guardSandbox(supabase, companyId)
|
||||
if (blocked) return blocked
|
||||
|
||||
@@ -32,6 +23,12 @@ export async function GET(request: Request) {
|
||||
return NextResponse.json({ error: 'Invalid currency' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Reject malformed dates up front — an Invalid Date would otherwise reach
|
||||
// the Riksbanken request as "NaN-NaN-NaN".
|
||||
if (dateStr && !/^\d{4}-\d{2}-\d{2}$/.test(dateStr)) {
|
||||
return NextResponse.json({ error: 'Invalid date (expected YYYY-MM-DD)' }, { status: 400 })
|
||||
}
|
||||
|
||||
const date = dateStr ? new Date(dateStr) : undefined
|
||||
const rate = await fetchExchangeRate(currency, date)
|
||||
|
||||
@@ -40,4 +37,4 @@ export async function GET(request: Request) {
|
||||
}
|
||||
|
||||
return NextResponse.json({ data: rate })
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Tests for /api/deadlines/[id] — validated PUT and count-checked DELETE.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { createMockRequest, parseJsonResponse } from '@/tests/helpers'
|
||||
|
||||
const requireAuthMock = vi.fn()
|
||||
vi.mock('@/lib/auth/require-auth', () => ({
|
||||
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/company/context', () => ({
|
||||
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
}))
|
||||
|
||||
const requireWriteMock = vi.fn()
|
||||
vi.mock('@/lib/auth/require-write', () => ({
|
||||
requireWritePermission: (...args: unknown[]) => requireWriteMock(...args),
|
||||
}))
|
||||
|
||||
import { PUT, DELETE } from '../route'
|
||||
|
||||
const idParams = { params: Promise.resolve({ id: 'deadline-1' }) }
|
||||
|
||||
function createCapturingSupabase(
|
||||
results: { data?: unknown; error?: unknown; count?: number | null }[]
|
||||
) {
|
||||
let idx = 0
|
||||
const makeBuilder = () => {
|
||||
const result = results[idx++] ?? { data: null, error: null, count: null }
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const b: any = {}
|
||||
for (const m of ['select', 'eq', 'update', 'delete', 'single', 'maybeSingle']) {
|
||||
b[m] = () => b
|
||||
}
|
||||
b.then = (resolve: (v: unknown) => void) =>
|
||||
resolve({ data: result.data ?? null, error: result.error ?? null, count: result.count ?? null })
|
||||
return b
|
||||
}
|
||||
return { from: () => makeBuilder() }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
requireWriteMock.mockResolvedValue({ ok: true })
|
||||
})
|
||||
|
||||
function auth(supabase: unknown) {
|
||||
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase, error: null })
|
||||
}
|
||||
|
||||
describe('PUT /api/deadlines/[id]', () => {
|
||||
it('rejects a malformed body (bad due_date) with 400', async () => {
|
||||
auth(createCapturingSupabase([]))
|
||||
const req = createMockRequest('/api/deadlines/deadline-1', {
|
||||
method: 'PUT',
|
||||
body: { due_date: 'banana' },
|
||||
})
|
||||
const { status } = await parseJsonResponse(await PUT(req, idParams))
|
||||
expect(status).toBe(400)
|
||||
})
|
||||
|
||||
it('rejects an empty body with 400', async () => {
|
||||
auth(createCapturingSupabase([]))
|
||||
const req = createMockRequest('/api/deadlines/deadline-1', { method: 'PUT', body: {} })
|
||||
const { status } = await parseJsonResponse(await PUT(req, idParams))
|
||||
expect(status).toBe(400)
|
||||
})
|
||||
|
||||
it('maps zero-rows to 404', async () => {
|
||||
auth(createCapturingSupabase([{ error: { code: 'PGRST116', message: 'no rows' } }]))
|
||||
const req = createMockRequest('/api/deadlines/deadline-1', {
|
||||
method: 'PUT',
|
||||
body: { title: 'Momsdeklaration Q3' },
|
||||
})
|
||||
const { status } = await parseJsonResponse(await PUT(req, idParams))
|
||||
expect(status).toBe(404)
|
||||
})
|
||||
|
||||
it('updates the deadline', async () => {
|
||||
auth(createCapturingSupabase([{ data: { id: 'deadline-1', title: 'Momsdeklaration Q3' } }]))
|
||||
const req = createMockRequest('/api/deadlines/deadline-1', {
|
||||
method: 'PUT',
|
||||
body: { title: 'Momsdeklaration Q3' },
|
||||
})
|
||||
const { status, body } = await parseJsonResponse<{ data: { title: string } }>(
|
||||
await PUT(req, idParams)
|
||||
)
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.title).toBe('Momsdeklaration Q3')
|
||||
})
|
||||
})
|
||||
|
||||
describe('DELETE /api/deadlines/[id]', () => {
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
requireAuthMock.mockResolvedValue({
|
||||
user: null,
|
||||
supabase: {},
|
||||
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
|
||||
})
|
||||
const res = await DELETE(createMockRequest('/x', { method: 'DELETE' }), idParams)
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
it('returns 404 instead of phantom success when no row matches', async () => {
|
||||
auth(createCapturingSupabase([{ count: 0 }]))
|
||||
const { status } = await parseJsonResponse(
|
||||
await DELETE(createMockRequest('/x', { method: 'DELETE' }), idParams)
|
||||
)
|
||||
expect(status).toBe(404)
|
||||
})
|
||||
|
||||
it('deletes the deadline', async () => {
|
||||
auth(createCapturingSupabase([{ count: 1 }]))
|
||||
const { status, body } = await parseJsonResponse<{ success: boolean }>(
|
||||
await DELETE(createMockRequest('/x', { method: 'DELETE' }), idParams)
|
||||
)
|
||||
expect(status).toBe(200)
|
||||
expect(body.success).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -1,63 +1,49 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
|
||||
/**
|
||||
* POST /api/deadlines/[id]/complete
|
||||
* Toggle completion status of a deadline
|
||||
*/
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const supabase = await createClient()
|
||||
const { id } = await params
|
||||
export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
'deadline.toggle_complete',
|
||||
async (_request, ctx, { params }) => {
|
||||
const { id } = await params
|
||||
const { supabase, companyId } = ctx
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
// First, get current deadline state
|
||||
const { data: existing, error: fetchError } = await supabase
|
||||
.from('deadlines')
|
||||
.select('is_completed')
|
||||
.eq('id', id)
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const writeCheck = await requireWritePermission(supabase, user.id)
|
||||
if (!writeCheck.ok) return writeCheck.response
|
||||
|
||||
const companyId = await requireCompanyId(supabase, user.id)
|
||||
|
||||
// First, get current deadline state
|
||||
const { data: existing, error: fetchError } = await supabase
|
||||
.from('deadlines')
|
||||
.select('is_completed')
|
||||
.eq('id', id)
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
|
||||
if (fetchError) {
|
||||
if (fetchError.code === 'PGRST116') {
|
||||
return NextResponse.json({ error: 'Deadline not found' }, { status: 404 })
|
||||
if (fetchError) {
|
||||
if (fetchError.code === 'PGRST116') {
|
||||
return NextResponse.json({ error: 'Deadline not found' }, { status: 404 })
|
||||
}
|
||||
return NextResponse.json({ error: fetchError.message }, { status: 500 })
|
||||
}
|
||||
return NextResponse.json({ error: fetchError.message }, { status: 500 })
|
||||
}
|
||||
|
||||
// Toggle completion
|
||||
const newCompletedState = !existing.is_completed
|
||||
const { data, error } = await supabase
|
||||
.from('deadlines')
|
||||
.update({
|
||||
is_completed: newCompletedState,
|
||||
completed_at: newCompletedState ? new Date().toISOString() : null,
|
||||
})
|
||||
.eq('id', id)
|
||||
.eq('company_id', companyId)
|
||||
.select('*, customer:customers(id, name)')
|
||||
.single()
|
||||
// Toggle completion
|
||||
const newCompletedState = !existing.is_completed
|
||||
const { data, error } = await supabase
|
||||
.from('deadlines')
|
||||
.update({
|
||||
is_completed: newCompletedState,
|
||||
completed_at: newCompletedState ? new Date().toISOString() : null,
|
||||
})
|
||||
.eq('id', id)
|
||||
.eq('company_id', companyId)
|
||||
.select('*, customer:customers(id, name)')
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
return NextResponse.json({ data })
|
||||
},
|
||||
{ requireWrite: true },
|
||||
)
|
||||
|
||||
+88
-117
@@ -1,147 +1,118 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
import type { CreateDeadlineInput } from '@/types'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { CreateDeadlineSchema } from '@/lib/api/schemas'
|
||||
|
||||
// Sparse update: every Create field, optional. Validated — the previous
|
||||
// implementation type-asserted the raw JSON, so malformed values reached
|
||||
// Postgres and malformed JSON crashed the handler.
|
||||
const UpdateDeadlineSchema = CreateDeadlineSchema.partial()
|
||||
|
||||
/**
|
||||
* GET /api/deadlines/[id]
|
||||
* Get a single deadline by ID
|
||||
*/
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const supabase = await createClient()
|
||||
const { id } = await params
|
||||
export const GET = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
'deadline.get',
|
||||
async (_request, ctx, { params }) => {
|
||||
const { id } = await params
|
||||
const { supabase, companyId } = ctx
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
const { data, error } = await supabase
|
||||
.from('deadlines')
|
||||
.select('*, customer:customers(id, name)')
|
||||
.eq('id', id)
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const companyId = await requireCompanyId(supabase, user.id)
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('deadlines')
|
||||
.select('*, customer:customers(id, name)')
|
||||
.eq('id', id)
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
if (error.code === 'PGRST116') {
|
||||
return NextResponse.json({ error: 'Deadline not found' }, { status: 404 })
|
||||
if (error) {
|
||||
if (error.code === 'PGRST116') {
|
||||
return NextResponse.json({ error: 'Deadline not found' }, { status: 404 })
|
||||
}
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
return NextResponse.json({ data })
|
||||
},
|
||||
)
|
||||
|
||||
/**
|
||||
* PUT /api/deadlines/[id]
|
||||
* Update a deadline
|
||||
*/
|
||||
export async function PUT(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const supabase = await createClient()
|
||||
const { id } = await params
|
||||
export const PUT = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
'deadline.update',
|
||||
async (request, ctx, { params }) => {
|
||||
const { id } = await params
|
||||
const { supabase, companyId, log } = ctx
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
const validation = await validateBody(request, UpdateDeadlineSchema, {
|
||||
log,
|
||||
operation: 'deadline.update',
|
||||
})
|
||||
if (!validation.success) return validation.response
|
||||
const body = validation.data
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
// Build update object
|
||||
const updateData: Record<string, unknown> = {}
|
||||
if (body.title !== undefined) updateData.title = body.title
|
||||
if (body.due_date !== undefined) updateData.due_date = body.due_date
|
||||
if (body.due_time !== undefined) updateData.due_time = body.due_time
|
||||
if (body.deadline_type !== undefined) updateData.deadline_type = body.deadline_type
|
||||
if (body.priority !== undefined) updateData.priority = body.priority
|
||||
if (body.customer_id !== undefined) updateData.customer_id = body.customer_id || null
|
||||
if (body.notes !== undefined) updateData.notes = body.notes
|
||||
|
||||
const writeCheck = await requireWritePermission(supabase, user.id)
|
||||
if (!writeCheck.ok) return writeCheck.response
|
||||
|
||||
const companyId = await requireCompanyId(supabase, user.id)
|
||||
|
||||
const body: Partial<CreateDeadlineInput> = await request.json()
|
||||
|
||||
// First, get existing deadline to verify ownership
|
||||
const { data: _existing, error: fetchError } = await supabase
|
||||
.from('deadlines')
|
||||
.select('*')
|
||||
.eq('id', id)
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
|
||||
if (fetchError) {
|
||||
if (fetchError.code === 'PGRST116') {
|
||||
return NextResponse.json({ error: 'Deadline not found' }, { status: 404 })
|
||||
if (Object.keys(updateData).length === 0) {
|
||||
return NextResponse.json({ error: 'Nothing to update' }, { status: 400 })
|
||||
}
|
||||
return NextResponse.json({ error: fetchError.message }, { status: 500 })
|
||||
}
|
||||
|
||||
// Build update object
|
||||
const updateData: Record<string, unknown> = {}
|
||||
if (body.title !== undefined) updateData.title = body.title
|
||||
if (body.due_date !== undefined) updateData.due_date = body.due_date
|
||||
if (body.due_time !== undefined) updateData.due_time = body.due_time
|
||||
if (body.deadline_type !== undefined) updateData.deadline_type = body.deadline_type
|
||||
if (body.priority !== undefined) updateData.priority = body.priority
|
||||
if (body.customer_id !== undefined) updateData.customer_id = body.customer_id || null
|
||||
if (body.notes !== undefined) updateData.notes = body.notes
|
||||
const { data, error } = await supabase
|
||||
.from('deadlines')
|
||||
.update(updateData)
|
||||
.eq('id', id)
|
||||
.eq('company_id', companyId)
|
||||
.select('*, customer:customers(id, name)')
|
||||
.single()
|
||||
|
||||
// Update the deadline
|
||||
const { data, error } = await supabase
|
||||
.from('deadlines')
|
||||
.update(updateData)
|
||||
.eq('id', id)
|
||||
.eq('company_id', companyId)
|
||||
.select('*, customer:customers(id, name)')
|
||||
.single()
|
||||
if (error) {
|
||||
// PGRST116 = zero rows — the deadline doesn't exist in this company.
|
||||
if (error.code === 'PGRST116') {
|
||||
return NextResponse.json({ error: 'Deadline not found' }, { status: 404 })
|
||||
}
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
return NextResponse.json({ data })
|
||||
},
|
||||
{ requireWrite: true },
|
||||
)
|
||||
|
||||
/**
|
||||
* DELETE /api/deadlines/[id]
|
||||
* Delete a deadline
|
||||
*/
|
||||
export async function DELETE(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const supabase = await createClient()
|
||||
const { id } = await params
|
||||
export const DELETE = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
'deadline.delete',
|
||||
async (_request, ctx, { params }) => {
|
||||
const { id } = await params
|
||||
const { supabase, companyId } = ctx
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
const { error, count } = await supabase
|
||||
.from('deadlines')
|
||||
.delete({ count: 'exact' })
|
||||
.eq('id', id)
|
||||
.eq('company_id', companyId)
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
// Zero rows = wrong id / another company's deadline — not a success.
|
||||
if (count === 0) {
|
||||
return NextResponse.json({ error: 'Deadline not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const writeCheck = await requireWritePermission(supabase, user.id)
|
||||
if (!writeCheck.ok) return writeCheck.response
|
||||
|
||||
const companyId = await requireCompanyId(supabase, user.id)
|
||||
|
||||
const { error } = await supabase
|
||||
.from('deadlines')
|
||||
.delete()
|
||||
.eq('id', id)
|
||||
.eq('company_id', companyId)
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
return NextResponse.json({ success: true })
|
||||
},
|
||||
{ requireWrite: true },
|
||||
)
|
||||
|
||||
@@ -1,114 +1,81 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { updateDeadlineStatus, isValidTransition } from '@/lib/deadlines/status-engine'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
import type { DeadlineStatus } from '@/types'
|
||||
|
||||
const ALL_STATUSES = [
|
||||
'upcoming',
|
||||
'action_needed',
|
||||
'in_progress',
|
||||
'submitted',
|
||||
'confirmed',
|
||||
'overdue',
|
||||
] as const satisfies readonly DeadlineStatus[]
|
||||
|
||||
const PatchStatusSchema = z.object({
|
||||
status: z.enum(ALL_STATUSES),
|
||||
})
|
||||
|
||||
/**
|
||||
* PATCH /api/deadlines/[id]/status
|
||||
* Manually update a deadline's status
|
||||
*/
|
||||
export async function PATCH(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const supabase = await createClient()
|
||||
export const PATCH = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
'deadline.set_status',
|
||||
async (request, ctx, { params }) => {
|
||||
const { id } = await params
|
||||
const { supabase, companyId, log } = ctx
|
||||
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
const validation = await validateBody(request, PatchStatusSchema, {
|
||||
log,
|
||||
operation: 'deadline.set_status',
|
||||
})
|
||||
if (!validation.success) return validation.response
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
const result = await updateDeadlineStatus(supabase, id, companyId, validation.data.status)
|
||||
|
||||
const writeCheck = await requireWritePermission(supabase, user.id)
|
||||
if (!writeCheck.ok) return writeCheck.response
|
||||
if (!result.success) {
|
||||
return NextResponse.json({ error: result.error }, { status: 400 })
|
||||
}
|
||||
|
||||
const companyId = await requireCompanyId(supabase, user.id)
|
||||
|
||||
const { id } = await params
|
||||
|
||||
const body = await request.json()
|
||||
const newStatus = body.status as DeadlineStatus
|
||||
|
||||
if (!newStatus) {
|
||||
return NextResponse.json({ error: 'Status is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const validStatuses: DeadlineStatus[] = [
|
||||
'upcoming',
|
||||
'action_needed',
|
||||
'in_progress',
|
||||
'submitted',
|
||||
'confirmed',
|
||||
'overdue',
|
||||
]
|
||||
|
||||
if (!validStatuses.includes(newStatus)) {
|
||||
return NextResponse.json({ error: 'Invalid status' }, { status: 400 })
|
||||
}
|
||||
|
||||
const result = await updateDeadlineStatus(supabase, id, companyId, newStatus)
|
||||
|
||||
if (!result.success) {
|
||||
return NextResponse.json({ error: result.error }, { status: 400 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
return NextResponse.json({ success: true })
|
||||
},
|
||||
{ requireWrite: true },
|
||||
)
|
||||
|
||||
/**
|
||||
* GET /api/deadlines/[id]/status
|
||||
* Get current status and valid transitions
|
||||
*/
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const supabase = await createClient()
|
||||
export const GET = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
'deadline.get_status',
|
||||
async (_request, ctx, { params }) => {
|
||||
const { id } = await params
|
||||
const { supabase, companyId } = ctx
|
||||
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
const { data: deadline, error } = await supabase
|
||||
.from('deadlines')
|
||||
.select('status, is_completed, due_date')
|
||||
.eq('id', id)
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const companyId = await requireCompanyId(supabase, user.id)
|
||||
|
||||
const { id } = await params
|
||||
|
||||
const { data: deadline, error } = await supabase
|
||||
.from('deadlines')
|
||||
.select('status, is_completed, due_date')
|
||||
.eq('id', id)
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
|
||||
if (error || !deadline) {
|
||||
return NextResponse.json({ error: 'Deadline not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Calculate valid transitions from current status
|
||||
const validTransitions: DeadlineStatus[] = []
|
||||
const allStatuses: DeadlineStatus[] = [
|
||||
'upcoming',
|
||||
'action_needed',
|
||||
'in_progress',
|
||||
'submitted',
|
||||
'confirmed',
|
||||
'overdue',
|
||||
]
|
||||
|
||||
for (const status of allStatuses) {
|
||||
if (isValidTransition(deadline.status, status)) {
|
||||
validTransitions.push(status)
|
||||
if (error || !deadline) {
|
||||
return NextResponse.json({ error: 'Deadline not found' }, { status: 404 })
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
currentStatus: deadline.status,
|
||||
isCompleted: deadline.is_completed,
|
||||
dueDate: deadline.due_date,
|
||||
validTransitions,
|
||||
})
|
||||
}
|
||||
// Calculate valid transitions from current status
|
||||
const validTransitions = ALL_STATUSES.filter((status) =>
|
||||
isValidTransition(deadline.status, status)
|
||||
)
|
||||
|
||||
return NextResponse.json({
|
||||
currentStatus: deadline.status,
|
||||
isCompleted: deadline.is_completed,
|
||||
dueDate: deadline.due_date,
|
||||
validTransitions,
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
+11
-32
@@ -1,9 +1,7 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { CreateDeadlineSchema } from '@/lib/api/schemas'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
|
||||
/**
|
||||
* GET /api/deadlines
|
||||
@@ -14,18 +12,8 @@ import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
* - from: ISO date string (optional)
|
||||
* - to: ISO date string (optional)
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
const supabase = await createClient()
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const companyId = await requireCompanyId(supabase, user.id)
|
||||
export const GET = withRouteContext('deadline.list', async (request, ctx) => {
|
||||
const { supabase, companyId } = ctx
|
||||
|
||||
// Parse query params
|
||||
const { searchParams } = new URL(request.url)
|
||||
@@ -66,27 +54,16 @@ export async function GET(request: Request) {
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* POST /api/deadlines
|
||||
* Create a new deadline
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
const supabase = await createClient()
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const writeCheck = await requireWritePermission(supabase, user.id)
|
||||
if (!writeCheck.ok) return writeCheck.response
|
||||
|
||||
const companyId = await requireCompanyId(supabase, user.id)
|
||||
export const POST = withRouteContext(
|
||||
'deadline.create',
|
||||
async (request, ctx) => {
|
||||
const { supabase, companyId, user } = ctx
|
||||
|
||||
const validation = await validateBody(request, CreateDeadlineSchema)
|
||||
if (!validation.success) return validation.response
|
||||
@@ -114,4 +91,6 @@ export async function POST(request: Request) {
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
},
|
||||
{ requireWrite: true },
|
||||
)
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { CreateDimensionSchema } from '@/lib/api/schemas'
|
||||
import { errorResponse } from '@/lib/errors/get-structured-error'
|
||||
@@ -71,19 +72,28 @@ export const GET = withRouteContext(
|
||||
return errorResponse(dimsError, log, { requestId })
|
||||
}
|
||||
|
||||
const { data: values, error: valuesError } = await supabase
|
||||
.from('dimension_values')
|
||||
.select('id, dimension_id, code, name, is_active, start_date, end_date')
|
||||
.eq('company_id', companyId)
|
||||
.order('code', { ascending: true })
|
||||
|
||||
if (valuesError) {
|
||||
log.error('dimension value list failed', valuesError)
|
||||
// Paginated: import-existing can mint one value row per historical code
|
||||
// (thousands for project-heavy SIE histories), which exceeds PostgREST's
|
||||
// 1000-row cap and would silently drop codes from the register/pickers.
|
||||
// Secondary order on id gives the stable total order .range() requires.
|
||||
let values: DimensionValueRow[]
|
||||
try {
|
||||
values = await fetchAllRows<DimensionValueRow>(({ from, to }) =>
|
||||
supabase
|
||||
.from('dimension_values')
|
||||
.select('id, dimension_id, code, name, is_active, start_date, end_date')
|
||||
.eq('company_id', companyId)
|
||||
.order('code', { ascending: true })
|
||||
.order('id', { ascending: true })
|
||||
.range(from, to),
|
||||
)
|
||||
} catch (valuesError) {
|
||||
log.error('dimension value list failed', valuesError as Error)
|
||||
return errorResponse(valuesError, log, { requestId })
|
||||
}
|
||||
|
||||
const valuesByDimension = new Map<string, Omit<DimensionValueRow, 'dimension_id'>[]>()
|
||||
for (const v of (values ?? []) as DimensionValueRow[]) {
|
||||
for (const v of values) {
|
||||
const bucket = valuesByDimension.get(v.dimension_id) ?? []
|
||||
bucket.push({
|
||||
id: v.id,
|
||||
|
||||
@@ -1,22 +1,13 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
|
||||
/**
|
||||
* GET /api/documents/counts?journal_entry_ids=id1,id2,...
|
||||
* Returns attachment counts per journal entry ID.
|
||||
* Max 50 IDs per request.
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
const supabase = await createClient()
|
||||
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const companyId = await requireCompanyId(supabase, user.id)
|
||||
export const GET = withRouteContext('document.counts', async (request, ctx) => {
|
||||
const { supabase, companyId } = ctx
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const idsParam = searchParams.get('journal_entry_ids')
|
||||
@@ -55,4 +46,4 @@ export async function GET(request: Request) {
|
||||
}
|
||||
|
||||
return NextResponse.json({ data: counts })
|
||||
}
|
||||
})
|
||||
|
||||
@@ -44,7 +44,17 @@ export const POST = withRouteContext(
|
||||
const opLog = log.child({ filename: file.name, sizeBytes: file.size })
|
||||
|
||||
try {
|
||||
const uploadSource = (formData.get('upload_source') as string) || 'file_upload'
|
||||
// Whitelist the source — arbitrary strings from formData would otherwise
|
||||
// land in the upload_source column; unknown values fall back to the
|
||||
// default rather than 400ing an otherwise-valid upload.
|
||||
const VALID_SOURCES: DocumentUploadSource[] = [
|
||||
'camera', 'file_upload', 'email', 'e_invoice', 'scan', 'api', 'system',
|
||||
]
|
||||
const rawSource = formData.get('upload_source')
|
||||
const uploadSource: DocumentUploadSource =
|
||||
typeof rawSource === 'string' && VALID_SOURCES.includes(rawSource as DocumentUploadSource)
|
||||
? (rawSource as DocumentUploadSource)
|
||||
: 'file_upload'
|
||||
const journalEntryId = formData.get('journal_entry_id') as string | null
|
||||
const journalEntryLineId = formData.get('journal_entry_line_id') as string | null
|
||||
|
||||
@@ -55,7 +65,7 @@ export const POST = withRouteContext(
|
||||
buffer,
|
||||
type: file.type,
|
||||
}, {
|
||||
upload_source: uploadSource as DocumentUploadSource,
|
||||
upload_source: uploadSource,
|
||||
journal_entry_id: journalEntryId || undefined,
|
||||
journal_entry_line_id: journalEntryLineId || undefined,
|
||||
})
|
||||
@@ -106,8 +116,12 @@ export const GET = withRouteContext(
|
||||
const { searchParams } = new URL(request.url)
|
||||
const journalEntryId = searchParams.get('journal_entry_id')
|
||||
const currentOnly = searchParams.get('current_only') !== 'false'
|
||||
const limit = parseInt(searchParams.get('limit') || '50')
|
||||
const offset = parseInt(searchParams.get('offset') || '0')
|
||||
// Clamp pagination — parseInt('abc') is NaN and unbounded limits let a
|
||||
// caller demand the whole table in one page.
|
||||
const rawLimit = parseInt(searchParams.get('limit') || '50', 10)
|
||||
const limit = Number.isFinite(rawLimit) ? Math.min(Math.max(rawLimit, 1), 500) : 50
|
||||
const rawOffset = parseInt(searchParams.get('offset') || '0', 10)
|
||||
const offset = Number.isFinite(rawOffset) && rawOffset >= 0 ? rawOffset : 0
|
||||
|
||||
let query = supabase
|
||||
.from('document_attachments')
|
||||
|
||||
@@ -9,9 +9,11 @@ vi.mock('@/extensions/general/enable-banking/lib/api-client', () => ({
|
||||
}))
|
||||
|
||||
// Use hoisted to safely create mock objects referenced in vi.mock factories
|
||||
const { mockFrom } = vi.hoisted(() => {
|
||||
const { mockFrom, mockUpsertFromPsd2, mockAllocate } = vi.hoisted(() => {
|
||||
const mockFrom = vi.fn()
|
||||
return { mockFrom }
|
||||
const mockUpsertFromPsd2 = vi.fn()
|
||||
const mockAllocate = vi.fn()
|
||||
return { mockFrom, mockUpsertFromPsd2, mockAllocate }
|
||||
})
|
||||
|
||||
vi.mock('@/lib/supabase/server', () => ({
|
||||
@@ -20,6 +22,20 @@ vi.mock('@/lib/supabase/server', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
const CURRENCY_DEFAULTS: Record<string, string> = {
|
||||
SEK: '1930',
|
||||
EUR: '1932',
|
||||
USD: '1933',
|
||||
GBP: '1934',
|
||||
}
|
||||
|
||||
vi.mock('@/lib/cash-accounts/service', () => ({
|
||||
upsertFromPsd2: (...args: unknown[]) => mockUpsertFromPsd2(...args),
|
||||
allocatePsd2LedgerAccount: (...args: unknown[]) => mockAllocate(...args),
|
||||
defaultLedgerForCurrency: (currency: string) =>
|
||||
CURRENCY_DEFAULTS[currency.toUpperCase()] ?? '1930',
|
||||
}))
|
||||
|
||||
vi.stubEnv('NEXT_PUBLIC_APP_URL', 'http://localhost:3000')
|
||||
|
||||
import { GET } from '../route'
|
||||
@@ -46,6 +62,27 @@ function mockChain(result: { data?: unknown; error?: unknown }) {
|
||||
describe('GET /api/extensions/enable-banking/callback', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUpsertFromPsd2.mockResolvedValue(undefined)
|
||||
// Allocator stand-in mirroring the real behavior: currency default first,
|
||||
// then the next free 1931–1959 slot (skipping other currency defaults).
|
||||
mockAllocate.mockImplementation(
|
||||
async (
|
||||
_supabase: unknown,
|
||||
_companyId: unknown,
|
||||
_userId: unknown,
|
||||
input: { currency: string; exclude?: ReadonlySet<string> },
|
||||
) => {
|
||||
const preferred = CURRENCY_DEFAULTS[input.currency.toUpperCase()] ?? '1930'
|
||||
const exclude = input.exclude ?? new Set<string>()
|
||||
if (!exclude.has(preferred)) return preferred
|
||||
const reserved = new Set(Object.values(CURRENCY_DEFAULTS))
|
||||
for (let n = 1931; n <= 1959; n++) {
|
||||
const candidate = String(n)
|
||||
if (!reserved.has(candidate) && !exclude.has(candidate)) return candidate
|
||||
}
|
||||
return null
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects when state does not match any pending connection', async () => {
|
||||
@@ -115,13 +152,79 @@ describe('GET /api/extensions/enable-banking/callback', () => {
|
||||
// Verify the update payload: status=pending_selection, no last_synced_at,
|
||||
// and every account defaults to enabled=true so the picker can simply
|
||||
// mirror current state without back-filling.
|
||||
expect(capturedUpdates).toHaveLength(1)
|
||||
// Two updates: the connection write, then the accounts_data follow-up
|
||||
// persisting the allocated ledgers.
|
||||
expect(capturedUpdates).toHaveLength(2)
|
||||
const payload = capturedUpdates[0]
|
||||
expect(payload.status).toBe('pending_selection')
|
||||
expect(payload).not.toHaveProperty('last_synced_at')
|
||||
const accountsData = payload.accounts_data as Array<{ uid: string; enabled: boolean }>
|
||||
expect(accountsData).toHaveLength(2)
|
||||
expect(accountsData.every(a => a.enabled === true)).toBe(true)
|
||||
|
||||
// Two same-currency accounts must NOT collide on the same BAS slot — the
|
||||
// second SEK account gets the next free 19xx sub-account, and the
|
||||
// assignment is persisted to accounts_data for the picker to pre-fill.
|
||||
const persisted = capturedUpdates[1].accounts_data as Array<{
|
||||
uid: string
|
||||
ledger_account?: string
|
||||
}>
|
||||
expect(persisted.find(a => a.uid === 'acc-1')?.ledger_account).toBe('1930')
|
||||
expect(persisted.find(a => a.uid === 'acc-2')?.ledger_account).toBe('1931')
|
||||
|
||||
// The mirror wrote the same distinct assignments into cash_accounts.
|
||||
expect(mockUpsertFromPsd2).toHaveBeenCalledTimes(2)
|
||||
const mirrorLedgers = mockUpsertFromPsd2.mock.calls.map(
|
||||
(c) => (c[2] as { ledger_account: string }).ledger_account,
|
||||
)
|
||||
expect(mirrorLedgers).toEqual(['1930', '1931'])
|
||||
})
|
||||
|
||||
it('preserves existing mirrored ledgers on reconnect instead of re-deriving them', async () => {
|
||||
let callIndex = 0
|
||||
mockFrom.mockImplementation((table: string) => {
|
||||
callIndex++
|
||||
if (callIndex === 1) {
|
||||
return mockChain({ data: { id: 'conn-1', user_id: 'user-1', company_id: 'company-1' }, error: null })
|
||||
}
|
||||
if (table === 'cash_accounts') {
|
||||
// Already mirrored on a previous connect — acc-1 was remapped to 1935
|
||||
// by the user; a reconnect must not clobber it back to 1930.
|
||||
return mockChain({
|
||||
data: [{ external_uid: 'acc-1', ledger_account: '1935' }],
|
||||
error: null,
|
||||
})
|
||||
}
|
||||
const chain: Record<string, unknown> = {}
|
||||
chain.update = vi.fn(() => chain)
|
||||
chain.eq = vi.fn().mockReturnValue(chain)
|
||||
chain.select = vi.fn().mockReturnValue(chain)
|
||||
chain.single = vi.fn().mockResolvedValue({
|
||||
data: { id: 'conn-1', bank_name: 'TestBank', company_id: 'company-1', user_id: 'user-1' },
|
||||
error: null,
|
||||
})
|
||||
chain.then = (resolve: (v: unknown) => void) => resolve({ data: null, error: null })
|
||||
return chain
|
||||
})
|
||||
|
||||
mockCreateSession.mockResolvedValue({
|
||||
session_id: 'sess-2',
|
||||
accounts: [
|
||||
{ uid: 'acc-1', account_id: { iban: 'SE1234' }, name: 'Företagskonto', currency: 'SEK' },
|
||||
],
|
||||
access: { valid_until: '2024-12-31T00:00:00Z' },
|
||||
aspsp: { name: 'TestBank', country: 'SE' },
|
||||
})
|
||||
|
||||
const response = await GET(makeRequest({ code: 'auth-code', state: 'valid-state' }))
|
||||
|
||||
expect(response.status).toBe(307)
|
||||
// No allocation for an already-mirrored account; the upsert reuses 1935.
|
||||
expect(mockAllocate).not.toHaveBeenCalled()
|
||||
expect(mockUpsertFromPsd2).toHaveBeenCalledTimes(1)
|
||||
expect(
|
||||
(mockUpsertFromPsd2.mock.calls[0][2] as { ledger_account: string }).ledger_account,
|
||||
).toBe('1935')
|
||||
})
|
||||
|
||||
it('redirects with error when bank returns error param (no state)', async () => {
|
||||
@@ -154,6 +257,32 @@ describe('GET /api/extensions/enable-banking/callback', () => {
|
||||
expect(mockFrom).toHaveBeenCalledWith('bank_connections')
|
||||
})
|
||||
|
||||
it('forwards bank_error_code and psu_type when the denied state matches a pending connection', async () => {
|
||||
mockFrom.mockImplementation(() =>
|
||||
mockChain({
|
||||
data: { id: 'conn-1', user_id: 'user-1', bank_name: 'Handelsbanken', psu_type: 'business' },
|
||||
error: null,
|
||||
})
|
||||
)
|
||||
|
||||
const response = await GET(makeRequest({
|
||||
error: 'server_error',
|
||||
state: 'pending-state',
|
||||
}))
|
||||
|
||||
expect(response.status).toBe(307)
|
||||
const location = response.headers.get('location') || ''
|
||||
expect(location).toContain('/settings/banking?')
|
||||
// error_description is null for server_error, so the code doubles as message
|
||||
expect(location).toContain('bank_error=server_error')
|
||||
expect(location).toContain('bank_name=Handelsbanken')
|
||||
// The code is forwarded for every error, not just access_denied, together
|
||||
// with the connection's psu_type — the settings page keys the Handelsbanken
|
||||
// corporate fullmakt guidance off this exact combination.
|
||||
expect(location).toContain('bank_error_code=server_error')
|
||||
expect(location).toContain('psu_type=business')
|
||||
})
|
||||
|
||||
it('redirects with error when code or state is missing', async () => {
|
||||
const response = await GET(makeRequest({ code: 'auth-code' }))
|
||||
|
||||
|
||||
@@ -4,7 +4,11 @@ import { ensureInitialized } from '@/lib/init'
|
||||
import { createSession, type AccountInfo } from '@/extensions/general/enable-banking/lib/api-client'
|
||||
import type { StoredAccount } from '@/extensions/general/enable-banking/types'
|
||||
import { eventBus } from '@/lib/events/bus'
|
||||
import { upsertFromPsd2 } from '@/lib/cash-accounts/service'
|
||||
import {
|
||||
upsertFromPsd2,
|
||||
allocatePsd2LedgerAccount,
|
||||
defaultLedgerForCurrency,
|
||||
} from '@/lib/cash-accounts/service'
|
||||
|
||||
// This route emits bank_connection.consent_granted / .cash_account_mirror_failed
|
||||
// (ASVS V16 / GDPR Art.30 audit events). ensureInitialized() must run at module
|
||||
@@ -13,19 +17,6 @@ import { upsertFromPsd2 } from '@/lib/cash-accounts/service'
|
||||
// redirect route is the first event-emitting code path to execute.
|
||||
ensureInitialized()
|
||||
|
||||
// Suggested BAS account per currency. Mirrors the AccountPickerDialog defaults
|
||||
// (SEK→1930, EUR→1932, USD→1933, GBP→1934). The user can re-map in the picker
|
||||
// after this callback redirects them.
|
||||
const CURRENCY_DEFAULTS: Record<string, string> = {
|
||||
SEK: '1930',
|
||||
EUR: '1932',
|
||||
USD: '1933',
|
||||
GBP: '1934',
|
||||
}
|
||||
|
||||
function defaultLedgerForCurrency(currency: string): string {
|
||||
return CURRENCY_DEFAULTS[currency.toUpperCase()] ?? '1930'
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/extensions/enable-banking/callback
|
||||
@@ -46,7 +37,12 @@ export async function GET(request: Request) {
|
||||
|
||||
if (error) {
|
||||
const errorMessage = errorDescription || error
|
||||
console.error('[enable-banking] Bank authorization denied', {
|
||||
// access_denied is the user cancelling at the bank — an expected outcome,
|
||||
// not a runtime error. Only bank-side failures stay at error level.
|
||||
const isUserCancel =
|
||||
error === 'access_denied' || /cancelled by user/i.test(errorDescription ?? '')
|
||||
const logDenied = isUserCancel ? console.warn : console.error
|
||||
logDenied('[enable-banking] Bank authorization denied', {
|
||||
error,
|
||||
error_description: errorDescription,
|
||||
has_state: !!state,
|
||||
@@ -62,13 +58,13 @@ export async function GET(request: Request) {
|
||||
// (which stays 'expired' during the round-trip) is also handled.
|
||||
const { data: pendingConn } = await supabase
|
||||
.from('bank_connections')
|
||||
.select('id, user_id, bank_name')
|
||||
.select('id, user_id, bank_name, psu_type')
|
||||
.eq('oauth_state', state)
|
||||
.in('status', ['pending', 'expired', 'error'])
|
||||
.single()
|
||||
|
||||
if (pendingConn) {
|
||||
console.error('[enable-banking] Authorization denied details', {
|
||||
logDenied('[enable-banking] Authorization denied details', {
|
||||
connection_id: pendingConn.id,
|
||||
user_id: pendingConn.user_id,
|
||||
bank_name: pendingConn.bank_name,
|
||||
@@ -88,11 +84,15 @@ export async function GET(request: Request) {
|
||||
.update({ status: isSessionExpiry ? 'expired' : 'error', error_message: errorMessage, oauth_state: null })
|
||||
.eq('id', pendingConn.id)
|
||||
|
||||
// Include bank name and error code in redirect so the UI can offer PSU type retry
|
||||
// Include bank name, error code, and psu_type in the redirect so the
|
||||
// UI can render targeted guidance (e.g. PSU-type retry on
|
||||
// access_denied, or the Handelsbanken corporate fullmakt steps on
|
||||
// server_error for a business connect).
|
||||
const params = new URLSearchParams({
|
||||
bank_error: errorMessage,
|
||||
...(pendingConn.bank_name ? { bank_name: pendingConn.bank_name } : {}),
|
||||
...(error === 'access_denied' ? { bank_error_code: error } : {}),
|
||||
bank_error_code: error,
|
||||
...(pendingConn.psu_type ? { psu_type: pendingConn.psu_type } : {}),
|
||||
})
|
||||
return NextResponse.redirect(`${baseUrl}/settings/banking?${params.toString()}`)
|
||||
}
|
||||
@@ -206,12 +206,40 @@ export async function GET(request: Request) {
|
||||
throw new Error(`Failed to update connection: ${updateError.message}`)
|
||||
}
|
||||
|
||||
// Mirror each PSD2 account into cash_accounts so routing decisions read from
|
||||
// the canonical entity table. The user picks a ledger_account in the
|
||||
// AccountPickerDialog after this redirect; until then we route SEK→1930,
|
||||
// EUR→1932, USD→1933, GBP→1934 by convention.
|
||||
// Mirror each PSD2 account into cash_accounts so routing decisions read
|
||||
// from the canonical entity table. Accounts already mirrored (reconnect)
|
||||
// keep their ledger_account — re-deriving it here would clobber the
|
||||
// user's remaps. New accounts each get a free BAS class-19 slot: a bank
|
||||
// returning N same-currency accounts must not collide on the UNIQUE
|
||||
// (company_id, ledger_account) constraint by all defaulting to 1930.
|
||||
const { data: mirroredRows } = await supabase
|
||||
.from('cash_accounts')
|
||||
.select('external_uid, ledger_account')
|
||||
.eq('company_id', updatedConnection.company_id)
|
||||
.eq('bank_connection_id', updatedConnection.id)
|
||||
const existingLedgerByUid = new Map(
|
||||
((mirroredRows ?? []) as Array<{ external_uid: string; ledger_account: string }>).map(
|
||||
(r) => [r.external_uid, r.ledger_account],
|
||||
),
|
||||
)
|
||||
const assignedLedgers = new Set<string>(existingLedgerByUid.values())
|
||||
let accountsDataDirty = false
|
||||
|
||||
for (const account of accountsMetadata) {
|
||||
const targetLedger = defaultLedgerForCurrency(account.currency)
|
||||
let targetLedger = existingLedgerByUid.get(account.uid)
|
||||
if (!targetLedger) {
|
||||
targetLedger =
|
||||
(await allocatePsd2LedgerAccount(supabase, updatedConnection.company_id, updatedConnection.user_id, {
|
||||
currency: account.currency,
|
||||
accountName: account.name,
|
||||
exclude: assignedLedgers,
|
||||
})) ?? defaultLedgerForCurrency(account.currency)
|
||||
}
|
||||
assignedLedgers.add(targetLedger)
|
||||
if (account.ledger_account !== targetLedger) {
|
||||
account.ledger_account = targetLedger
|
||||
accountsDataDirty = true
|
||||
}
|
||||
try {
|
||||
await upsertFromPsd2(supabase, updatedConnection.company_id, {
|
||||
bank_connection_id: updatedConnection.id,
|
||||
@@ -256,6 +284,22 @@ export async function GET(request: Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// Persist the allocated ledgers into accounts_data so the AccountPicker
|
||||
// pre-fills the actual assignments instead of colliding currency
|
||||
// defaults. Non-fatal: cash_accounts is the routing source of truth.
|
||||
if (accountsDataDirty) {
|
||||
const { error: accountsDataError } = await supabase
|
||||
.from('bank_connections')
|
||||
.update({ accounts_data: accountsMetadata })
|
||||
.eq('id', updatedConnection.id)
|
||||
if (accountsDataError) {
|
||||
console.warn('[enable-banking] Failed to persist allocated ledgers to accounts_data', {
|
||||
connectionId: updatedConnection.id,
|
||||
error: accountsDataError.message,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Audit trail: PSD2 consent has been exchanged and account metadata stored.
|
||||
// ASVS V16 requires this transition to be logged as a security event; emit
|
||||
// here so the event_log handler persists it (30-day TTL).
|
||||
|
||||
@@ -4,6 +4,7 @@ import { ensureInitialized } from '@/lib/init'
|
||||
import { verifyCronSecret } from '@/lib/auth/cron'
|
||||
import { agiGetKvittenser } from '@/extensions/general/skatteverket/lib/agi-client'
|
||||
import { SkatteverketAuthError } from '@/extensions/general/skatteverket/lib/api-client'
|
||||
import { markNeedsReconsent, RECONSENT_ERROR_CODES } from '@/extensions/general/skatteverket/lib/token-store'
|
||||
import { formatRedovisare, formatRedovisningsperiod } from '@/lib/skatteverket/format'
|
||||
import { hasCapability } from '@/lib/entitlements/has-capability'
|
||||
import { CAPABILITY } from '@/lib/entitlements/keys'
|
||||
@@ -103,7 +104,7 @@ export async function GET(request: Request) {
|
||||
// operator's token is reused only for the company that owns the AGI.
|
||||
const { data: token } = await supabase
|
||||
.from('skatteverket_tokens')
|
||||
.select('user_id')
|
||||
.select('user_id, status')
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle()
|
||||
|
||||
@@ -112,6 +113,15 @@ export async function GET(request: Request) {
|
||||
continue
|
||||
}
|
||||
|
||||
// A connection flagged needs_reconsent cannot heal on its own (SKV's
|
||||
// per-flow refresh tokens live 65 minutes) — skip quietly instead of
|
||||
// failing the same pending declaration every run until the user
|
||||
// re-consents.
|
||||
if (token.status === 'needs_reconsent') {
|
||||
results.push({ declarationId, companyId, period, status: 'expired_token', error: 'needs_reconsent' })
|
||||
continue
|
||||
}
|
||||
|
||||
const { data: settings } = await supabase
|
||||
.from('company_settings')
|
||||
.select('org_number, entity_type')
|
||||
@@ -208,8 +218,23 @@ export async function GET(request: Request) {
|
||||
|
||||
if (
|
||||
err instanceof SkatteverketAuthError &&
|
||||
(err.code === 'REFRESH_EXHAUSTED' || err.code === 'SESSION_EXPIRED' || err.code === 'TOKEN_CORRUPTED' || err.code === 'MISSING_SCOPE')
|
||||
(RECONSENT_ERROR_CODES as readonly string[]).includes(err.code)
|
||||
) {
|
||||
// Persist the health flag so both crons stop retrying this
|
||||
// connection and the UI can prompt for re-consent proactively.
|
||||
const { data: tokenRow } = await supabase
|
||||
.from('skatteverket_tokens')
|
||||
.select('user_id')
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle()
|
||||
if (tokenRow?.user_id) {
|
||||
await markNeedsReconsent(supabase, tokenRow.user_id as string, err.code)
|
||||
}
|
||||
results.push({ declarationId, companyId, period, status: 'expired_token', error: err.code })
|
||||
continue
|
||||
}
|
||||
if (err instanceof SkatteverketAuthError && err.code === 'TOKEN_REVOKED') {
|
||||
// skvRequest already deleted the token row.
|
||||
results.push({ declarationId, companyId, period, status: 'expired_token', error: err.code })
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { syncSkattekonto, SKATTEKONTO_LAST_SYNCED_AT_KEY } from '@/extensions/ge
|
||||
import { computeSkattekontoDrift, maybeAlertDrift } from '@/extensions/general/skatteverket/lib/skattekonto-drift'
|
||||
import { SkatteverketAuthError } from '@/extensions/general/skatteverket/lib/api-client'
|
||||
import { SkatteverketSkattekontoError } from '@/extensions/general/skatteverket/lib/skattekonto-client'
|
||||
import { markNeedsReconsent, RECONSENT_ERROR_CODES } from '@/extensions/general/skatteverket/lib/token-store'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
@@ -47,11 +48,16 @@ export async function GET(request: Request) {
|
||||
|
||||
const supabase = createClient(supabaseUrl, supabaseServiceKey)
|
||||
|
||||
// Find all companies with a connected token. The token row is keyed by
|
||||
// user_id but carries company_id (added in the multi-tenant refactor).
|
||||
// Find all companies with a connected, believed-working token. The token
|
||||
// row is keyed by user_id but carries company_id (multi-tenant refactor).
|
||||
// Rows flagged needs_reconsent are excluded: SKV's per-flow refresh tokens
|
||||
// live 65 minutes, so a connection that failed with a terminal auth error
|
||||
// can never heal on its own — retrying it every night only produced a
|
||||
// per-company error log until the user re-consents (which resets status).
|
||||
const { data: tokens, error: tokensError } = await supabase
|
||||
.from('skatteverket_tokens')
|
||||
.select('user_id, company_id, expires_at, refresh_count')
|
||||
.eq('status', 'active')
|
||||
.order('expires_at', { ascending: true })
|
||||
.limit(50)
|
||||
|
||||
@@ -146,12 +152,21 @@ export async function GET(request: Request) {
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Unknown error'
|
||||
|
||||
// Expired token / refresh exhausted is a known outcome: surface it
|
||||
// distinctly so ops can dashboard "X companies need to reconnect".
|
||||
// Terminal auth states are a known outcome: surface them distinctly
|
||||
// so ops can dashboard "X companies need to reconnect", persist the
|
||||
// health flag so this cron stops retrying the row, and let the UI
|
||||
// prompt for re-consent proactively.
|
||||
if (
|
||||
err instanceof SkatteverketAuthError &&
|
||||
(err.code === 'REFRESH_EXHAUSTED' || err.code === 'SESSION_EXPIRED' || err.code === 'TOKEN_CORRUPTED')
|
||||
(RECONSENT_ERROR_CODES as readonly string[]).includes(err.code)
|
||||
) {
|
||||
await markNeedsReconsent(supabase, userId, err.code)
|
||||
results.push({ userId, companyId, status: 'expired', error: err.code })
|
||||
continue
|
||||
}
|
||||
// TOKEN_REVOKED auto-deletes the row inside skvRequest — treat it as
|
||||
// the same quiet "reconnect needed" outcome, not a runtime error.
|
||||
if (err instanceof SkatteverketAuthError && err.code === 'TOKEN_REVOKED') {
|
||||
results.push({ userId, companyId, status: 'expired', error: err.code })
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -126,8 +126,59 @@ describe('POST /api/import/opening-balance/correct', () => {
|
||||
expect((body.error as unknown as { code: string }).code).toBe('OB_PERIOD_LOCKED')
|
||||
})
|
||||
|
||||
it('returns 409 when the company lock date covers the period start', async () => {
|
||||
enqueue({ data: openPeriodWithOB() }) // period (period_start 2026-01-01)
|
||||
enqueue({ data: { bookkeeping_locked_through: '2026-02-28' } }) // company lock-date pre-flight
|
||||
|
||||
const res = await POST(makeRequest({ fiscal_period_id: PERIOD_ID, lines: BALANCED_LINES }))
|
||||
const { status, body } = await parseJsonResponse(res)
|
||||
|
||||
expect(status).toBe(409)
|
||||
const err = body.error as unknown as { code: string; details?: { lockDate?: string; entryDate?: string } }
|
||||
expect(err.code).toBe('OB_COMPANY_LOCK_DATE')
|
||||
expect(err.details?.lockDate).toBe('2026-02-28')
|
||||
expect(err.details?.entryDate).toBe('2026-01-01')
|
||||
expect(mockCreateJournalEntry).not.toHaveBeenCalled()
|
||||
expect(mockReverseEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('proceeds when the company lock date is before the period start', async () => {
|
||||
enqueue({ data: openPeriodWithOB() }) // period (period_start 2026-01-01)
|
||||
enqueue({ data: { bookkeeping_locked_through: '2025-12-31' } }) // company lock-date pre-flight
|
||||
enqueue({ count: 0 }) // year-end check
|
||||
enqueue({ error: null }) // replace_period_opening_balance_link RPC
|
||||
|
||||
mockCreateJournalEntry.mockResolvedValue({ id: 'entry-new', voucher_series: 'A', voucher_number: 5 })
|
||||
mockReverseEntry.mockResolvedValue({ id: 'entry-storno' })
|
||||
|
||||
const res = await POST(makeRequest({ fiscal_period_id: PERIOD_ID, lines: BALANCED_LINES }))
|
||||
const { status, body } = await parseJsonResponse(res)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.success).toBe(true)
|
||||
})
|
||||
|
||||
it('maps a raced lock-date trigger rejection to OB_COMPANY_LOCK_DATE instead of a retryable 500', async () => {
|
||||
enqueue({ data: openPeriodWithOB() }) // period
|
||||
enqueue({ data: { bookkeeping_locked_through: null } }) // pre-flight passes (lock set after it)
|
||||
enqueue({ count: 0 }) // year-end check
|
||||
|
||||
// The enforce_company_lock_date trigger fired inside the engine.
|
||||
mockCreateJournalEntry.mockRejectedValue(
|
||||
new Error('Database operation "create_draft_entry" failed: Bokföringen är låst t.o.m. 2026-02-28. Kan inte skapa verifikation med datum 2026-01-01.'),
|
||||
)
|
||||
|
||||
const res = await POST(makeRequest({ fiscal_period_id: PERIOD_ID, lines: BALANCED_LINES }))
|
||||
const { status, body } = await parseJsonResponse(res)
|
||||
|
||||
expect(status).toBe(409)
|
||||
expect((body.error as unknown as { code: string }).code).toBe('OB_COMPANY_LOCK_DATE')
|
||||
expect(mockReverseEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns 409 when the period has no opening balances to correct', async () => {
|
||||
enqueue({ data: openPeriodWithOB({ opening_balances_set: false, opening_balance_entry_id: null }) })
|
||||
enqueue({ data: { bookkeeping_locked_through: null } }) // company lock-date pre-flight
|
||||
|
||||
const res = await POST(makeRequest({ fiscal_period_id: PERIOD_ID, lines: BALANCED_LINES }))
|
||||
const { status, body } = await parseJsonResponse(res)
|
||||
@@ -138,6 +189,7 @@ describe('POST /api/import/opening-balance/correct', () => {
|
||||
|
||||
it('returns 409 when a year-end close exists on the period', async () => {
|
||||
enqueue({ data: openPeriodWithOB() }) // period
|
||||
enqueue({ data: { bookkeeping_locked_through: null } }) // company lock-date pre-flight
|
||||
enqueue({ count: 1 }) // year-end entry count
|
||||
|
||||
const res = await POST(makeRequest({ fiscal_period_id: PERIOD_ID, lines: BALANCED_LINES }))
|
||||
@@ -151,6 +203,7 @@ describe('POST /api/import/opening-balance/correct', () => {
|
||||
|
||||
it('returns 400 for unbalanced corrected lines', async () => {
|
||||
enqueue({ data: openPeriodWithOB() }) // period
|
||||
enqueue({ data: { bookkeeping_locked_through: null } }) // company lock-date pre-flight
|
||||
enqueue({ count: 0 }) // year-end check
|
||||
|
||||
const res = await POST(makeRequest({
|
||||
@@ -168,6 +221,7 @@ describe('POST /api/import/opening-balance/correct', () => {
|
||||
|
||||
it('books a corrected IB, stornoes the old one, and relinks on success', async () => {
|
||||
enqueue({ data: openPeriodWithOB() }) // period
|
||||
enqueue({ data: { bookkeeping_locked_through: null } }) // company lock-date pre-flight
|
||||
enqueue({ count: 0 }) // year-end check
|
||||
enqueue({ error: null }) // replace_period_opening_balance_link RPC
|
||||
|
||||
@@ -204,6 +258,7 @@ describe('POST /api/import/opening-balance/correct', () => {
|
||||
|
||||
it('returns 500 OB_CORRECT_FAILED if the relink RPC fails', async () => {
|
||||
enqueue({ data: openPeriodWithOB() }) // period
|
||||
enqueue({ data: { bookkeeping_locked_through: null } }) // company lock-date pre-flight
|
||||
enqueue({ count: 0 }) // year-end check
|
||||
enqueue({ error: { message: 'relink boom' } }) // RPC failure
|
||||
|
||||
|
||||
@@ -105,6 +105,7 @@ describe('POST /api/import/opening-balance/correct: atomicity, audit, BFL refere
|
||||
// FIX 3 (BFL 5 kap 5§): the corrected entry references the original voucher.
|
||||
it('references the original verifikationsnummer in the corrected entry description', async () => {
|
||||
enqueue({ data: openPeriodWithOB({ opening_balance_entry: { voucher_series: 'B', voucher_number: 7 } }) }) // period
|
||||
enqueue({ data: { bookkeeping_locked_through: null } }) // company lock-date pre-flight
|
||||
enqueue({ count: 0 }) // year-end check
|
||||
enqueue({ error: null }) // replace_period_opening_balance_link RPC
|
||||
|
||||
@@ -134,6 +135,7 @@ describe('POST /api/import/opening-balance/correct: atomicity, audit, BFL refere
|
||||
// after the new entry was already created.
|
||||
it('compensates by stornoing the new entry when reverseEntry throws, returning OB_CORRECT_FAILED', async () => {
|
||||
enqueue({ data: openPeriodWithOB() }) // period
|
||||
enqueue({ data: { bookkeeping_locked_through: null } }) // company lock-date pre-flight
|
||||
enqueue({ count: 0 }) // year-end check
|
||||
// No RPC enqueue: step B throws before the relink is reached.
|
||||
|
||||
@@ -163,6 +165,7 @@ describe('POST /api/import/opening-balance/correct: atomicity, audit, BFL refere
|
||||
// FIX 1 + FIX 2: relink RPC error triggers compensation and a durable audit.
|
||||
it('compensates and emits a durable audit when the relink RPC returns an error', async () => {
|
||||
enqueue({ data: openPeriodWithOB() }) // period
|
||||
enqueue({ data: { bookkeeping_locked_through: null } }) // company lock-date pre-flight
|
||||
enqueue({ count: 0 }) // year-end check
|
||||
enqueue({ error: { message: 'relink boom' } }) // RPC failure
|
||||
|
||||
@@ -194,6 +197,7 @@ describe('POST /api/import/opening-balance/correct: atomicity, audit, BFL refere
|
||||
// return the envelope and audit the compensation failure (never rethrow).
|
||||
it('audits a compensation failure and still returns OB_CORRECT_FAILED', async () => {
|
||||
enqueue({ data: openPeriodWithOB() }) // period
|
||||
enqueue({ data: { bookkeeping_locked_through: null } }) // company lock-date pre-flight
|
||||
enqueue({ count: 0 }) // year-end check
|
||||
enqueue({ error: { message: 'relink boom' } }) // RPC failure
|
||||
|
||||
|
||||
@@ -75,6 +75,26 @@ export const POST = withRouteContext(
|
||||
return errorResponseFromCode('OB_PERIOD_LOCKED', opLog, { requestId })
|
||||
}
|
||||
|
||||
// Company-wide lock date pre-flight. The enforce_company_lock_date
|
||||
// trigger blocks BOTH the corrected IB and the storno of the old one
|
||||
// (each books at period_start), and a trigger rejection surfaces as a
|
||||
// retryable-500 BOOKKEEPING_DATABASE_ERROR with a generic message —
|
||||
// which invites blind retries (prod incident: 3× against a covering
|
||||
// lock date). Pre-flight it and return an actionable 409 instead.
|
||||
const { data: settings } = await supabase
|
||||
.from('company_settings')
|
||||
.select('bookkeeping_locked_through')
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle()
|
||||
|
||||
const lockDate = settings?.bookkeeping_locked_through as string | null
|
||||
if (lockDate && period.period_start <= lockDate) {
|
||||
return errorResponseFromCode('OB_COMPANY_LOCK_DATE', opLog, {
|
||||
requestId,
|
||||
details: { lockDate, entryDate: period.period_start },
|
||||
})
|
||||
}
|
||||
|
||||
if (!period.opening_balances_set || !period.opening_balance_entry_id) {
|
||||
return errorResponseFromCode('OB_CORRECT_NO_EXISTING', opLog, { requestId })
|
||||
}
|
||||
@@ -240,6 +260,16 @@ export const POST = withRouteContext(
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
// Belt-and-braces: if the lock-date trigger fired anyway (lock date set
|
||||
// between the pre-flight and the write), return the same actionable
|
||||
// envelope instead of the generic retryable BOOKKEEPING_DATABASE_ERROR.
|
||||
const message = err instanceof Error ? err.message : ''
|
||||
if (/Bokföringen är låst/i.test(message)) {
|
||||
return errorResponseFromCode('OB_COMPANY_LOCK_DATE', opLog, {
|
||||
requestId,
|
||||
details: { reason: message },
|
||||
})
|
||||
}
|
||||
if (isBookkeepingError(err)) {
|
||||
return errorResponse(err, opLog, { requestId })
|
||||
}
|
||||
|
||||
@@ -42,7 +42,10 @@ export async function POST(request: Request) {
|
||||
|
||||
// Route through the structured logger so message + extra are PII-redacted
|
||||
// (personnummer / IBAN / tokens via REDACT_KEYS) before reaching Vercel logs.
|
||||
log.error('client onboarding error', { clientMessage: message, extra })
|
||||
// warn, never error: this is client-supplied telemetry (mostly form
|
||||
// validation misses) — client input must not be able to emit error-level
|
||||
// lines that land in Vercel's runtime-error clustering.
|
||||
log.warn('client onboarding error', { clientMessage: message, extra })
|
||||
|
||||
return NextResponse.json({ ok: true })
|
||||
} catch {
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { createQueuedMockSupabase, createMockRequest, createMockRouteParams } from '@/tests/helpers'
|
||||
|
||||
vi.mock('@/lib/auth/api-keys', () => ({ createServiceClientNoCookies: vi.fn() }))
|
||||
vi.mock('@/lib/salary/payslips/links', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/lib/salary/payslips/links')>()
|
||||
return {
|
||||
...actual,
|
||||
resolvePayslipToken: vi.fn(),
|
||||
}
|
||||
})
|
||||
vi.mock('@react-pdf/renderer', () => ({
|
||||
renderToBuffer: vi.fn(async () => Buffer.from('%PDF-fake')),
|
||||
}))
|
||||
vi.mock('@/lib/salary/pdf/payslip-template', () => ({ PayslipPDF: vi.fn(() => null) }))
|
||||
vi.mock('@/lib/salary/payslips/build-payslip-data', () => ({
|
||||
buildPayslipData: vi.fn(() => ({})),
|
||||
payslipFileName: vi.fn(() => 'lonespec_Test_2026-06.pdf'),
|
||||
}))
|
||||
|
||||
import { GET } from '../route'
|
||||
import { createServiceClientNoCookies } from '@/lib/auth/api-keys'
|
||||
import { resolvePayslipToken } from '@/lib/salary/payslips/links'
|
||||
|
||||
// Distinct valid-format tokens per test — the route's rate-limit map is
|
||||
// module-level state shared across this file.
|
||||
const token = (c: string) => c.repeat(43)
|
||||
|
||||
describe('GET /api/payslip/[token]/pdf', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
const { supabase } = createQueuedMockSupabase()
|
||||
vi.mocked(createServiceClientNoCookies).mockReturnValue(supabase as never)
|
||||
})
|
||||
|
||||
it('returns 400 for malformed tokens', async () => {
|
||||
const request = createMockRequest('/api/payslip/x/pdf')
|
||||
const response = await GET(request, createMockRouteParams({ token: 'not-a-token' }))
|
||||
expect(response.status).toBe(400)
|
||||
expect(vi.mocked(resolvePayslipToken)).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns 404 for an unknown token', async () => {
|
||||
vi.mocked(resolvePayslipToken).mockResolvedValue({ ok: false, reason: 'not_found' })
|
||||
const request = createMockRequest('/api/payslip/t/pdf')
|
||||
const response = await GET(request, createMockRouteParams({ token: token('B') }))
|
||||
expect(response.status).toBe(404)
|
||||
})
|
||||
|
||||
it('returns 410 for expired and revoked tokens', async () => {
|
||||
vi.mocked(resolvePayslipToken).mockResolvedValue({ ok: false, reason: 'expired' })
|
||||
let response = await GET(
|
||||
createMockRequest('/api/payslip/t/pdf'),
|
||||
createMockRouteParams({ token: token('C') }),
|
||||
)
|
||||
expect(response.status).toBe(410)
|
||||
|
||||
vi.mocked(resolvePayslipToken).mockResolvedValue({ ok: false, reason: 'revoked' })
|
||||
response = await GET(
|
||||
createMockRequest('/api/payslip/t/pdf'),
|
||||
createMockRouteParams({ token: token('D') }),
|
||||
)
|
||||
expect(response.status).toBe(410)
|
||||
})
|
||||
|
||||
it('rate limits repeated requests per token', async () => {
|
||||
vi.mocked(resolvePayslipToken).mockResolvedValue({ ok: false, reason: 'not_found' })
|
||||
const t = token('E')
|
||||
let last: Response | null = null
|
||||
for (let i = 0; i < 21; i++) {
|
||||
last = await GET(
|
||||
createMockRequest('/api/payslip/t/pdf'),
|
||||
createMockRouteParams({ token: t }),
|
||||
)
|
||||
}
|
||||
expect(last?.status).toBe(429)
|
||||
})
|
||||
|
||||
it('streams the PDF with no-store for a live token', async () => {
|
||||
const { supabase, enqueueMany } = createQueuedMockSupabase()
|
||||
vi.mocked(createServiceClientNoCookies).mockReturnValue(supabase as never)
|
||||
vi.mocked(resolvePayslipToken).mockResolvedValue({
|
||||
ok: true,
|
||||
link: {
|
||||
id: 'link-1',
|
||||
company_id: 'company-1',
|
||||
salary_run_id: 'run-1',
|
||||
employee_id: 'emp-1',
|
||||
token_hash: 'h',
|
||||
expires_at: new Date(Date.now() + 60_000).toISOString(),
|
||||
revoked_at: null,
|
||||
access_count: 0,
|
||||
},
|
||||
})
|
||||
enqueueMany([
|
||||
{ data: { id: 'run-1', period_year: 2026, period_month: 6, payment_date: '2026-06-25' } },
|
||||
{ data: { employee: { first_name: 'Anna', last_name: 'A', personnummer: 'enc' }, line_items: [] } },
|
||||
{ data: { name: 'Bolaget AB', org_number: null } },
|
||||
])
|
||||
|
||||
const response = await GET(
|
||||
createMockRequest('/api/payslip/t/pdf'),
|
||||
createMockRouteParams({ token: token('F') }),
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.headers.get('Content-Type')).toBe('application/pdf')
|
||||
expect(response.headers.get('Cache-Control')).toBe('no-store')
|
||||
expect(response.headers.get('Content-Disposition')).toContain('lonespec_Test_2026-06.pdf')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,113 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { renderToBuffer } from '@react-pdf/renderer'
|
||||
import { createServiceClientNoCookies } from '@/lib/auth/api-keys'
|
||||
import { resolvePayslipToken, isValidPayslipTokenFormat } from '@/lib/salary/payslips/links'
|
||||
import { buildPayslipData, payslipFileName } from '@/lib/salary/payslips/build-payslip-data'
|
||||
import { PayslipPDF } from '@/lib/salary/pdf/payslip-template'
|
||||
|
||||
// In-memory rate limiting per token (pattern from /api/calendar/feed).
|
||||
const rateLimitMap = new Map<string, { count: number; resetAt: number }>()
|
||||
const RATE_LIMIT_WINDOW_MS = 60_000
|
||||
const RATE_LIMIT_MAX = 20
|
||||
|
||||
let lastCleanup = Date.now()
|
||||
function cleanupRateLimitMap() {
|
||||
const now = Date.now()
|
||||
if (now - lastCleanup < 5 * 60_000) return
|
||||
lastCleanup = now
|
||||
for (const [key, value] of rateLimitMap) {
|
||||
if (now > value.resetAt) rateLimitMap.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/payslip/[token]/pdf
|
||||
*
|
||||
* Public payslip PDF download. The token IS the authentication — the code
|
||||
* path is the only guard (strict hash equality, revocation/expiry checks,
|
||||
* per-token rate limit). Salary PII: masked personnummer only, no-store,
|
||||
* and the raw token is never logged.
|
||||
*/
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ token: string }> },
|
||||
) {
|
||||
const { token } = await params
|
||||
|
||||
if (!isValidPayslipTokenFormat(token)) {
|
||||
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 })
|
||||
}
|
||||
|
||||
const serviceClient = createServiceClientNoCookies()
|
||||
const resolved = await resolvePayslipToken(serviceClient, token)
|
||||
|
||||
if (!resolved.ok) {
|
||||
if (resolved.reason === 'expired' || resolved.reason === 'revoked') {
|
||||
return new NextResponse('Link no longer valid', { status: 410 })
|
||||
}
|
||||
return new NextResponse('Not found', { status: 404 })
|
||||
}
|
||||
|
||||
const { link } = resolved
|
||||
|
||||
const [{ data: run }, { data: sre }, { data: company }] = await Promise.all([
|
||||
serviceClient
|
||||
.from('salary_runs')
|
||||
.select('*')
|
||||
.eq('id', link.salary_run_id)
|
||||
.eq('company_id', link.company_id)
|
||||
.single(),
|
||||
serviceClient
|
||||
.from('salary_run_employees')
|
||||
.select('*, employee:employees(first_name, last_name, personnummer, employment_type, tax_table_number, tax_column, clearing_number, bank_account_number), line_items:salary_line_items(*)')
|
||||
.eq('salary_run_id', link.salary_run_id)
|
||||
.eq('employee_id', link.employee_id)
|
||||
.single(),
|
||||
serviceClient
|
||||
.from('companies')
|
||||
.select('name, org_number')
|
||||
.eq('id', link.company_id)
|
||||
.single(),
|
||||
])
|
||||
|
||||
if (!run || !sre || !company) {
|
||||
return new NextResponse('Not found', { status: 404 })
|
||||
}
|
||||
|
||||
const emp = sre.employee as unknown as {
|
||||
first_name: string
|
||||
last_name: string
|
||||
personnummer: string
|
||||
employment_type: string
|
||||
tax_table_number: number | null
|
||||
tax_column: number
|
||||
clearing_number: string | null
|
||||
bank_account_number: string | null
|
||||
}
|
||||
|
||||
const data = buildPayslipData({ run, sre, employee: emp, company })
|
||||
const fileName = payslipFileName(run, emp)
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const buffer = await renderToBuffer(PayslipPDF({ data }) as any)
|
||||
|
||||
return new Response(buffer as unknown as BodyInit, {
|
||||
headers: {
|
||||
'Content-Type': 'application/pdf',
|
||||
'Content-Disposition': `attachment; filename="${fileName}"`,
|
||||
'Cache-Control': 'no-store',
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -30,6 +30,11 @@ vi.mock('@/lib/bookkeeping/category-mapping', () => ({
|
||||
getCategoryAccountMapping: (...args: unknown[]) => accountMappingMock(...args),
|
||||
}))
|
||||
|
||||
const buildLinesMock = vi.fn()
|
||||
vi.mock('@/lib/bookkeeping/transaction-entries', () => ({
|
||||
buildTransactionEntryLines: (...args: unknown[]) => buildLinesMock(...args),
|
||||
}))
|
||||
|
||||
import { PATCH } from '../route'
|
||||
|
||||
const mockUser = { id: 'user-1' }
|
||||
@@ -52,6 +57,7 @@ beforeEach(() => {
|
||||
vatDebitAccount: '2641',
|
||||
vatCreditAccount: null,
|
||||
})
|
||||
buildLinesMock.mockReturnValue([])
|
||||
})
|
||||
|
||||
describe('PATCH /api/pending-operations/[id]', () => {
|
||||
@@ -211,6 +217,58 @@ describe('PATCH /api/pending-operations/[id]', () => {
|
||||
expect(mappingMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('re-derives the full journal lines from the new mapping (stale-preview guard)', async () => {
|
||||
enqueue({
|
||||
data: {
|
||||
id: 'op-1',
|
||||
company_id: 'company-1',
|
||||
operation_type: 'categorize_transaction',
|
||||
status: 'pending',
|
||||
params: { transaction_id: 'tx-1', category: 'expense_other', vat_treatment: null },
|
||||
preview_data: {
|
||||
debit_account: '6990',
|
||||
credit_account: '1930',
|
||||
amount: 500,
|
||||
// Stale lines from staging — must be replaced, not spread through.
|
||||
lines: [{ account_number: '6990', debit_amount: 400, credit_amount: 0 }],
|
||||
},
|
||||
title: 'Kategorisera: X',
|
||||
},
|
||||
})
|
||||
enqueue({ data: { id: 'tx-1', company_id: 'company-1', amount: -500, currency: 'SEK' } })
|
||||
enqueue({ data: { entity_type: 'aktiebolag' } })
|
||||
enqueue({ data: { id: 'op-1', params: {}, preview_data: {}, title: '', status: 'pending' } })
|
||||
|
||||
const mapping = {
|
||||
debit_account: '5420',
|
||||
credit_account: '1930',
|
||||
vat_lines: [
|
||||
{ account_number: '2641', debit_amount: 100, credit_amount: 0, description: 'Ingående moms 25%' },
|
||||
],
|
||||
}
|
||||
mappingMock.mockReturnValue(mapping)
|
||||
buildLinesMock.mockReturnValue([
|
||||
{ account_number: '2641', debit_amount: 100, credit_amount: 0, line_description: 'Ingående moms 25%' },
|
||||
{ account_number: '5420', debit_amount: 400, credit_amount: 0, line_description: 'Kostnad' },
|
||||
{ account_number: '1930', debit_amount: 0, credit_amount: 500, line_description: 'X' },
|
||||
])
|
||||
|
||||
const res = await PATCH(
|
||||
createMockRequest('/api/pending-operations/op-1', {
|
||||
method: 'PATCH',
|
||||
body: { category: 'expense_software' },
|
||||
}),
|
||||
createMockRouteParams({ id: 'op-1' }),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(buildLinesMock).toHaveBeenCalledTimes(1)
|
||||
expect(buildLinesMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 'tx-1' }),
|
||||
mapping,
|
||||
)
|
||||
})
|
||||
|
||||
it('preserves a staged vat_amount override when the new treatment still carries VAT', async () => {
|
||||
enqueue({
|
||||
data: {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { ensureInitialized } from '@/lib/init'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
import { buildMappingResultFromCategory, getCategoryAccountMapping } from '@/lib/bookkeeping/category-mapping'
|
||||
import { buildTransactionEntryLines } from '@/lib/bookkeeping/transaction-entries'
|
||||
import { getVatRate } from '@/lib/bookkeeping/vat-entries'
|
||||
import type { EntityType, Transaction, TransactionCategory, VatTreatment } from '@/types'
|
||||
|
||||
@@ -191,6 +192,14 @@ export async function PATCH(
|
||||
credit_account: mapping.credit_account,
|
||||
amount: Math.abs((tx as Transaction).amount),
|
||||
currency: (tx as Transaction).currency,
|
||||
// Re-derive the exact journal lines (net cost line, VAT, gross bank) —
|
||||
// spreading oldPreview would otherwise leave stale lines from staging.
|
||||
lines: buildTransactionEntryLines(tx as Transaction, mapping).map((l) => ({
|
||||
account_number: l.account_number,
|
||||
debit_amount: l.debit_amount,
|
||||
credit_amount: l.credit_amount,
|
||||
description: l.line_description ?? '',
|
||||
})),
|
||||
vat_lines: (mapping.vat_lines ?? []).map((v) => ({
|
||||
account: v.account_number,
|
||||
amount: v.debit_amount || v.credit_amount,
|
||||
|
||||
@@ -28,7 +28,12 @@ vi.mock('@/lib/auth/require-write', () => ({
|
||||
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
|
||||
}))
|
||||
|
||||
import { DELETE } from '../route'
|
||||
vi.mock('@/lib/salary/personnummer', () => ({
|
||||
decryptPersonnummer: vi.fn((v: string) => v),
|
||||
maskPersonnummer: vi.fn(() => '19900101-****'),
|
||||
}))
|
||||
|
||||
import { DELETE, GET } from '../route'
|
||||
import { requireAuth } from '@/lib/auth/require-auth'
|
||||
|
||||
// ── Test data ────────────────────────────────────────────────
|
||||
@@ -118,3 +123,149 @@ describe('DELETE /api/salary/runs/[id]', () => {
|
||||
expect(body.data).toEqual({ id: 'run-1', deleted: true })
|
||||
})
|
||||
})
|
||||
|
||||
// ── GET detail additions (previous_run, corrected_by_run_id, deliveries) ────
|
||||
//
|
||||
// GET consumes queue entries in from() order. The run loads first; the rest
|
||||
// fire together in a Promise.all, so their from() calls resolve in this order:
|
||||
// 1 run, 2 employees, 3 settings (arbetsgivare), 4 previous-run lookup,
|
||||
// [corrected_by lookup when status is 'corrected'], deliveries,
|
||||
// [previous-run employees when found — nested after the lookup's await, so
|
||||
// it lands last].
|
||||
|
||||
const GET_RUN = {
|
||||
id: 'run-2',
|
||||
company_id: 'company-1',
|
||||
status: 'draft',
|
||||
period_year: 2026,
|
||||
period_month: 7,
|
||||
payment_date: '2026-07-25',
|
||||
}
|
||||
|
||||
describe('GET /api/salary/runs/[id] — additive detail fields', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
function authed(supabase: unknown) {
|
||||
vi.mocked(requireAuth).mockResolvedValue({
|
||||
user: mockUser as never,
|
||||
supabase: supabase as never,
|
||||
error: null,
|
||||
})
|
||||
}
|
||||
|
||||
it('returns previous_run with effective (override-coalesced) values', async () => {
|
||||
const { supabase, enqueueMany } = createQueuedMockSupabase()
|
||||
authed(supabase)
|
||||
|
||||
enqueueMany([
|
||||
{ data: GET_RUN },
|
||||
{ data: [] }, // employees in this run
|
||||
{ data: { org_number: null, entity_type: null } },
|
||||
{ data: { id: 'run-1', period_year: 2026, period_month: 6 } }, // previous-run lookup
|
||||
{ data: [] }, // deliveries
|
||||
{
|
||||
// previous-run employees — nested after the lookup's await, resolves last
|
||||
data: [
|
||||
{
|
||||
employee_id: 'emp-1',
|
||||
gross_salary: 35000,
|
||||
tax_withheld: 8000,
|
||||
tax_withheld_override: 7000,
|
||||
net_salary: 27000,
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
const response = await GET(
|
||||
createMockRequest('/api/salary/runs/run-2'),
|
||||
createMockRouteParams({ id: 'run-2' }),
|
||||
)
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
data: {
|
||||
previous_run: {
|
||||
id: string
|
||||
by_employee: Record<string, { gross: number; tax: number; net: number }>
|
||||
} | null
|
||||
corrected_by_run_id: string | null
|
||||
payslip_deliveries_summary: { sent: number; failed: number; skipped: number }
|
||||
}
|
||||
}>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.previous_run?.id).toBe('run-1')
|
||||
// Override coalesced: tax 7000, net compensated +1000
|
||||
expect(body.data.previous_run?.by_employee['emp-1']).toEqual({
|
||||
gross: 35000,
|
||||
tax: 7000,
|
||||
net: 28000,
|
||||
})
|
||||
expect(body.data.corrected_by_run_id).toBeNull()
|
||||
expect(body.data.payslip_deliveries_summary).toMatchObject({ sent: 0, failed: 0, skipped: 0 })
|
||||
})
|
||||
|
||||
it('returns previous_run null on the first-ever run', async () => {
|
||||
const { supabase, enqueueMany } = createQueuedMockSupabase()
|
||||
authed(supabase)
|
||||
|
||||
enqueueMany([
|
||||
{ data: GET_RUN },
|
||||
{ data: [] },
|
||||
{ data: null }, // settings
|
||||
{ data: null }, // no previous booked run
|
||||
{ data: [] }, // deliveries
|
||||
])
|
||||
|
||||
const response = await GET(
|
||||
createMockRequest('/api/salary/runs/run-2'),
|
||||
createMockRouteParams({ id: 'run-2' }),
|
||||
)
|
||||
const { status, body } = await parseJsonResponse<{ data: { previous_run: unknown } }>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.previous_run).toBeNull()
|
||||
})
|
||||
|
||||
it('exposes corrected_by_run_id on corrected originals and counts latest deliveries', async () => {
|
||||
const { supabase, enqueueMany } = createQueuedMockSupabase()
|
||||
authed(supabase)
|
||||
|
||||
enqueueMany([
|
||||
{ data: { ...GET_RUN, status: 'corrected' } },
|
||||
{ data: [] },
|
||||
{ data: null }, // settings
|
||||
{ data: null }, // previous booked run
|
||||
{ data: { id: 'run-correction' } }, // corrected_by lookup
|
||||
{
|
||||
data: [
|
||||
// newest first (route orders sent_at desc): emp-1 latest = sent
|
||||
{ employee_id: 'emp-1', status: 'sent', sent_at: '2026-07-01T10:00:00Z' },
|
||||
{ employee_id: 'emp-1', status: 'failed', sent_at: '2026-06-30T10:00:00Z' },
|
||||
{ employee_id: 'emp-2', status: 'skipped', sent_at: '2026-07-01T10:00:00Z' },
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
const response = await GET(
|
||||
createMockRequest('/api/salary/runs/run-2'),
|
||||
createMockRouteParams({ id: 'run-2' }),
|
||||
)
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
data: {
|
||||
corrected_by_run_id: string | null
|
||||
payslip_deliveries_summary: { sent: number; failed: number; skipped: number }
|
||||
}
|
||||
}>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.corrected_by_run_id).toBe('run-correction')
|
||||
// Latest attempt per employee: emp-1 sent (failure superseded), emp-2 skipped
|
||||
expect(body.data.payslip_deliveries_summary).toMatchObject({
|
||||
sent: 1,
|
||||
failed: 0,
|
||||
skipped: 1,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -137,4 +137,82 @@ describe('POST /api/salary/runs/[id]/approve: bank-detail guard', () => {
|
||||
|
||||
expect(status).toBe(200)
|
||||
})
|
||||
|
||||
it('flags the bank-detail block as overridable so the UI can offer "Godkänn ändå"', async () => {
|
||||
const { supabase, enqueueMany } = createQueuedMockSupabase()
|
||||
authed(supabase)
|
||||
|
||||
enqueueMany([
|
||||
{ data: { id: 'run-1', status: 'review', company_id: 'company-1' } },
|
||||
{ data: [runEmp({ first_name: 'Test', last_name: 'Testsson', net_salary: 24000, tax_withheld: 8000 })] },
|
||||
])
|
||||
|
||||
const request = createMockRequest('/api/salary/runs/run-1/approve', { method: 'POST' })
|
||||
const response = await POST(request, createMockRouteParams({ id: 'run-1' }))
|
||||
const { status, body } = await parseJsonResponse<{ code: string; overridable: boolean }>(response)
|
||||
|
||||
expect(status).toBe(400)
|
||||
expect(body.code).toBe('SALARY_APPROVE_BANK_DETAILS_MISSING')
|
||||
expect(body.overridable).toBe(true)
|
||||
})
|
||||
|
||||
it('approves past missing bank details when ?force=true, surfacing the reminder as a warning', async () => {
|
||||
const { supabase, enqueueMany } = createQueuedMockSupabase()
|
||||
authed(supabase)
|
||||
|
||||
enqueueMany([
|
||||
{ data: { id: 'run-1', status: 'review', company_id: 'company-1' } },
|
||||
{ data: [runEmp({ first_name: 'Test', last_name: 'Testsson', net_salary: 24000, tax_withheld: 8000 })] },
|
||||
{ data: { id: 'run-1', status: 'approved' } }, // update
|
||||
])
|
||||
|
||||
const request = createMockRequest('/api/salary/runs/run-1/approve', {
|
||||
method: 'POST',
|
||||
searchParams: { force: 'true' },
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'run-1' }))
|
||||
const { status, body } = await parseJsonResponse<{ data: { status: string }; warnings: string[] }>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.status).toBe('approved')
|
||||
expect(body.warnings.some((w) => w.includes('Bankuppgifter saknas'))).toBe(true)
|
||||
})
|
||||
|
||||
it('does not let ?force=true bypass a missing calculation (hard block)', async () => {
|
||||
const { supabase, enqueueMany } = createQueuedMockSupabase()
|
||||
authed(supabase)
|
||||
|
||||
enqueueMany([
|
||||
{ data: { id: 'run-1', status: 'review', company_id: 'company-1' } },
|
||||
{
|
||||
data: [
|
||||
{
|
||||
net_salary: 24000,
|
||||
tax_withheld: 8000,
|
||||
tax_withheld_override: null,
|
||||
calculation_breakdown: null, // never calculated → hard block
|
||||
employee: {
|
||||
first_name: 'Test',
|
||||
last_name: 'Testsson',
|
||||
clearing_number: '8327',
|
||||
bank_account_number: '1234567',
|
||||
email: 'employee@example.com',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
const request = createMockRequest('/api/salary/runs/run-1/approve', {
|
||||
method: 'POST',
|
||||
searchParams: { force: 'true' },
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'run-1' }))
|
||||
const { status, body } = await parseJsonResponse<{ code: string; overridable: boolean; details: string[] }>(response)
|
||||
|
||||
expect(status).toBe(400)
|
||||
expect(body.code).toBe('SALARY_APPROVE_BLOCKED')
|
||||
expect(body.overridable).toBe(false)
|
||||
expect(body.details[0]).toContain('Beräkning saknas')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,11 +6,15 @@ import { effectiveNetPayout } from '@/lib/salary/payment/effective-net'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
/** review → approved (authorization recorded, with pre-approve validation) */
|
||||
/** review → approved (authorization recorded, with pre-approve validation).
|
||||
* ?force=true bypasses the overridable "bank details missing" guard — approval
|
||||
* is an authorization step, so the user may approve now and complete bank
|
||||
* details before generating the payment file (which hard-blocks on its own). */
|
||||
export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
'salary.run.approve',
|
||||
async (_request, { supabase, companyId, user }, { params }) => {
|
||||
async (request, { supabase, companyId, user }, { params }) => {
|
||||
const { id } = await params
|
||||
const force = new URL(request.url).searchParams.get('force') === 'true'
|
||||
|
||||
// Verify run exists and is in review status
|
||||
const { data: run, error: runError } = await supabase
|
||||
@@ -31,7 +35,12 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
.select('*, employee:employees(first_name, last_name, clearing_number, bank_account_number, email)')
|
||||
.eq('salary_run_id', id)
|
||||
|
||||
const validationErrors: string[] = []
|
||||
// Blocking errors can never be overridden (a run with no calculation can't
|
||||
// be paid at all). Missing bank details are overridable at approval — the
|
||||
// payment-file generators (pain.001 / BG-LB) enforce them where it actually
|
||||
// matters, so the user may authorize now and complete details later.
|
||||
const blockingErrors: string[] = []
|
||||
const bankDetailErrors: string[] = []
|
||||
const warnings: string[] = []
|
||||
|
||||
for (const sre of runEmployees || []) {
|
||||
@@ -50,12 +59,12 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
// so no destination account is needed: mirrors the pain.001 / BG-LB
|
||||
// generators, which only include employees with effectiveNet > 0.
|
||||
if (effectiveNetPayout(sre) > 0 && (!emp.clearing_number || !emp.bank_account_number)) {
|
||||
validationErrors.push(`${name}: Bankuppgifter saknas (clearingnummer och/eller kontonummer)`)
|
||||
bankDetailErrors.push(`${name}: Bankuppgifter saknas (clearingnummer och/eller kontonummer)`)
|
||||
}
|
||||
|
||||
// Must have been calculated (calculation_breakdown exists)
|
||||
if (!sre.calculation_breakdown) {
|
||||
validationErrors.push(`${name}: Beräkning saknas, kör beräkning först`)
|
||||
blockingErrors.push(`${name}: Beräkning saknas, kör beräkning först`)
|
||||
}
|
||||
|
||||
// Warning: no email means pay slip cannot be sent
|
||||
@@ -64,15 +73,33 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
}
|
||||
}
|
||||
|
||||
if (validationErrors.length > 0) {
|
||||
if (blockingErrors.length > 0) {
|
||||
return NextResponse.json({
|
||||
error: 'Valideringsfel: korrigera innan godkännande',
|
||||
details: validationErrors,
|
||||
details: blockingErrors,
|
||||
warnings,
|
||||
code: 'SALARY_APPROVE_BLOCKED',
|
||||
overridable: false,
|
||||
}, { status: 400 })
|
||||
}
|
||||
|
||||
// All validation passed: approve
|
||||
// Overridable: block by default so the user is warned, but allow ?force=true
|
||||
// to approve anyway (the "Godkänn ändå" path).
|
||||
if (bankDetailErrors.length > 0 && !force) {
|
||||
return NextResponse.json({
|
||||
error: 'Bankuppgifter saknas för en eller flera anställda',
|
||||
details: bankDetailErrors,
|
||||
warnings,
|
||||
code: 'SALARY_APPROVE_BANK_DETAILS_MISSING',
|
||||
overridable: true,
|
||||
}, { status: 400 })
|
||||
}
|
||||
|
||||
// When forced past missing bank details, keep them visible on the success
|
||||
// response so the caller can still surface the reminder to complete them.
|
||||
const approveWarnings = force ? [...bankDetailErrors, ...warnings] : warnings
|
||||
|
||||
// All validation passed (or was explicitly overridden): approve
|
||||
const { data: updatedRun, error } = await supabase
|
||||
.from('salary_runs')
|
||||
.update({
|
||||
@@ -95,7 +122,7 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
payload: { salaryRunId: id, approvedBy: user.id, userId: user.id, companyId },
|
||||
})
|
||||
|
||||
return NextResponse.json({ data: updatedRun, warnings })
|
||||
return NextResponse.json({ data: updatedRun, warnings: approveWarnings })
|
||||
},
|
||||
{ requireWrite: true },
|
||||
)
|
||||
|
||||
@@ -5,6 +5,7 @@ import { requireCompanyId } from '@/lib/company/context'
|
||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
import { reverseEntry } from '@/lib/bookkeeping/engine'
|
||||
import { bookkeepingErrorResponse, EntryAlreadyReversedError } from '@/lib/bookkeeping/errors'
|
||||
import { revokeLinksForRun } from '@/lib/salary/payslips/links'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
@@ -77,6 +78,12 @@ export async function POST(
|
||||
.update({ status: 'corrected' })
|
||||
.eq('id', id)
|
||||
|
||||
// The storno replaces the payslips — previously emailed payslip links for
|
||||
// the original run must stop resolving (they show as "ersatt" to the
|
||||
// employee). Fresh links are issued when the correction run's payslips
|
||||
// are sent.
|
||||
await revokeLinksForRun(supabase, id)
|
||||
|
||||
// Create new correction run for same period
|
||||
// Remove the unique constraint conflict by using the original run's unique key
|
||||
// The unique constraint is (company_id, period_year, period_month) so we need
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user