diff --git a/DECISIONS.md b/DECISIONS.md index 81983afd..c80b493d 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -16,3 +16,7 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-07-06] Migration 20260706100000 adds profiles.deleted_at/anonymized_at (ADD COLUMN IF NOT EXISTS) alongside committing anonymize_user_account verbatim: the prod function writes those columns but no repo migration ever created them, so without the columns the drift capture would ship a function that fails on every from-scratch database (CI replay, self-hosted). No-op on prod. [2026-07-06] v1 reconciliation run: confidence_threshold has NO server-side default when omitted (existing API consumers keep current behavior; only the unattended enable-banking sync callers pass DEFAULT_UNATTENDED_CONFIDENCE_THRESHOLD=0.9); registry pitfalls recommend 0.9 to integrators. Revisit if telemetry shows API callers auto-applying fuzzy matches. [2026-07-05] Fixed supplier-invoice VAT silently dropped via MCP inbox conversion: gnubok_create_supplier_invoice_from_inbox now derives vat_amount from summed lineItems instead of the unreconciled OCR totals.vat field, and createSupplierInvoiceRegistrationEntry/CashEntry/PrivatelyPaidEntry gate the 2641 posting on itemsHaveVat(items) instead of invoice.vat_amount > 0. Chose to fix both the immediate source (server.ts) and the downstream gate (supplier-invoice-entries.ts) rather than just one: the header field is inherently a redundant, independently-sourced aggregate that can drift again from a different call site in the future, so the engine itself should never trust it as a gate. +[2026-07-06] v1 invoice POST (#895) refactored onto buildInvoiceWriteData instead of extending the hand-rolled compute: the v1 route was silently dropping ROT/RUT, article_id, revenue_account, accrual, and line_type fields that CreateInvoiceSchema already accepted; one shared builder eliminates that drift class permanently. Wire-shape kept: VAT_RULE_VIOLATION details stay snake_case via a mapping shim. +[2026-07-06] v1 dimension value DELETE mirrors internal semantics (hard-delete unreferenced, 409 DIMENSION_VALUE_REFERENCED with archive hint otherwise) rather than DELETE=archive: identical behavior across dashboard and API beats a simpler mental model that would surprise users comparing the two surfaces. Value dates (end_date for projects) ride the existing PATCH; whole-dimension DELETE stays unsupported. +[2026-07-06] Fastigheter-on-customers (item 3 of #895) deferred to a follow-up issue instead of shipping a quick column: single-default-property vs multi-property registry changes the data model and the ROT prefill UX; needs its own design pass. +[2026-07-06] v1 articles endpoint is read-only list (GET) under invoices:read: the #895 ask is "pick articles when composing invoices via API", not article CRUD; linking article_id does not auto-fill line fields (caller copies price/VAT), matching how invoice_items freeze article data at write time. diff --git a/app/api/v1/companies/[companyId]/articles/__tests__/route.test.ts b/app/api/v1/companies/[companyId]/articles/__tests__/route.test.ts new file mode 100644 index 00000000..d89ef577 --- /dev/null +++ b/app/api/v1/companies/[companyId]/articles/__tests__/route.test.ts @@ -0,0 +1,165 @@ +/** + * Tests for GET /api/v1/companies/:companyId/articles (#895). + */ +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' + +beforeAll(() => { + if (process.env.NODE_ENV !== 'test') throw new Error('NODE_ENV=test required') + process.env.NEXT_PUBLIC_SUPABASE_URL ||= 'http://localhost:54321' + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ||= 'test-anon-key' +}) + +vi.mock('@/lib/auth/api-keys', async () => { + const actual = await vi.importActual('@/lib/auth/api-keys') + return { ...actual, validateApiKey: vi.fn(), createServiceClientNoCookies: vi.fn() } +}) +vi.mock('@supabase/supabase-js', async () => { + const actual = await vi.importActual('@supabase/supabase-js') + return { ...actual, createClient: vi.fn().mockReturnValue({}) } +}) + +import { validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys' +import { GET as listArticles } from '../route' + +const mockValidate = validateApiKey as ReturnType +const mockServiceClient = createServiceClientNoCookies as ReturnType + +const COMPANY_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' + +const SAMPLE_ARTICLE = { + id: 'dddddddd-dddd-4ddd-8ddd-dddddddddddd', + article_number: 'A-0001', + name: 'Takarbete', + name_en: null, + type: 'tjanst', + unit: 'tim', + price_excl_vat: 850, + vat_rate: 25, + revenue_account: null, + cost_price: null, + ean: null, + housework_type: 'BYGG', + notes: null, + active: true, + created_at: '2026-05-01T09:14:33Z', + updated_at: '2026-05-01T09:14:33Z', +} + +/** Chainable mock whose terminal await resolves per-table. Records eq() calls. */ +function makeSupabase(byTable: Record) { + const eqCalls: Array<[string, unknown, unknown]> = [] + const buildChain = (table: string): unknown => { + const handler: ProxyHandler = { + get(_target, prop) { + if (prop === 'then') { + return (resolve: (v: unknown) => void) => + resolve(byTable[table] ?? { data: null, error: null }) + } + return (...args: unknown[]) => { + if (prop === 'eq') eqCalls.push([table, args[0], args[1]]) + return buildChain(table) + } + }, + } + return new Proxy({}, handler) + } + return { + from: vi.fn((table: string) => buildChain(table)), + rpc: vi.fn(() => buildChain('rpc')), + eqCalls, + } +} + +function makeRequest(url: string): Request { + return new Request(url, { + headers: { Authorization: 'Bearer test-fixture-not-a-real-key' }, + }) +} + +const routeParams = { params: Promise.resolve({ companyId: COMPANY_ID }) } + +beforeEach(() => { + vi.clearAllMocks() + mockValidate.mockResolvedValue({ + userId: 'user-1', + companyId: COMPANY_ID, + apiKeyId: 'ak_1', + apiKeyName: 'CI key', + scopes: ['invoices:read'], + mode: 'live', + }) +}) + +describe('GET /api/v1/companies/:companyId/articles', () => { + it('returns active articles with the documented projection', async () => { + const client = makeSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + articles: { data: [SAMPLE_ARTICLE], error: null }, + }) + mockServiceClient.mockReturnValue(client) + + const res = await listArticles( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/articles`), + routeParams, + ) + + expect(res.status).toBe(200) + const body = await res.json() + expect(body.data.articles).toHaveLength(1) + expect(body.data.articles[0].housework_type).toBe('BYGG') + // Default: inactive articles filtered out. + expect(client.eqCalls).toContainEqual(['articles', 'active', true]) + }) + + it('includes inactive articles with ?include_inactive=true', async () => { + const client = makeSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + articles: { data: [SAMPLE_ARTICLE, { ...SAMPLE_ARTICLE, id: 'eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee', active: false }], error: null }, + }) + mockServiceClient.mockReturnValue(client) + + const res = await listArticles( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/articles?include_inactive=true`), + routeParams, + ) + + expect(res.status).toBe(200) + const body = await res.json() + expect(body.data.articles).toHaveLength(2) + expect(client.eqCalls).not.toContainEqual(['articles', 'active', true]) + }) + + it('rejects a malformed include_inactive with 400 VALIDATION_ERROR', async () => { + mockServiceClient.mockReturnValue( + makeSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + }), + ) + + const res = await listArticles( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/articles?include_inactive=1`), + routeParams, + ) + + expect(res.status).toBe(400) + const body = await res.json() + expect(body.error.code).toBe('VALIDATION_ERROR') + }) + + it('rejects keys without invoices:read scope (403)', async () => { + mockValidate.mockResolvedValue({ + userId: 'user-1', + companyId: COMPANY_ID, + scopes: ['reports:read'], + mode: 'live', + }) + mockServiceClient.mockReturnValue(makeSupabase({})) + + const res = await listArticles( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/articles`), + routeParams, + ) + + expect(res.status).toBe(403) + }) +}) diff --git a/app/api/v1/companies/[companyId]/articles/route.ts b/app/api/v1/companies/[companyId]/articles/route.ts new file mode 100644 index 00000000..703ae137 --- /dev/null +++ b/app/api/v1/companies/[companyId]/articles/route.ts @@ -0,0 +1,127 @@ +/** + * GET /api/v1/companies/{companyId}/articles: list the artikelregister. + * + * Read-only (#895): exposes the article catalog so API callers can link + * invoice items via items[].article_id and copy the article's price, + * VAT rate, revenue-account override, and ROT/RUT arbetstypskod + * (housework_type) onto the line. Article CRUD stays dashboard-only for + * now; the register is small, so this is a plain (non-cursor) list with + * an include_inactive toggle, mirroring the internal /api/articles GET. + */ +import { z } from 'zod' +import { ok } from '@/lib/api/v1/response' +import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry' +import { withApiV1 } from '@/lib/api/v1/with-api-v1' +import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors' +import { fetchAllRows } from '@/lib/supabase/fetch-all' + +const ArticleShape = z.object({ + id: z.string().uuid(), + article_number: z.string().nullable(), + name: z.string(), + name_en: z.string().nullable(), + type: z.enum(['vara', 'tjanst']), + unit: z.string(), + price_excl_vat: z.number(), + vat_rate: z.number(), + revenue_account: z.string().nullable(), + cost_price: z.number().nullable(), + ean: z.string().nullable(), + housework_type: z.string().nullable(), + notes: z.string().nullable(), + active: z.boolean(), + created_at: z.string(), + updated_at: z.string(), +}) + +// Explicit projection: excludes user_id, company_id (internal scoping). +const ARTICLE_COLUMNS = + 'id, article_number, name, name_en, type, unit, price_excl_vat, vat_rate, revenue_account, cost_price, ean, housework_type, notes, active, created_at, updated_at' + +registerEndpoint({ + operation: 'articles.list', + method: 'GET', + path: '/api/v1/companies/:companyId/articles', + summary: 'List the article register (artikelregister).', + description: + 'Returns the company\'s articles ordered by name. Pass ?include_inactive=true to include soft-deactivated articles. Use the returned id as items[].article_id when creating invoices; housework_type carries the ROT/RUT arbetstypskod for service articles, and revenue_account the optional BAS class-3 override.', + useWhen: + 'You need the article catalog before composing invoice lines: to resolve an article_id, read its price/VAT defaults, or find ROT/RUT-tagged service articles (housework_type set).', + doNotUseFor: + 'Creating or editing articles (dashboard-only for now). Invoice line creation itself (POST …/invoices with items[].article_id).', + pitfalls: [ + 'Linking article_id does NOT auto-fill the invoice line: send description, unit_price, vat_rate etc. explicitly on the item (copy them from this response).', + 'price_excl_vat always excludes VAT.', + 'housework_type is an arbetstypskod hint (e.g. BYGG, STAD); the invoice line still needs deduction_type + labor_hours + work_type set explicitly for ROT/RUT.', + 'Inactive articles (active=false) are hidden by default but remain linkable for historical reads.', + ], + example: { + response: { + data: { + articles: [ + { + id: '0e9c…', + article_number: 'A-0001', + name: 'Takarbete', + name_en: null, + type: 'tjanst', + unit: 'tim', + price_excl_vat: 850, + vat_rate: 25, + revenue_account: null, + cost_price: null, + ean: null, + housework_type: 'BYGG', + notes: null, + active: true, + created_at: '2026-05-01T09:14:33Z', + updated_at: '2026-05-01T09:14:33Z', + }, + ], + }, + meta: { request_id: 'req_…', api_version: '2026-05-12' }, + }, + }, + scope: 'invoices:read', + risk: 'low', + idempotent: true, + reversible: false, + dryRunSupported: false, + response: { success: dataEnvelope(z.object({ articles: z.array(ArticleShape) })) }, +}) + +export const GET = withApiV1<{ params: Promise<{ companyId: string }> }>( + 'articles.list', + async (request, ctx) => { + const url = new URL(request.url) + const includeInactiveRaw = url.searchParams.get('include_inactive') + if (includeInactiveRaw !== null && !['true', 'false'].includes(includeInactiveRaw)) { + return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, { + requestId: ctx.requestId, + details: { field: 'include_inactive', message: 'Expected true or false.' }, + }) + } + const includeInactive = includeInactiveRaw === 'true' + + try { + // Imported product catalogs can exceed PostgREST's silent 1000-row cap: + // paginate internally, same as the dashboard route. Secondary order on + // id gives the stable total order .range() paging requires. + const articles = await fetchAllRows(({ from, to }) => { + let query = ctx.supabase + .from('articles') + .select(ARTICLE_COLUMNS) + .eq('company_id', ctx.companyId!) + if (!includeInactive) query = query.eq('active', true) + return query + .order('name', { ascending: true }) + .order('id', { ascending: true }) + .range(from, to) + }) + + return ok({ articles }, { requestId: ctx.requestId }) + } catch (err) { + return v1ErrorResponse(err, ctx.log, { requestId: ctx.requestId }) + } + }, +) diff --git a/app/api/v1/companies/[companyId]/dimensions/[id]/values/[valueId]/route.ts b/app/api/v1/companies/[companyId]/dimensions/[id]/values/[valueId]/route.ts new file mode 100644 index 00000000..22d1d69c --- /dev/null +++ b/app/api/v1/companies/[companyId]/dimensions/[id]/values/[valueId]/route.ts @@ -0,0 +1,269 @@ +/** + * PATCH /api/v1/companies/{companyId}/dimensions/{id}/values/{valueId}: + * update a dimension value (name / is_active / start_date / end_date; + * `code` is immutable). Archiving (is_active=false) and setting an + * end_date on a project code both go through here. + * DELETE /api/v1/companies/{companyId}/dimensions/{id}/values/{valueId}: + * delete an UNREFERENCED value. Values referenced by posted/reversed + * journal lines are protected by the DB retention trigger + * (enforce_dimension_value_retention, BFL 7-year philosophy) and + * return 409 DIMENSION_VALUE_REFERENCED: archive instead. + * + * Mirrors the internal /api/dimensions/[id]/values/[valueId] semantics so + * dashboard and API behave identically. + */ +import { z } from 'zod' +import { ok } from '@/lib/api/v1/response' +import { dryRunPreview } from '@/lib/api/v1/dry-run' +import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry' +import { withApiV1 } from '@/lib/api/v1/with-api-v1' +import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors' +import { UpdateDimensionValueSchema } from '@/lib/api/schemas' + +const DimensionValueShape = z.object({ + id: z.string().uuid(), + dimension_id: z.string().uuid(), + code: z.string(), + name: z.string(), + is_active: z.boolean(), + start_date: z.string().nullable(), + end_date: z.string().nullable(), +}) + +const VALUE_COLUMNS = 'id, dimension_id, code, name, is_active, start_date, end_date' + +registerEndpoint({ + operation: 'dimensions.values.update', + method: 'PATCH', + path: '/api/v1/companies/:companyId/dimensions/:id/values/:valueId', + summary: 'Update a dimension value (rename, archive, set start/end date).', + description: + 'Sparse update of a dimension value (SIE #OBJEKT): name, is_active (false = archive), start_date, end_date. `code` is immutable: renaming a code would orphan every journal line tagged with it; create a new value and archive the old one instead. Dates are only allowed on accumulating dimensions (resets_annually=false, e.g. dim 6 Projekt): use end_date to close a finished project. Idempotent (mandatory Idempotency-Key) and dry-runnable.', + useWhen: + 'You need to rename a project/cost-centre, mark a finished project with an end date, or archive (is_active=false) a value that should no longer be used on new lines.', + doNotUseFor: + 'Changing the code (immutable: create + archive instead). Removing an unused value entirely (use DELETE). Tagging lines (pass dimensions on the journal-entry line or invoice).', + pitfalls: [ + 'Idempotency-Key is mandatory.', + 'The :id segment is the dimension UUID and :valueId the value UUID (both from GET …/dimensions), not SIE numbers or codes.', + 'start_date/end_date return 400 DIMENSION_VALUE_DATES_NOT_ALLOWED on resets_annually dimensions (dim 1 Kostnadsställe).', + 'Archived values (is_active=false) still appear in GET …/dimensions and remain valid on historical lines; they are only blocked for NEW tags.', + ], + example: { + request: { end_date: '2026-08-31', is_active: false }, + response: { + data: { + id: '0e9c…', + dimension_id: 'a8f1…', + code: 'P001', + name: 'Villa Almgren tak', + is_active: false, + start_date: null, + end_date: '2026-08-31', + }, + meta: { request_id: 'req_…', api_version: '2026-05-12' }, + }, + }, + scope: 'bookkeeping:write', + risk: 'low', + idempotent: true, + reversible: true, + dryRunSupported: true, + request: { body: UpdateDimensionValueSchema }, + response: { success: dataEnvelope(DimensionValueShape) }, +}) + +registerEndpoint({ + operation: 'dimensions.values.delete', + method: 'DELETE', + path: '/api/v1/companies/:companyId/dimensions/:id/values/:valueId', + summary: 'Delete an unreferenced dimension value.', + description: + 'Hard-deletes a dimension value (SIE #OBJEKT) that no journal line references. Values used on posted or reversed verifikat are retained for the BFL 7-year archive and cannot be deleted: the DB trigger blocks it and this endpoint returns 409 DIMENSION_VALUE_REFERENCED. Archive those instead (PATCH is_active=false). Requires Idempotency-Key.', + useWhen: + 'A project/cost-centre code was created by mistake (typo, duplicate) and has never been used on any booking.', + doNotUseFor: + 'Retiring a project that has bookings: PATCH is_active=false (and optionally end_date) instead. Deleting a whole dimension (not supported).', + pitfalls: [ + 'Idempotency-Key is mandatory.', + '409 DIMENSION_VALUE_REFERENCED means the value is used on booked verifikat: it can never be deleted, only archived.', + 'Deletion is permanent: the code can be re-created afterwards, but the old row id is gone.', + ], + example: { + response: { + data: { deleted: true, id: '0e9c…' }, + meta: { request_id: 'req_…', api_version: '2026-05-12' }, + }, + }, + scope: 'bookkeeping:write', + risk: 'medium', + idempotent: true, + reversible: false, + dryRunSupported: false, + response: { success: dataEnvelope(z.object({ deleted: z.literal(true), id: z.string().uuid() })) }, +}) + +type ValueRouteParams = { params: Promise<{ companyId: string; id: string; valueId: string }> } + +async function resolveIds(params: ValueRouteParams['params']) { + const { id, valueId } = await params + const parsed = z + .object({ id: z.string().uuid(), valueId: z.string().uuid() }) + .safeParse({ id, valueId }) + return parsed.success ? parsed.data : null +} + +export const PATCH = withApiV1( + 'dimensions.values.update', + async (request, ctx, { params }) => { + const ids = await resolveIds(params) + if (!ids) { + return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, { + requestId: ctx.requestId, + details: { field: 'id', message: 'Dimension id and value id must be UUIDs.' }, + }) + } + + let rawBody: unknown + try { + rawBody = await request.json() + } catch { + return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, { + requestId: ctx.requestId, + details: { field: 'body', message: 'Body is not valid JSON.' }, + }) + } + + const parsed = UpdateDimensionValueSchema.safeParse(rawBody) + if (!parsed.success) { + return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, { + requestId: ctx.requestId, + details: { + issues: parsed.error.issues.map((i) => ({ + field: i.path.join('.'), + message: i.message, + })), + }, + }) + } + const body = parsed.data + + // Value dates only make sense on accumulating dimensions (projekt-style + // ranges). When the request carries an actual date (explicit null = clear, + // always allowed), check the parent dimension's resets_annually flag. + // Mirrors the internal route exactly. + if (body.start_date != null || body.end_date != null) { + const { data: dimension, error: dimError } = await ctx.supabase + .from('dimensions') + .select('id, resets_annually') + .eq('id', ids.id) + .eq('company_id', ctx.companyId!) + .maybeSingle() + + if (dimError) { + return v1ErrorResponse(dimError, ctx.log, { requestId: ctx.requestId }) + } + if (!dimension) { + return v1ErrorResponseFromCode('DIMENSION_NOT_FOUND', ctx.log, { + requestId: ctx.requestId, + details: { dimension_id: ids.id }, + }) + } + if (dimension.resets_annually) { + return v1ErrorResponseFromCode('DIMENSION_VALUE_DATES_NOT_ALLOWED', ctx.log, { + requestId: ctx.requestId, + }) + } + } + + const updateData: Record = {} + for (const key of ['name', 'is_active', 'start_date', 'end_date'] as const) { + if (body[key] !== undefined) updateData[key] = body[key] + } + + // Pre-flight fetch: needed for a faithful dry-run preview and a clean 404 + // before the write. + const { data: current, error: fetchErr } = await ctx.supabase + .from('dimension_values') + .select(VALUE_COLUMNS) + .eq('id', ids.valueId) + .eq('dimension_id', ids.id) + .eq('company_id', ctx.companyId!) + .maybeSingle() + + if (fetchErr) { + return v1ErrorResponse(fetchErr, ctx.log, { requestId: ctx.requestId }) + } + if (!current) { + return v1ErrorResponseFromCode('DIMENSION_VALUE_NOT_FOUND', ctx.log, { + requestId: ctx.requestId, + }) + } + + if (ctx.dryRun) { + return dryRunPreview({ ...current, ...updateData }, { requestId: ctx.requestId, log: ctx.log }) + } + + const { data, error } = await ctx.supabase + .from('dimension_values') + .update(updateData) + .eq('id', ids.valueId) + .eq('dimension_id', ids.id) + .eq('company_id', ctx.companyId!) + .select(VALUE_COLUMNS) + .single() + + if (error) { + return v1ErrorResponse(error, ctx.log, { requestId: ctx.requestId }) + } + + return ok(data, { requestId: ctx.requestId }) + }, + { requireIdempotencyKey: true }, +) + +export const DELETE = withApiV1( + 'dimensions.values.delete', + async (_request, ctx, { params }) => { + const ids = await resolveIds(params) + if (!ids) { + return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, { + requestId: ctx.requestId, + details: { field: 'id', message: 'Dimension id and value id must be UUIDs.' }, + }) + } + + const { data, error } = await ctx.supabase + .from('dimension_values') + .delete() + .eq('id', ids.valueId) + .eq('dimension_id', ids.id) + .eq('company_id', ctx.companyId!) + .select('id') + + if (error) { + // P0001 = plpgsql RAISE EXCEPTION: the retention trigger refusing the + // delete because posted/reversed lines reference the value. + if (error.code === 'P0001') { + return v1ErrorResponseFromCode('DIMENSION_VALUE_REFERENCED', ctx.log, { + requestId: ctx.requestId, + details: { value_id: ids.valueId }, + validAlternatives: { + archive_endpoint: `/api/v1/companies/${ctx.companyId}/dimensions/${ids.id}/values/${ids.valueId}`, + archive_body: { is_active: false }, + }, + }) + } + return v1ErrorResponse(error, ctx.log, { requestId: ctx.requestId }) + } + + if (!data || data.length === 0) { + return v1ErrorResponseFromCode('DIMENSION_VALUE_NOT_FOUND', ctx.log, { + requestId: ctx.requestId, + }) + } + + return ok({ deleted: true, id: ids.valueId }, { requestId: ctx.requestId }) + }, + { requireIdempotencyKey: true }, +) diff --git a/app/api/v1/companies/[companyId]/dimensions/__tests__/value-id.test.ts b/app/api/v1/companies/[companyId]/dimensions/__tests__/value-id.test.ts new file mode 100644 index 00000000..a28c1fdc --- /dev/null +++ b/app/api/v1/companies/[companyId]/dimensions/__tests__/value-id.test.ts @@ -0,0 +1,349 @@ +/** + * Tests for the v1 dimension value lifecycle endpoints (#895): + * PATCH /api/v1/companies/:companyId/dimensions/:id/values/:valueId + * DELETE /api/v1/companies/:companyId/dimensions/:id/values/:valueId + * + * Same Proxy-backed Supabase mock pattern as ./route.test.ts. + */ +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' + +beforeAll(() => { + if (process.env.NODE_ENV !== 'test') throw new Error('NODE_ENV=test required') + process.env.NEXT_PUBLIC_SUPABASE_URL ||= 'http://localhost:54321' + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ||= 'test-anon-key' +}) + +vi.mock('@/lib/auth/api-keys', async () => { + const actual = await vi.importActual('@/lib/auth/api-keys') + return { ...actual, validateApiKey: vi.fn(), createServiceClientNoCookies: vi.fn() } +}) +vi.mock('@supabase/supabase-js', async () => { + const actual = await vi.importActual('@supabase/supabase-js') + return { ...actual, createClient: vi.fn().mockReturnValue({}) } +}) + +import { validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys' +import { PATCH as patchValue, DELETE as deleteValue } from '../[id]/values/[valueId]/route' + +const mockValidate = validateApiKey as ReturnType +const mockServiceClient = createServiceClientNoCookies as ReturnType + +interface TableResp { + data?: unknown + error?: unknown +} + +function makeFlexibleSupabase(byTable: Record) { + const queues = new Map() + for (const [t, val] of Object.entries(byTable)) { + queues.set(t, Array.isArray(val) ? [...val] : [val]) + } + const buildChain = (key: string): unknown => { + const handler: ProxyHandler = { + get(_target, prop) { + if (prop === 'then') { + return (resolve: (v: unknown) => void) => { + const q = queues.get(key) + const next = q && q.length > 1 ? q.shift()! : (q?.[0] ?? { data: null, error: null }) + resolve(next) + } + } + return (..._args: unknown[]) => buildChain(key) + }, + } + return new Proxy({}, handler) + } + return { + from: vi.fn((table: string) => buildChain(table)), + rpc: vi.fn(() => buildChain('rpc')), + } +} + +const COMPANY_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' +const DIMENSION_ID = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' +const VALUE_ID = 'dddddddd-dddd-4ddd-8ddd-dddddddddddd' + +const URL_BASE = `https://x.test/api/v1/companies/${COMPANY_ID}/dimensions/${DIMENSION_ID}/values/${VALUE_ID}` +const routeParams = { + params: Promise.resolve({ companyId: COMPANY_ID, id: DIMENSION_ID, valueId: VALUE_ID }), +} + +const SAMPLE_VALUE = { + id: VALUE_ID, + dimension_id: DIMENSION_ID, + code: 'P001', + name: 'Villa Almgren tak', + is_active: true, + start_date: null, + end_date: null, +} + +function makeRequest(url: string, init?: RequestInit): Request { + return new Request(url, { + ...init, + headers: { + Authorization: 'Bearer test-fixture-not-a-real-key', + 'Idempotency-Key': 'c1aaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + ...(init?.headers ?? {}), + }, + }) +} + +beforeEach(() => { + vi.clearAllMocks() + mockValidate.mockResolvedValue({ + userId: 'user-1', + companyId: COMPANY_ID, + apiKeyId: 'ak_1', + apiKeyName: 'CI key', + scopes: ['bookkeeping:write'], + mode: 'live', + }) +}) + +describe('PATCH /api/v1/companies/:companyId/dimensions/:id/values/:valueId', () => { + it('updates name + is_active (archive) on the value', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + dimension_values: [ + { data: SAMPLE_VALUE, error: null }, + { data: { ...SAMPLE_VALUE, name: 'Nytt namn', is_active: false }, error: null }, + ], + idempotency_keys: { data: null, error: null }, + }), + ) + + const res = await patchValue( + makeRequest(URL_BASE, { + method: 'PATCH', + body: JSON.stringify({ name: 'Nytt namn', is_active: false }), + }), + routeParams, + ) + + expect(res.status).toBe(200) + const body = await res.json() + expect(body.data.name).toBe('Nytt namn') + expect(body.data.is_active).toBe(false) + }) + + it('sets an end_date on a project value (accumulating dimension)', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + dimensions: { data: { id: DIMENSION_ID, resets_annually: false }, error: null }, + dimension_values: [ + { data: SAMPLE_VALUE, error: null }, + { data: { ...SAMPLE_VALUE, end_date: '2026-08-31' }, error: null }, + ], + idempotency_keys: { data: null, error: null }, + }), + ) + + const res = await patchValue( + makeRequest(URL_BASE, { + method: 'PATCH', + body: JSON.stringify({ end_date: '2026-08-31' }), + }), + routeParams, + ) + + expect(res.status).toBe(200) + const body = await res.json() + expect(body.data.end_date).toBe('2026-08-31') + }) + + it('rejects dates on a resets_annually dimension (kostnadsställe)', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + dimensions: { data: { id: DIMENSION_ID, resets_annually: true }, error: null }, + idempotency_keys: { data: null, error: null }, + }), + ) + + const res = await patchValue( + makeRequest(URL_BASE, { + method: 'PATCH', + body: JSON.stringify({ end_date: '2026-08-31' }), + }), + routeParams, + ) + + expect(res.status).toBe(400) + const body = await res.json() + expect(body.error.code).toBe('DIMENSION_VALUE_DATES_NOT_ALLOWED') + }) + + it('returns 404 DIMENSION_VALUE_NOT_FOUND when the value is not in the company', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + dimension_values: { data: null, error: null }, + idempotency_keys: { data: null, error: null }, + }), + ) + + const res = await patchValue( + makeRequest(URL_BASE, { + method: 'PATCH', + body: JSON.stringify({ name: 'x' }), + }), + routeParams, + ) + + expect(res.status).toBe(404) + const body = await res.json() + expect(body.error.code).toBe('DIMENSION_VALUE_NOT_FOUND') + }) + + it('rejects an empty body (schema refine)', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + idempotency_keys: { data: null, error: null }, + }), + ) + + const res = await patchValue( + makeRequest(URL_BASE, { method: 'PATCH', body: JSON.stringify({}) }), + routeParams, + ) + + expect(res.status).toBe(400) + const body = await res.json() + expect(body.error.code).toBe('VALIDATION_ERROR') + }) + + it('dry-run previews the merged value without writing', async () => { + const fromSpy = vi.fn() + mockServiceClient.mockReturnValue({ + from: (table: string) => { + fromSpy(table) + const handler: ProxyHandler = { + get(_t, prop) { + if (prop === 'then') { + const data = + table === 'company_members' + ? { company_id: COMPANY_ID, role: 'owner' } + : table === 'dimension_values' + ? SAMPLE_VALUE + : null + return (resolve: (v: unknown) => void) => resolve({ data, error: null }) + } + return () => new Proxy({}, handler) + }, + } + return new Proxy({}, handler) + }, + rpc: vi.fn(), + }) + + const res = await patchValue( + makeRequest(`${URL_BASE}?dry_run=true`, { + method: 'PATCH', + body: JSON.stringify({ is_active: false }), + }), + routeParams, + ) + + expect(res.status).toBe(200) + expect(res.headers.get('X-Dry-Run')).toBe('true') + const body = await res.json() + expect(body.data.dry_run).toBe(true) + expect(body.data.preview.is_active).toBe(false) + // Only one read (the pre-flight fetch): no second dimension_values await + // means no UPDATE was issued through the queue. + expect(fromSpy.mock.calls.filter(([t]) => t === 'dimension_values')).toHaveLength(1) + }) + + it('rejects keys without bookkeeping:write scope (403)', async () => { + mockValidate.mockResolvedValue({ + userId: 'user-1', + companyId: COMPANY_ID, + scopes: ['reports:read'], + mode: 'live', + }) + mockServiceClient.mockReturnValue(makeFlexibleSupabase({})) + + const res = await patchValue( + makeRequest(URL_BASE, { method: 'PATCH', body: JSON.stringify({ name: 'x' }) }), + routeParams, + ) + + expect(res.status).toBe(403) + }) +}) + +describe('DELETE /api/v1/companies/:companyId/dimensions/:id/values/:valueId', () => { + it('deletes an unreferenced value', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + dimension_values: { data: [{ id: VALUE_ID }], error: null }, + idempotency_keys: { data: null, error: null }, + }), + ) + + const res = await deleteValue(makeRequest(URL_BASE, { method: 'DELETE' }), routeParams) + + expect(res.status).toBe(200) + const body = await res.json() + expect(body.data).toEqual({ deleted: true, id: VALUE_ID }) + }) + + it('returns 409 DIMENSION_VALUE_REFERENCED with an archive hint when the retention trigger blocks', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + dimension_values: { + data: null, + error: { code: 'P0001', message: 'Värdet "P001" används på bokförda verifikat' }, + }, + idempotency_keys: { data: null, error: null }, + }), + ) + + const res = await deleteValue(makeRequest(URL_BASE, { method: 'DELETE' }), routeParams) + + expect(res.status).toBe(409) + const body = await res.json() + expect(body.error.code).toBe('DIMENSION_VALUE_REFERENCED') + expect(body.error.valid_alternatives.archive_body).toEqual({ is_active: false }) + }) + + it('returns 404 DIMENSION_VALUE_NOT_FOUND when nothing was deleted', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + dimension_values: { data: [], error: null }, + idempotency_keys: { data: null, error: null }, + }), + ) + + const res = await deleteValue(makeRequest(URL_BASE, { method: 'DELETE' }), routeParams) + + expect(res.status).toBe(404) + const body = await res.json() + expect(body.error.code).toBe('DIMENSION_VALUE_NOT_FOUND') + }) + + it('returns 400 VALIDATION_ERROR for a non-UUID valueId', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + idempotency_keys: { data: null, error: null }, + }), + ) + + const res = await deleteValue( + makeRequest(URL_BASE, { method: 'DELETE' }), + { params: Promise.resolve({ companyId: COMPANY_ID, id: DIMENSION_ID, valueId: 'not-a-uuid' }) }, + ) + + expect(res.status).toBe(400) + const body = await res.json() + expect(body.error.code).toBe('VALIDATION_ERROR') + }) +}) diff --git a/app/api/v1/companies/[companyId]/invoices/[id]/route.ts b/app/api/v1/companies/[companyId]/invoices/[id]/route.ts index 86b047bd..5f3e190e 100644 --- a/app/api/v1/companies/[companyId]/invoices/[id]/route.ts +++ b/app/api/v1/companies/[companyId]/invoices/[id]/route.ts @@ -19,6 +19,8 @@ import { parseExpand } from '@/lib/api/v1/expand' import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry' import { withApiV1 } from '@/lib/api/v1/with-api-v1' import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors' +import { INVOICE_FULL_COLUMNS, INVOICE_ITEM_FULL_COLUMNS } from '@/lib/api/v1/invoice-columns' +import { DimensionsBagSchema } from '@/lib/bookkeeping/dimension-resolver' // Allowed PATCH fields for a draft invoice. Excludes items (separate // workflow), customer_id / currency / document_type (structural: change @@ -31,6 +33,10 @@ const V1PatchDraftInvoiceSchema = z.object({ your_reference: z.union([z.string(), z.null()]).optional(), our_reference: z.union([z.string(), z.null()]).optional(), notes: z.union([z.string(), z.null()]).optional(), + // Project/cost-centre tagging ({"6":"P001"}): replaces the whole bag. + // Send {} to clear all tags. Codes are validated against the dimension + // registry when the invoice posts at :send, not here. + default_dimensions: DimensionsBagSchema.optional(), }) // Loose schema: detail responses carry many fields, and pinning the exact @@ -53,17 +59,14 @@ const InvoiceDetail = z.object({ const ALLOWED_EXPAND = ['items', 'payments'] as const -// Explicit projections. Detail endpoint is more verbose than list: includes -// VAT treatment, conversion, FX, and notes, but still drops user_id and -// company_id (internal scoping). -const INVOICE_DETAIL_COLUMNS = - 'id, invoice_number, customer_id, invoice_date, due_date, delivery_date, status, currency, exchange_rate, exchange_rate_date, subtotal, subtotal_sek, vat_amount, vat_amount_sek, total, total_sek, vat_treatment, vat_rate, moms_ruta, your_reference, our_reference, notes, reverse_charge_text, credited_invoice_id, document_type, converted_from_id, paid_at, paid_amount, remaining_amount, default_dimensions, created_at, updated_at' +// Explicit projections, shared with the create route so create/detail/patch +// responses never drift. +const INVOICE_DETAIL_COLUMNS = INVOICE_FULL_COLUMNS const CUSTOMER_DETAIL_COLUMNS = 'id, name, customer_type, email, phone, address_line1, address_line2, postal_code, city, country, org_number, vat_number, vat_number_validated, default_payment_terms, notes, archived_at, created_at, updated_at' -const INVOICE_ITEM_COLUMNS = - 'id, sort_order, description, quantity, unit, unit_price, line_total, vat_rate, vat_amount, dimensions, created_at' +const INVOICE_ITEM_COLUMNS = INVOICE_ITEM_FULL_COLUMNS // Payment projection: drops invoice_id (redundant on the parent), user_id, // company_id (internal scoping). @@ -182,7 +185,7 @@ registerEndpoint({ path: '/api/v1/companies/:companyId/invoices/:id', summary: 'Update a draft invoice (metadata fields only).', description: - 'Partial update for invoices in draft status. Allowed fields: invoice_date, due_date, delivery_date, your_reference, our_reference, notes. customer_id, currency, document_type, items, and computed totals are immutable: replace those by deleting the draft and recreating it. Returns 409 INVOICE_UPDATE_NOT_DRAFT if the invoice is no longer in draft status. Idempotent and dry-runnable.', + 'Partial update for invoices in draft status. Allowed fields: invoice_date, due_date, delivery_date, your_reference, our_reference, notes, default_dimensions (project/cost-centre tags, e.g. {"6":"P001"}; replaces the whole bag). customer_id, currency, document_type, items, and computed totals are immutable: replace those by deleting the draft and recreating it. Returns 409 INVOICE_UPDATE_NOT_DRAFT if the invoice is no longer in draft status. Idempotent and dry-runnable.', useWhen: 'You need to correct a typo, push the due date, or update a customer reference on a draft you have not sent yet. The invoice number stays null until the first :send action.', doNotUseFor: @@ -191,6 +194,7 @@ registerEndpoint({ 'Idempotency-Key is mandatory.', 'A 409 INVOICE_UPDATE_NOT_DRAFT means the invoice has been sent / paid / credited / cancelled. The error code name is shared with the DELETE handler.', 'Items are immutable here: to change line items, delete the draft and POST a fresh one.', + 'default_dimensions replaces the entire bag (no per-key merge): read the current value first if you want to add a tag. Send {} to clear all tags. Codes are validated against the dimension registry at :send, not at PATCH time.', ], example: { request: { due_date: '2026-07-15', notes: 'Förlängd förfallotid' }, @@ -213,8 +217,7 @@ registerEndpoint({ response: { success: dataEnvelope(InvoiceDetail) }, }) -const INVOICE_PATCH_RESPONSE_COLUMNS = - 'id, invoice_number, customer_id, invoice_date, due_date, delivery_date, status, currency, exchange_rate, exchange_rate_date, subtotal, subtotal_sek, vat_amount, vat_amount_sek, total, total_sek, vat_treatment, vat_rate, moms_ruta, your_reference, our_reference, notes, reverse_charge_text, credited_invoice_id, document_type, converted_from_id, paid_at, paid_amount, remaining_amount, created_at, updated_at' +const INVOICE_PATCH_RESPONSE_COLUMNS = INVOICE_FULL_COLUMNS export const PATCH = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>( 'invoices.update', @@ -262,6 +265,7 @@ export const PATCH = withApiV1<{ params: Promise<{ companyId: string; id: string 'your_reference', 'our_reference', 'notes', + 'default_dimensions', ] as const) { if (body[key] !== undefined) updateData[key] = body[key] } diff --git a/app/api/v1/companies/[companyId]/invoices/__tests__/route.test.ts b/app/api/v1/companies/[companyId]/invoices/__tests__/route.test.ts index 678974e5..e0e2eb10 100644 --- a/app/api/v1/companies/[companyId]/invoices/__tests__/route.test.ts +++ b/app/api/v1/companies/[companyId]/invoices/__tests__/route.test.ts @@ -775,6 +775,253 @@ describe('POST /api/v1/companies/:companyId/invoices', () => { expect(body.data.preview.default_dimensions).toEqual({ '6': 'P001' }) expect(body.data.preview.items[0].dimensions).toEqual({ '1': 'KS01' }) }) + + // ── ROT/RUT + article linkage (#895) ──────────────────────────── + + // Valid 12-digit personnummer (Luhn-checked); same fixture family as + // lib/invoices/__tests__/rot-rut-file.test.ts. + const VALID_PNR = '198406012388' + + it('computes ROT deduction server-side and persists deduction fields', async () => { + withInvoiceWriteScope() + const createdInvoice = { id: 'eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee', status: 'draft' } + let insertedInvoice: Record | null = null + let insertedItems: Array> | null = null + mockServiceClient.mockReturnValue({ + from: (table: string) => { + const handler: ProxyHandler = { + get(_t, prop) { + if (prop === 'insert') { + return (rows: Record | Array>) => { + if (table === 'invoices') insertedInvoice = rows as Record + if (table === 'invoice_items') insertedItems = rows as Array> + return new Proxy({}, handler) + } + } + if (prop === 'then') { + const data = + table === 'company_members' + ? { company_id: COMPANY_ID, role: 'owner' } + : table === 'customers' + ? SWEDISH_BUSINESS_CUSTOMER + : table === 'invoices' + ? createdInvoice + : null + return (r: (v: unknown) => void) => r({ data, error: null }) + } + return () => new Proxy({}, handler) + }, + } + return new Proxy({}, handler) + }, + }) + + const res = await createInvoice( + makePostInvoice(`https://x.test/api/v1/companies/${COMPANY_ID}/invoices`, { + customer_id: CUSTOMER_ID, + invoice_date: '2026-05-12', + due_date: '2026-06-11', + currency: 'SEK', + deduction_personnummer: VALID_PNR, + deduction_housing_designation: 'Almgren 1:23', + items: [ + { + description: 'Takarbete', + quantity: 10, + unit: 'tim', + unit_price: 1000, + deduction_type: 'rot', + labor_hours: 10, + work_type: 'BYGG', + }, + ], + }), + companyParams(COMPANY_ID), + ) + + expect(res.status).toBe(201) + expect(insertedInvoice).not.toBeNull() + // ROT = 30% of 10000 labor. + expect(insertedInvoice!.deduction_total).toBe(3000) + // Personnummer never stored in plaintext: ciphertext + last4 only. + expect(insertedInvoice!.deduction_personnummer_encrypted).toBeTruthy() + expect(insertedInvoice!.deduction_personnummer_encrypted).not.toContain(VALID_PNR) + expect(insertedInvoice!.deduction_personnummer_last4).toBe('2388') + // Customer share: total 12500 minus the 3000 Skatteverket pays via 1513. + expect(insertedInvoice!.remaining_amount).toBe(9500) + expect(insertedItems).not.toBeNull() + expect(insertedItems![0].deduction_type).toBe('rot') + expect(insertedItems![0].deduction_amount).toBe(3000) + expect(insertedItems![0].work_type).toBe('BYGG') + expect(insertedItems![0].labor_hours).toBe(10) + // Invoice-level fastighetsbeteckning stamped onto the deduction line. + expect(insertedItems![0].housing_designation).toBe('Almgren 1:23') + }) + + it('rejects a ROT line without personnummer/housing info (400 INVOICE_CREATE_ROT_RUT_VALIDATION)', async () => { + withInvoiceWriteScope() + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + customers: { data: SWEDISH_BUSINESS_CUSTOMER, error: null }, + }), + ) + + const res = await createInvoice( + makePostInvoice(`https://x.test/api/v1/companies/${COMPANY_ID}/invoices`, { + customer_id: CUSTOMER_ID, + invoice_date: '2026-05-12', + due_date: '2026-06-11', + currency: 'SEK', + items: [ + { + description: 'Takarbete', + quantity: 10, + unit: 'tim', + unit_price: 1000, + deduction_type: 'rot', + labor_hours: 10, + work_type: 'BYGG', + }, + ], + }), + companyParams(COMPANY_ID), + ) + + expect(res.status).toBe(400) + const body = await res.json() + expect(body.error.code).toBe('INVOICE_CREATE_ROT_RUT_VALIDATION') + }) + + it('dry-run preview computes deduction_total and never echoes the encrypted personnummer', async () => { + withInvoiceWriteScope() + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + customers: { data: SWEDISH_BUSINESS_CUSTOMER, error: null }, + }), + ) + + const res = await createInvoice( + makePostInvoice(`https://x.test/api/v1/companies/${COMPANY_ID}/invoices?dry_run=true`, { + customer_id: CUSTOMER_ID, + invoice_date: '2026-05-12', + due_date: '2026-06-11', + currency: 'SEK', + deduction_personnummer: VALID_PNR, + deduction_housing_designation: 'Almgren 1:23', + items: [ + { + description: 'Städning', + quantity: 4, + unit: 'tim', + unit_price: 500, + deduction_type: 'rut', + labor_hours: 4, + work_type: 'STAD', + }, + ], + }), + companyParams(COMPANY_ID), + ) + + expect(res.status).toBe(200) + const body = await res.json() + // RUT = 50% of 2000 labor. + expect(body.data.preview.deduction_total).toBe(1000) + expect(body.data.preview.deduction_personnummer_last4).toBe('2388') + expect(body.data.preview.deduction_personnummer_encrypted).toBeUndefined() + expect(body.data.preview.items[0].deduction_amount).toBe(1000) + }) + + it('persists article_id + revenue_account on line items (validated against the chart)', async () => { + withInvoiceWriteScope() + const ARTICLE_ID = 'ffffffff-ffff-4fff-8fff-ffffffffffff' + const createdInvoice = { id: 'eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee', status: 'draft' } + let insertedItems: Array> | null = null + mockServiceClient.mockReturnValue({ + from: (table: string) => { + const handler: ProxyHandler = { + get(_t, prop) { + if (prop === 'insert') { + return (rows: Record | Array>) => { + if (table === 'invoice_items') insertedItems = rows as Array> + return new Proxy({}, handler) + } + } + if (prop === 'then') { + const data = + table === 'company_members' + ? { company_id: COMPANY_ID, role: 'owner' } + : table === 'customers' + ? SWEDISH_BUSINESS_CUSTOMER + : table === 'chart_of_accounts' + ? [{ account_number: '3041' }] + : table === 'invoices' + ? createdInvoice + : null + return (r: (v: unknown) => void) => r({ data, error: null }) + } + return () => new Proxy({}, handler) + }, + } + return new Proxy({}, handler) + }, + }) + + const res = await createInvoice( + makePostInvoice(`https://x.test/api/v1/companies/${COMPANY_ID}/invoices`, { + customer_id: CUSTOMER_ID, + invoice_date: '2026-05-12', + due_date: '2026-06-11', + currency: 'SEK', + items: [ + { + description: 'Takarbete', + quantity: 1, + unit: 'st', + unit_price: 5000, + article_id: ARTICLE_ID, + revenue_account: '3041', + }, + ], + }), + companyParams(COMPANY_ID), + ) + + expect(res.status).toBe(201) + expect(insertedItems).not.toBeNull() + expect(insertedItems![0].article_id).toBe(ARTICLE_ID) + expect(insertedItems![0].revenue_account).toBe('3041') + }) + + it('rejects a revenue_account not in the chart of accounts', async () => { + withInvoiceWriteScope() + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + customers: { data: SWEDISH_BUSINESS_CUSTOMER, error: null }, + chart_of_accounts: { data: [], error: null }, + }), + ) + + const res = await createInvoice( + makePostInvoice(`https://x.test/api/v1/companies/${COMPANY_ID}/invoices`, { + customer_id: CUSTOMER_ID, + invoice_date: '2026-05-12', + due_date: '2026-06-11', + currency: 'SEK', + items: [ + { description: 'x', quantity: 1, unit: 'st', unit_price: 100, revenue_account: '3999' }, + ], + }), + companyParams(COMPANY_ID), + ) + + expect(res.status).toBe(400) + const body = await res.json() + expect(body.error.code).toBe('INVOICE_CREATE_REVENUE_ACCOUNT_INVALID') + }) }) // ────────────────────────────────────────────────────────────────── @@ -904,6 +1151,54 @@ describe('PATCH /api/v1/companies/:companyId/invoices/:id', () => { expect(body.data.preview.due_date).toBe('2026-06-11') // unchanged from current }) + it('updates default_dimensions (project tag) on a draft invoice (#895)', async () => { + withInvoiceWriteScope() + const draftInvoice = { + id: INVOICE_ID, + status: 'draft', + invoice_date: '2026-05-12', + due_date: '2026-06-11', + default_dimensions: {}, + } + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + invoices: { data: { ...draftInvoice, default_dimensions: { '6': 'P001' } }, error: null }, + }), + ) + + const res = await updateInvoice( + makePatchInvoice(`https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}`, { + default_dimensions: { '6': 'P001' }, + }), + detailParams(COMPANY_ID, INVOICE_ID), + ) + + expect(res.status).toBe(200) + const body = await res.json() + expect(body.data.default_dimensions).toEqual({ '6': 'P001' }) + }) + + it('rejects a malformed default_dimensions bag', async () => { + withInvoiceWriteScope() + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + }), + ) + + const res = await updateInvoice( + makePatchInvoice(`https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}`, { + default_dimensions: { 'not-a-number': 'P001' }, + }), + detailParams(COMPANY_ID, INVOICE_ID), + ) + + expect(res.status).toBe(400) + const body = await res.json() + expect(body.error.code).toBe('VALIDATION_ERROR') + }) + it('rejects forbidden fields (items / currency / customer_id)', async () => { withInvoiceWriteScope() mockServiceClient.mockReturnValue( diff --git a/app/api/v1/companies/[companyId]/invoices/route.ts b/app/api/v1/companies/[companyId]/invoices/route.ts index cacef9bf..2fca2583 100644 --- a/app/api/v1/companies/[companyId]/invoices/route.ts +++ b/app/api/v1/companies/[companyId]/invoices/route.ts @@ -27,10 +27,10 @@ import { registerEndpoint, listEnvelope, dataEnvelope } from '@/lib/api/v1/regis import { withApiV1 } from '@/lib/api/v1/with-api-v1' import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors' import { CreateInvoiceSchema } from '@/lib/api/schemas' -import { getAvailableVatRates, getVatRules } from '@/lib/invoices/vat-rules' -import { convertToSEK, fetchExchangeRate } from '@/lib/currency/riksbanken' +import { INVOICE_FULL_COLUMNS, INVOICE_ITEM_FULL_COLUMNS } from '@/lib/api/v1/invoice-columns' +import { buildInvoiceWriteData } from '@/lib/invoices/build-invoice-write' import { eventBus } from '@/lib/events' -import type { Invoice, InvoiceDocumentType } from '@/types' +import type { Customer, Invoice, InvoiceDocumentType } from '@/types' const InvoiceStatus = z.enum([ 'draft', @@ -295,13 +295,10 @@ export const GET = withApiV1<{ params: Promise<{ companyId: string }> }>( // POST: create draft invoice (or proforma / delivery_note) // ────────────────────────────────────────────────────────────────── -// Response projection on create: same shape as the detail endpoint. -// Drop user_id, company_id (internal scoping). -const INVOICE_RESPONSE_COLUMNS = - 'id, invoice_number, customer_id, invoice_date, due_date, delivery_date, status, currency, exchange_rate, exchange_rate_date, subtotal, subtotal_sek, vat_amount, vat_amount_sek, total, total_sek, vat_treatment, vat_rate, moms_ruta, your_reference, our_reference, notes, reverse_charge_text, credited_invoice_id, document_type, converted_from_id, paid_at, paid_amount, remaining_amount, default_dimensions, created_at, updated_at' - -const INVOICE_ITEMS_RESPONSE_COLUMNS = - 'id, sort_order, description, quantity, unit, unit_price, line_total, vat_rate, vat_amount, dimensions, created_at' +// Response projection on create: same shape as the detail endpoint +// (shared module so create/detail/patch never drift). +const INVOICE_RESPONSE_COLUMNS = INVOICE_FULL_COLUMNS +const INVOICE_ITEMS_RESPONSE_COLUMNS = INVOICE_ITEM_FULL_COLUMNS // Loose response schema: invoices have many fields; pinning every one in // the registry is overkill until we have a real schema-drift test. @@ -327,7 +324,7 @@ registerEndpoint({ path: '/api/v1/companies/:companyId/invoices', summary: 'Create a draft invoice, proforma, or delivery note.', description: - 'Creates an invoice in draft status. The F-series invoice_number is allocated atomically on the first send action (PR-B-2b). Per-item VAT rates are validated against the customer\'s allowed rates (mixed-rate invoices supported). Non-SEK invoices are converted to SEK at the Riksbanken exchange rate fetched at create time. Idempotent (mandatory Idempotency-Key). Dry-runnable: the preview returns the validated would-be invoice + items with computed totals; no journal entry is involved at draft stage (posting happens on :send).', + 'Creates an invoice in draft status. The F-series invoice_number is allocated atomically on the first send action (PR-B-2b). Per-item VAT rates are validated against the customer\'s allowed rates (mixed-rate invoices supported). Non-SEK invoices are converted to SEK at the Riksbanken exchange rate fetched at create time. Supports ROT/RUT deduction lines (items[].deduction_type = "rot"|"rut" with invoice-level deduction_personnummer + deduction_housing_designation, or deduction_apartment_number + deduction_brf_org_number for bostadsrätt), article linkage (items[].article_id + optional revenue_account override from the artikelregister), and project/cost-centre tagging (default_dimensions / items[].dimensions). Idempotent (mandatory Idempotency-Key). Dry-runnable: the preview returns the validated would-be invoice + items with computed totals; no journal entry is involved at draft stage (posting happens on :send).', useWhen: 'You need to issue a new invoice, proforma, or delivery note. Use dry-run first to confirm VAT calculations and currency conversion before committing.', doNotUseFor: @@ -339,6 +336,8 @@ registerEndpoint({ 'invoice_number is null on creation. The number is allocated atomically when the invoice transitions out of draft. Counting on a specific number at create time is a bug.', 'document_type=\'delivery_note\' produces no VAT and a different number sequence (D-series). Most use cases want the default document_type=\'invoice\'.', 'Project/cost-center tagging: pass default_dimensions ({"6":"P001"} = project, {"1":"KS01"} = kostnadsställe) for the whole invoice and/or items[].dimensions per line (per-line wins per key). Tags are stored on the draft and applied to the journal entry lines when the invoice is sent. When the company has the dimension registry enabled, unknown or archived codes are rejected at :send with 400 DIMENSION_VALIDATION_FAILED — list valid codes via GET /dimensions.', + 'ROT/RUT: set items[].deduction_type ("rot"|"rut") on labor lines plus labor_hours and work_type (Skatteverket arbetstypskod). The invoice must carry deduction_personnummer AND housing info: deduction_housing_designation (fastighetsbeteckning) for småhus, or deduction_apartment_number + deduction_brf_org_number for bostadsrätt. deduction_amount is computed server-side and cannot be set by the caller; the response exposes deduction_total and remaining_amount = total - deduction_total (Skatteverket pays the rest via 1513). Validation failures return 400 INVOICE_CREATE_ROT_RUT_VALIDATION.', + 'Articles: pass items[].article_id (from the artikelregister, GET /articles) to link a line to a catalog article; price/description are still taken from the request body (the API never auto-fills from the article: send the values you want on the invoice). items[].revenue_account optionally overrides the BAS class-3 account and is validated against the chart of accounts.', ], example: { request: { @@ -414,8 +413,9 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>( const input = parsed.data const documentType: InvoiceDocumentType = input.document_type || 'invoice' - // Customer fetch (scoped to company). Determines VAT rules and the set - // of allowed per-item rates. + // Customer fetch (scoped to company). The builder only reads + // customer_type + vat_number_validated (VAT rules / allowed rates); + // select exactly those instead of '*' to keep PII out of this path. const { data: customer, error: customerErr } = await ctx.supabase .from('customers') .select('id, customer_type, vat_number_validated') @@ -433,116 +433,55 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>( }) } - const vatRules = getVatRules( - customer.customer_type as Parameters[0], - customer.vat_number_validated, - ) - const availableRates = getAvailableVatRates( - customer.customer_type as Parameters[0], - customer.vat_number_validated, - ) - const allowedRates = new Set(availableRates.map((r) => r.rate)) - - // Per-item VAT validation + totals. - const subtotal = input.items.reduce((sum, item) => sum + item.quantity * item.unit_price, 0) - let vatAmount = 0 - if (documentType !== 'delivery_note') { - for (const item of input.items) { - const itemRate = item.vat_rate !== undefined ? item.vat_rate : vatRules.rate - if (!allowedRates.has(itemRate)) { - return v1ErrorResponseFromCode('INVOICE_CREATE_VAT_RULE_VIOLATION', ctx.log, { - requestId: ctx.requestId, - details: { - attempted_rate: itemRate, - allowed_rates: Array.from(allowedRates), - customer_type: customer.customer_type, - }, - }) - } - const lineTotal = item.quantity * item.unit_price - vatAmount += Math.round((lineTotal * itemRate) / 100 * 100) / 100 - } - } - const total = documentType === 'delivery_note' ? 0 : subtotal + vatAmount - const uniqueRates = new Set(input.items.map((item) => item.vat_rate ?? vatRules.rate)) - const isMixedRate = uniqueRates.size > 1 - const headerVatRate = documentType === 'delivery_note' - ? 0 - : isMixedRate - ? null - : (uniqueRates.values().next().value ?? vatRules.rate) - - // Currency conversion (best-effort; non-fatal on failure). - let exchangeRate: number | null = null - let exchangeRateDate: string | null = null - let subtotalSek: number | null = null - let vatAmountSek: number | null = null - let totalSek: number | null = null - if (input.currency !== 'SEK') { - const rateData = await fetchExchangeRate(input.currency) - if (rateData) { - exchangeRate = rateData.rate - exchangeRateDate = rateData.date - subtotalSek = convertToSEK(subtotal, exchangeRate) - vatAmountSek = convertToSEK(vatAmount, exchangeRate) - totalSek = convertToSEK(total, exchangeRate) - } - } - - // Build computed item rows for the would-be insert. - const itemRows = input.items.map((item, index) => { - const itemRate = item.vat_rate !== undefined ? item.vat_rate : vatRules.rate - const lineTotal = item.quantity * item.unit_price - const itemVat = documentType === 'delivery_note' - ? 0 - : Math.round((lineTotal * itemRate) / 100 * 100) / 100 - return { - sort_order: index, - description: item.description, - quantity: item.quantity, - unit: item.unit, - unit_price: item.unit_price, - line_total: lineTotal, - vat_rate: itemRate, - vat_amount: itemVat, - // Dimensions PR7: per-item bag, merged over the invoice's - // default_dimensions on the revenue line when the JE posts at :send. - dimensions: item.dimensions ?? {}, - } + // Shared write-builder: identical validation + computation to the + // dashboard create/edit routes (VAT rule gating, accrual guards, + // revenue-account override checks, server-side ROT/RUT compute + + // personnummer encryption, currency conversion, item-row mapping). + // A v1 caller therefore gets the same field coverage as the UI: + // deduction lines, article linkage, and per-line dimensions included. + const build = await buildInvoiceWriteData({ + supabase: ctx.supabase, + companyId: ctx.companyId!, + // Narrow projection above; the builder only touches these two fields. + customer: customer as unknown as Customer, + documentType, + input, }) + if (!build.ok) { + if ('dbError' in build) { + return v1ErrorResponse(build.dbError, ctx.log, { requestId: ctx.requestId }) + } + // The builder emits camelCase detail keys (internal-route convention); + // the v1 wire shape for this code predates the builder and is + // documented snake_case: keep it stable for existing consumers. + const details = + build.code === 'INVOICE_CREATE_VAT_RULE_VIOLATION' && build.details + ? { + attempted_rate: build.details.attemptedRate, + allowed_rates: build.details.allowedRates, + customer_type: build.details.customerType, + } + : build.details + return v1ErrorResponseFromCode(build.code, ctx.log, { + requestId: ctx.requestId, + details, + }) + } + const { invoiceFields, items: itemRows } = build // Dry-run: validation-only preview. Drafts have no journal-entry side // effects yet, so no pending_operations staging needed; the // dryRunStaged() variant lands in PR-B-2b for :send. if (ctx.dryRun) { + // Never echo the encrypted personnummer blob in a preview; last4 is + // the display-safe representation the response columns expose too. + const { deduction_personnummer_encrypted: _omit, ...previewFields } = invoiceFields return dryRunPreview( { // Would-be invoice row. invoice_number: null, - customer_id: input.customer_id, - invoice_date: input.invoice_date, - due_date: input.due_date, - delivery_date: input.delivery_date ?? null, status: 'draft' as const, - currency: input.currency, - exchange_rate: exchangeRate, - exchange_rate_date: exchangeRateDate, - subtotal: documentType === 'delivery_note' ? 0 : subtotal, - subtotal_sek: documentType === 'delivery_note' ? null : subtotalSek, - vat_amount: vatAmount, - vat_amount_sek: documentType === 'delivery_note' ? null : vatAmountSek, - total, - total_sek: documentType === 'delivery_note' ? null : totalSek, - vat_treatment: vatRules.treatment, - vat_rate: headerVatRate, - moms_ruta: vatRules.momsRuta, - reverse_charge_text: vatRules.reverseChargeText || null, - your_reference: input.your_reference ?? null, - our_reference: input.our_reference ?? null, - notes: input.notes ?? null, - document_type: documentType, - remaining_amount: documentType === 'invoice' ? total : 0, - default_dimensions: input.default_dimensions ?? {}, + ...previewFields, items: itemRows, }, { requestId: ctx.requestId, log: ctx.log }, @@ -566,32 +505,8 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>( .insert({ user_id: ctx.userId, company_id: ctx.companyId!, - customer_id: input.customer_id, invoice_number: invoiceNumber, - invoice_date: input.invoice_date, - due_date: input.due_date, - delivery_date: input.delivery_date ?? null, - currency: input.currency, - exchange_rate: exchangeRate, - exchange_rate_date: exchangeRateDate, - subtotal: documentType === 'delivery_note' ? 0 : subtotal, - subtotal_sek: documentType === 'delivery_note' ? null : subtotalSek, - vat_amount: vatAmount, - vat_amount_sek: documentType === 'delivery_note' ? null : vatAmountSek, - total, - total_sek: documentType === 'delivery_note' ? null : totalSek, - remaining_amount: documentType === 'invoice' ? total : 0, - vat_treatment: vatRules.treatment, - vat_rate: headerVatRate, - moms_ruta: vatRules.momsRuta, - reverse_charge_text: vatRules.reverseChargeText || null, - your_reference: input.your_reference, - our_reference: input.our_reference, - notes: input.notes, - document_type: documentType, - // Dimensions PR7: invoice-level bag; the :send JE generator applies it - // to every line (items[].dimensions win per key). - default_dimensions: input.default_dimensions ?? {}, + ...invoiceFields, }) .select(INVOICE_RESPONSE_COLUMNS) .single() diff --git a/lib/api/v1/__tests__/__snapshots__/spec-snapshot.test.ts.snap b/lib/api/v1/__tests__/__snapshots__/spec-snapshot.test.ts.snap index 97e00029..401796c3 100644 --- a/lib/api/v1/__tests__/__snapshots__/spec-snapshot.test.ts.snap +++ b/lib/api/v1/__tests__/__snapshots__/spec-snapshot.test.ts.snap @@ -1,16 +1,18 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`v1 spec snapshot > matches the recorded endpoint count > endpoint-count 1`] = `104`; +exports[`v1 spec snapshot > matches the recorded endpoint count > endpoint-count 1`] = `107`; exports[`v1 spec snapshot > matches the recorded endpoint key set > endpoint-keys 1`] = ` [ "DELETE /api/v1/companies/:companyId/customers/:id", + "DELETE /api/v1/companies/:companyId/dimensions/:id/values/:valueId", "DELETE /api/v1/companies/:companyId/employees/:id", "DELETE /api/v1/companies/:companyId/salary-runs/:id", "DELETE /api/v1/companies/:companyId/suppliers/:id", "DELETE /api/v1/companies/:companyId/webhooks/:id", "GET /api/v1/companies", "GET /api/v1/companies/:companyId/accounts", + "GET /api/v1/companies/:companyId/articles", "GET /api/v1/companies/:companyId/compliance/check", "GET /api/v1/companies/:companyId/customers", "GET /api/v1/companies/:companyId/customers/:id", @@ -53,6 +55,7 @@ exports[`v1 spec snapshot > matches the recorded endpoint key set > endpoint-key "GET /api/v1/health", "GET /api/v1/operations/:id", "PATCH /api/v1/companies/:companyId/customers/:id", + "PATCH /api/v1/companies/:companyId/dimensions/:id/values/:valueId", "PATCH /api/v1/companies/:companyId/employees/:id", "PATCH /api/v1/companies/:companyId/invoices/:id", "PATCH /api/v1/companies/:companyId/salary-runs/:id", diff --git a/lib/api/v1/invoice-columns.ts b/lib/api/v1/invoice-columns.ts new file mode 100644 index 00000000..46dd346a --- /dev/null +++ b/lib/api/v1/invoice-columns.ts @@ -0,0 +1,18 @@ +/** + * Shared v1 invoice response projections. + * + * The create (POST), detail (GET), and draft-update (PATCH) endpoints all + * return the same invoice shape; keeping the column lists in one module + * prevents response-shape drift between them (a PATCH caller must see the + * same fields a GET caller does). Explicit projection: excludes user_id, + * company_id (internal scoping) and the encrypted personnummer blob + * (deduction_personnummer_last4 is the display-safe representation). + * Schema migrations adding columns must update these lists before the + * field becomes visible on the public API. + */ + +export const INVOICE_FULL_COLUMNS = + 'id, invoice_number, customer_id, invoice_date, due_date, delivery_date, status, currency, exchange_rate, exchange_rate_date, subtotal, subtotal_sek, vat_amount, vat_amount_sek, total, total_sek, vat_treatment, vat_rate, moms_ruta, your_reference, our_reference, notes, reverse_charge_text, credited_invoice_id, document_type, converted_from_id, paid_at, paid_amount, remaining_amount, default_dimensions, deduction_total, deduction_personnummer_last4, created_at, updated_at' + +export const INVOICE_ITEM_FULL_COLUMNS = + 'id, sort_order, line_type, description, quantity, unit, unit_price, line_total, vat_rate, vat_amount, article_id, revenue_account, deduction_type, deduction_amount, labor_hours, work_type, housing_designation, apartment_number, brf_org_number, dimensions, created_at' diff --git a/lib/api/v1/load-routes.ts b/lib/api/v1/load-routes.ts index 37e1d2e5..2d1fb50e 100644 --- a/lib/api/v1/load-routes.ts +++ b/lib/api/v1/load-routes.ts @@ -135,5 +135,10 @@ import '@/app/api/v1/companies/[companyId]/inbox-items/[id]/stamp/route' // Dimensions PR2: registry list + value creation (kostnadsställe/projekt). import '@/app/api/v1/companies/[companyId]/dimensions/route' import '@/app/api/v1/companies/[companyId]/dimensions/[id]/values/route' +// #895: value lifecycle (rename/archive/end-date + delete-unreferenced). +import '@/app/api/v1/companies/[companyId]/dimensions/[id]/values/[valueId]/route' + +// #895: articles read (artikelregister) for invoice line linkage. +import '@/app/api/v1/companies/[companyId]/articles/route' export {} diff --git a/lib/auth/scopes.ts b/lib/auth/scopes.ts index 2c925471..412ad600 100644 --- a/lib/auth/scopes.ts +++ b/lib/auth/scopes.ts @@ -186,6 +186,15 @@ export const V1_ENDPOINT_SCOPES: Record = { // is bookkeeping:write (it mints codes that journal lines reference). 'GET /api/v1/companies/:companyId/dimensions': 'reports:read', 'POST /api/v1/companies/:companyId/dimensions/:id/values': 'bookkeeping:write', + // Value lifecycle (#895): rename/archive/end-date via PATCH; DELETE only + // succeeds for unreferenced values (BFL retention trigger guards the rest). + 'PATCH /api/v1/companies/:companyId/dimensions/:id/values/:valueId': 'bookkeeping:write', + 'DELETE /api/v1/companies/:companyId/dimensions/:id/values/:valueId': 'bookkeeping:write', + + // Articles (artikelregister, #895): read-only list so invoice items can + // link article_id / copy housework_type + revenue_account. Rides + // invoices:read (the register exists to serve invoicing). + 'GET /api/v1/companies/:companyId/articles': 'invoices:read', // Webhooks (Phase 6 PR-1) 'GET /api/v1/companies/:companyId/webhooks': 'webhooks:manage',