diff --git a/DECISIONS.md b/DECISIONS.md index 514107fa..5b2223c9 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -768,3 +768,4 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-08-03] Tax depreciation (issue #324) is elected per fiscal period on a pooled snapshot chain, not per asset: fiscal_periods carries method, rule, opening, base, deduction and closing values, with DB guards enforcing method continuity and opening equal to the previous closing; assets keep depreciation_method linear for book depreciation, and company_settings.tax_depreciation_method was dropped because a second company-level method meant an admin/member RLS mismatch and a non-atomic second write; the annual snapshot chain is authoritative. [2026-08-03] kompletteringsregel_20 with a positive basis and zero acquisition cohorts is refused rather than computed: reducing over an empty cohort set would claim a full write-off the cohort evidence does not support (IL 18 kap. 17 §), so such periods require manual review instead of an automatic deduction. [2026-08-04] Balance-sheet synthetic result = complement of classes 1-2 (class 0/9/null rows included), not classes 3-8: a resultatavslut posted to 2099 without zeroing class 3-8 then self-cancels inside the residual instead of double-counting equity; mirrors balansrapport's residual definition (#1333) +[2026-08-04] v1 settings PATCH writes directly (no staging), following the v1 customers precedent: REST callers are already scope-gated, staging is an MCP segregation-of-duties concept. diff --git a/app/api/v1/companies/[companyId]/settings/__tests__/route.test.ts b/app/api/v1/companies/[companyId]/settings/__tests__/route.test.ts new file mode 100644 index 00000000..54f4b73d --- /dev/null +++ b/app/api/v1/companies/[companyId]/settings/__tests__/route.test.ts @@ -0,0 +1,453 @@ +/** + * Integration tests for PATCH /api/v1/companies/:companyId/settings. + * + * Modeled on the v1 customers route tests: mocked API-key auth + a flexible + * Supabase proxy mock; no real network or database access. + */ +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' + +beforeAll(() => { + // Belt-and-braces: ensure we never reach a real DB from this test suite. + if (process.env.NODE_ENV !== 'test') { + throw new Error( + `settings route tests require NODE_ENV=test (got ${process.env.NODE_ENV ?? 'undefined'})`, + ) + } + 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 updateSettings } from '../route' + +const mockValidate = validateApiKey as ReturnType +const mockServiceClient = createServiceClientNoCookies as ReturnType + +function makeFlexibleSupabase(byTable: Record) { + // Records update payloads and .select() projection strings so tests can + // assert what the route writes and which columns it fetches back. + const captured: { + update: unknown[] + selects: Record + } = { update: [], selects: {} } + 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 === 'update') captured.update.push(args[0]) + if (prop === 'select' && typeof args[0] === 'string') { + ;(captured.selects[table] ??= []).push(args[0]) + } + return buildChain(table) + } + }, + } + return new Proxy({}, handler) + } + return { from: vi.fn((table: string) => buildChain(table)), captured } +} + +const COMPANY_ID = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' +const USER_ID = 'user-1' + +function companyParams(companyId: string) { + return { params: Promise.resolve({ companyId }) } +} + +function makePatchRequest(url: string, body: unknown, extraHeaders: Record = {}): Request { + return new Request(url, { + method: 'PATCH', + headers: { + Authorization: 'Bearer test-fixture-not-a-real-key', + 'Content-Type': 'application/json', + 'Idempotency-Key': 'abcd1234-4444-4abc-8def-1234567890ab', + ...extraHeaders, + }, + body: JSON.stringify(body), + }) +} + +function withWriteScope() { + mockValidate.mockResolvedValue({ + userId: USER_ID, + companyId: COMPANY_ID, + apiKeyId: 'ak_1', + apiKeyName: 'CI key', + scopes: ['companies:write'], + mode: 'live', + }) +} + +const SAMPLE_SETTINGS = { + bank_name: 'Testbanken', + clearing_number: null, + account_number: null, + // '991-2346' passes the Bankgiro Luhn check (see lib/bankgiro Luhn tests). + bankgiro: '991-2346', + plusgiro: null, + swish: null, + iban: null, + bic: null, + default_our_reference: 'Anna Andersson', + email: 'faktura@acme.test', + phone: null, + website: null, + invoice_email_texts: null, +} + +beforeEach(() => { + vi.clearAllMocks() + withWriteScope() +}) + +describe('PATCH /api/v1/companies/:companyId/settings', () => { + it('returns 401 UNAUTHORIZED for an invalid API key', async () => { + mockValidate.mockResolvedValue({ error: 'Invalid API key', status: 401 }) + mockServiceClient.mockReturnValue(makeFlexibleSupabase({})) + + const res = await updateSettings( + makePatchRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/settings`, { + bank_name: 'X', + }), + companyParams(COMPANY_ID), + ) + + expect(res.status).toBe(401) + const body = await res.json() + expect(body.error.code).toBe('UNAUTHORIZED') + }) + + it('rejects keys without the companies:write scope', async () => { + mockValidate.mockResolvedValue({ + userId: USER_ID, + companyId: COMPANY_ID, + apiKeyId: 'ak_1', + apiKeyName: 'CI key', + scopes: ['companies:read'], + mode: 'live', + }) + mockServiceClient.mockReturnValue(makeFlexibleSupabase({})) + + const res = await updateSettings( + makePatchRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/settings`, { + bank_name: 'X', + }), + companyParams(COMPANY_ID), + ) + + expect(res.status).toBe(403) + const body = await res.json() + expect(body.error.code).toBe('INSUFFICIENT_SCOPE') + expect(body.error.details.required_scope).toBe('companies:write') + }) + + it('returns 404 when the caller is not a member of the company in the URL', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: null, error: null }, + }), + ) + + const res = await updateSettings( + makePatchRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/settings`, { + bank_name: 'X', + }), + companyParams(COMPANY_ID), + ) + + expect(res.status).toBe(404) + const body = await res.json() + expect(body.error.code).toBe('NOT_FOUND') + }) + + it('returns 404 when the company has no settings row', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + company_settings: { data: null, error: null }, + }), + ) + + const res = await updateSettings( + makePatchRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/settings`, { + bank_name: 'X', + }), + companyParams(COMPANY_ID), + ) + + expect(res.status).toBe(404) + const body = await res.json() + expect(body.error.code).toBe('NOT_FOUND') + expect(body.error.details).toEqual({ resource: 'company_settings' }) + }) + + it('rejects requests without an Idempotency-Key header', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + }), + ) + + const req = new Request(`https://x.test/api/v1/companies/${COMPANY_ID}/settings`, { + method: 'PATCH', + headers: { + Authorization: 'Bearer test-fixture-not-a-real-key', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ bank_name: 'X' }), + }) + + const res = await updateSettings(req, companyParams(COMPANY_ID)) + expect(res.status).toBe(400) + const body = await res.json() + expect(body.error.code).toBe('VALIDATION_ERROR') + }) + + it('returns 400 for a body that is not valid JSON', async () => { + const supabaseMock = makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + }) + mockServiceClient.mockReturnValue(supabaseMock) + + const req = new Request(`https://x.test/api/v1/companies/${COMPANY_ID}/settings`, { + method: 'PATCH', + headers: { + Authorization: 'Bearer test-fixture-not-a-real-key', + 'Content-Type': 'application/json', + 'Idempotency-Key': 'abcd1234-4444-4abc-8def-1234567890ab', + }, + body: '{"bank_name": not-json', + }) + + const res = await updateSettings(req, companyParams(COMPANY_ID)) + expect(res.status).toBe(400) + const body = await res.json() + expect(body.error.code).toBe('VALIDATION_ERROR') + expect(body.error.details).toEqual({ field: 'body', message: 'Body is not valid JSON.' }) + expect(supabaseMock.captured.update).toHaveLength(0) + }) + + it.each([ + ['a bare array', [{ bank_name: 'X' }]], + ['a bare string', 'bank_name=X'], + ['a bare number', 42], + ['null', null], + ])('returns 400 for a JSON body that is not an object (%s)', async (_label, jsonBody) => { + const supabaseMock = makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + }) + mockServiceClient.mockReturnValue(supabaseMock) + + const res = await updateSettings( + makePatchRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/settings`, jsonBody), + companyParams(COMPANY_ID), + ) + + expect(res.status).toBe(400) + const body = await res.json() + expect(body.error.code).toBe('VALIDATION_ERROR') + expect(body.error.details).toEqual({ field: 'body', message: 'Body must be a JSON object.' }) + expect(supabaseMock.captured.update).toHaveLength(0) + }) + + it('rejects an empty body (at least one field required)', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + }), + ) + + const res = await updateSettings( + makePatchRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/settings`, {}), + companyParams(COMPANY_ID), + ) + + expect(res.status).toBe(400) + const body = await res.json() + expect(body.error.code).toBe('VALIDATION_ERROR') + }) + + it('rejects unknown fields, including the internal column name default_our_reference', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + }), + ) + + const res = await updateSettings( + makePatchRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/settings`, { + default_our_reference: 'Sneaky', + vat_registered: true, + }), + companyParams(COMPANY_ID), + ) + + expect(res.status).toBe(400) + const body = await res.json() + expect(body.error.code).toBe('VALIDATION_ERROR') + const fields = body.error.details.issues.map((i: { field: string }) => i.field) + expect(fields).toContain('default_our_reference') + expect(fields).toContain('vat_registered') + }) + + it('returns 400 for a bankgiro that fails the Luhn check', async () => { + const supabaseMock = makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + }) + mockServiceClient.mockReturnValue(supabaseMock) + + const res = await updateSettings( + makePatchRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/settings`, { + // Right shape (regex passes) but wrong check digit: 991-2346 is valid. + bankgiro: '991-2345', + }), + companyParams(COMPANY_ID), + ) + + expect(res.status).toBe(400) + const body = await res.json() + expect(body.error.code).toBe('VALIDATION_ERROR') + const issue = body.error.details.issues.find((i: { field: string }) => i.field === 'bankgiro') + expect(issue).toBeTruthy() + expect(issue.message).toBe('Invalid Bankgiro number') + // Nothing was written. + expect(supabaseMock.captured.update).toHaveLength(0) + }) + + it('returns 400 for an unknown invoice email placeholder', async () => { + const supabaseMock = makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + }) + mockServiceClient.mockReturnValue(supabaseMock) + + const res = await updateSettings( + makePatchRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/settings`, { + invoice_email_texts: { sv: { body: 'Hej! Se faktura {faktura_nr}.' } }, + }), + companyParams(COMPANY_ID), + ) + + expect(res.status).toBe(400) + const body = await res.json() + expect(body.error.code).toBe('VALIDATION_ERROR') + const issue = body.error.details.issues.find( + (i: { field: string }) => i.field === 'invoice_email_texts.sv.body', + ) + expect(issue).toBeTruthy() + expect(issue.message).toContain('{faktura_nr}') + expect(supabaseMock.captured.update).toHaveLength(0) + }) + + it('updates settings and maps contact_person onto default_our_reference', async () => { + const supabaseMock = makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + company_settings: { data: SAMPLE_SETTINGS, error: null }, + }) + mockServiceClient.mockReturnValue(supabaseMock) + + const res = await updateSettings( + makePatchRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/settings`, { + contact_person: 'Anna Andersson', + bankgiro: '991-2346', + }), + companyParams(COMPANY_ID), + ) + + expect(res.status).toBe(200) + const body = await res.json() + // The response uses the public field name, mapped from the DB column. + expect(body.data.company_id).toBe(COMPANY_ID) + expect(body.data.contact_person).toBe('Anna Andersson') + expect(body.data.bankgiro).toBe('991-2346') + // The update payload carries the DB column name, not contact_person. + const updatePayload = supabaseMock.captured.update[0] as Record + expect(updatePayload.default_our_reference).toBe('Anna Andersson') + expect(updatePayload.bankgiro).toBe('991-2346') + expect(updatePayload.contact_person).toBeUndefined() + // The response projection reads the column back. + expect(supabaseMock.captured.selects['company_settings']?.[0]).toContain('default_our_reference') + }) + + it('keeps unsupplied fields undefined (never null) in the update payload', async () => { + // The route builds a literal 13-column update payload where unsupplied + // fields are undefined; supabase-js JSON serialization drops them, so + // the stored values survive a partial PATCH. A future `?? null` on that + // payload would silently CLEAR every column the caller did not send + // (null is a real write that empties the column). This test pins the + // undefined-not-null contract and fails on any such regression. + const supabaseMock = makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + company_settings: { data: { ...SAMPLE_SETTINGS, bank_name: 'Nya Banken' }, error: null }, + }) + mockServiceClient.mockReturnValue(supabaseMock) + + const res = await updateSettings( + makePatchRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/settings`, { + bank_name: 'Nya Banken', + }), + companyParams(COMPANY_ID), + ) + + expect(res.status).toBe(200) + expect(supabaseMock.captured.update).toHaveLength(1) + const updatePayload = supabaseMock.captured.update[0] as Record + expect(updatePayload.bank_name).toBe('Nya Banken') + + const unsuppliedKeys = Object.keys(updatePayload).filter((key) => key !== 'bank_name') + // Guard the guard: the literal payload declares every column, so the + // unsupplied set must be non-empty for the loop below to prove anything. + expect(unsuppliedKeys.length).toBeGreaterThan(0) + for (const key of unsuppliedKeys) { + expect( + updatePayload[key], + `unsupplied column "${key}" must be undefined in the update payload, never null`, + ).toBeUndefined() + } + // What actually reaches PostgREST after JSON serialization: only the + // supplied column remains. + expect(JSON.parse(JSON.stringify(updatePayload))).toEqual({ bank_name: 'Nya Banken' }) + }) + + it('dry-run merges the proposed changes with the current row and writes nothing', async () => { + const supabaseMock = makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + company_settings: { data: SAMPLE_SETTINGS, error: null }, + }) + mockServiceClient.mockReturnValue(supabaseMock) + + const res = await updateSettings( + makePatchRequest( + `https://x.test/api/v1/companies/${COMPANY_ID}/settings?dry_run=true`, + { contact_person: 'Bo Berg' }, + ), + companyParams(COMPANY_ID), + ) + + 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.contact_person).toBe('Bo Berg') + // Unchanged fields from the current record are preserved. + expect(body.data.preview.bank_name).toBe('Testbanken') + expect(supabaseMock.captured.update).toHaveLength(0) + }) +}) diff --git a/app/api/v1/companies/[companyId]/settings/route.ts b/app/api/v1/companies/[companyId]/settings/route.ts new file mode 100644 index 00000000..072d5912 --- /dev/null +++ b/app/api/v1/companies/[companyId]/settings/route.ts @@ -0,0 +1,332 @@ +/** + * /api/v1/companies/{companyId}/settings: company-settings writes. + * + * PATCH: partial update of invoice payment details (bank account, Bankgiro, + * Plusgiro, Swish, IBAN/BIC), company contact details shown on + * invoices (email, phone, website, contact_person), and the custom + * invoice email texts. Idempotent (mandatory Idempotency-Key). + * Dry-runnable. + * + * The field set is deliberately identical to the MCP staging tool + * gnubok_update_company_settings and validation is the SAME shared schema + * (UpdateCompanySettingsParamsSchema): Luhn-checked Bankgiro/Plusgiro and a + * fixed placeholder whitelist for the invoice email texts. Do not widen this + * surface toward the internal /api/settings PUT: that route accepts tax and + * legal profile fields and regenerates tax deadlines as a side effect. + * + * The write is direct (no staged operation), following the v1 customers + * precedent: REST callers are already gated by the companies:write scope. + * + * No GET here yet: a read endpoint is a possible follow-up (the MCP tool + * gnubok_get_company_settings covers reads today). + */ + +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 { InvoiceEmailTextsSchema, UpdateSettingsSchema } from '@/lib/api/schemas' +import { UpdateCompanySettingsParamsSchema } from '@/lib/pending-operations/schemas/company-settings' + +// Flat body keys copied into the update payload verbatim. Mirrors the MCP +// tool gnubok_update_company_settings field for field; contact_person is +// handled separately because it aliases the default_our_reference column. +const FLAT_BODY_KEYS = [ + 'bank_name', + 'clearing_number', + 'account_number', + 'bankgiro', + 'plusgiro', + 'swish', + 'iban', + 'bic', + 'email', + 'phone', + 'website', + 'invoice_email_texts', +] as const + +const KNOWN_BODY_KEYS: ReadonlySet = new Set([...FLAT_BODY_KEYS, 'contact_person']) + +interface SettingsRow { + bank_name: string | null + clearing_number: string | null + account_number: string | null + bankgiro: string | null + plusgiro: string | null + swish: string | null + iban: string | null + bic: string | null + default_our_reference: string | null + email: string | null + phone: string | null + website: string | null + invoice_email_texts: unknown +} + +const CompanySettingsResource = z.object({ + company_id: z.string().uuid(), + bank_name: z.string().nullable(), + clearing_number: z.string().nullable(), + account_number: z.string().nullable(), + bankgiro: z.string().nullable(), + plusgiro: z.string().nullable(), + swish: z.string().nullable(), + iban: z.string().nullable(), + bic: z.string().nullable(), + contact_person: z.string().nullable(), + email: z.string().nullable(), + phone: z.string().nullable(), + website: z.string().nullable(), + invoice_email_texts: InvoiceEmailTextsSchema.nullable(), +}) + +// Documentation body schema (OpenAPI + agent tool docs). Field shapes are +// reused from UpdateSettingsSchema, exactly like the shared changes schema +// composes them; contact_person exposes the default_our_reference column +// under its public name. Runtime validation goes through the shared +// UpdateCompanySettingsParamsSchema in the handler so the REST endpoint and +// the MCP tool can never drift apart on the Swedish-domain rules. +const V1PatchCompanySettingsSchema = z + .object({ + bank_name: UpdateSettingsSchema.shape.bank_name, + clearing_number: UpdateSettingsSchema.shape.clearing_number, + account_number: UpdateSettingsSchema.shape.account_number, + bankgiro: UpdateSettingsSchema.shape.bankgiro, + plusgiro: UpdateSettingsSchema.shape.plusgiro, + swish: UpdateSettingsSchema.shape.swish, + iban: UpdateSettingsSchema.shape.iban, + bic: UpdateSettingsSchema.shape.bic, + contact_person: UpdateSettingsSchema.shape.default_our_reference, + email: UpdateSettingsSchema.shape.email, + phone: UpdateSettingsSchema.shape.phone, + website: UpdateSettingsSchema.shape.website, + invoice_email_texts: UpdateSettingsSchema.shape.invoice_email_texts, + }) + .strict() + +function toSettingsResource(companyId: string, row: SettingsRow) { + return { + company_id: companyId, + bank_name: row.bank_name ?? null, + clearing_number: row.clearing_number ?? null, + account_number: row.account_number ?? null, + bankgiro: row.bankgiro ?? null, + plusgiro: row.plusgiro ?? null, + swish: row.swish ?? null, + iban: row.iban ?? null, + bic: row.bic ?? null, + contact_person: row.default_our_reference ?? null, + email: row.email ?? null, + phone: row.phone ?? null, + website: row.website ?? null, + invoice_email_texts: row.invoice_email_texts ?? null, + } +} + +/** + * Map a Zod issue path from the shared `{ changes: {...} }` wrapper back to + * the public body field names: strip the `changes` prefix and rename + * `default_our_reference` (the DB column) to `contact_person` (the only name + * this endpoint accepts in the request body). + */ +function formatIssueField(path: ReadonlyArray): string { + const rest = path[0] === 'changes' ? path.slice(1) : [...path] + if (rest.length === 0) return 'body' + return rest + .map((segment, index) => + index === 0 && segment === 'default_our_reference' ? 'contact_person' : String(segment), + ) + .join('.') +} + +registerEndpoint({ + operation: 'companies.settings.update', + method: 'PATCH', + path: '/api/v1/companies/:companyId/settings', + summary: 'Partially update company settings.', + description: + 'Patches the company payment details (bank account, Bankgiro, Plusgiro, Swish, IBAN/BIC), the contact details shown on invoices (contact_person, email, phone, website), and the custom invoice email texts. All fields optional; at least one must be supplied. Idempotent (mandatory Idempotency-Key). Dry-runnable. The same validation as the MCP staging tool applies: Bankgiro/Plusgiro numbers are Luhn-checked and invoice email texts only accept a fixed placeholder set.', + useWhen: + 'You need to change the payment or contact details that appear on invoices, or override the invoice email texts, directly over REST instead of the staged MCP flow.', + doNotUseFor: + 'Legal or tax profile changes (org number, VAT registration, fiscal year, accounting method): those are not exposed on the public API. Reading settings (no GET endpoint yet; use the MCP tool gnubok_get_company_settings).', + pitfalls: [ + 'Idempotency-Key is mandatory; calls without it return 400.', + 'contact_person is stored as default_our_reference: the default "Our reference" value on new invoices.', + 'bankgiro and plusgiro must carry a valid Luhn check digit; null or empty string clears them.', + 'invoice_email_texts only accepts the placeholders {fakturanummer} {kundnamn} {förnamn} {företag} {förfallodatum} {belopp}; any other {token} is rejected. Null clears every override.', + ], + example: { + request: { bankgiro: '991-2346', contact_person: 'Anna Andersson' }, + response: { + data: { + company_id: 'aaaa1111-2222-4333-8444-555566667777', + bank_name: 'Testbanken', + clearing_number: null, + account_number: null, + bankgiro: '991-2346', + plusgiro: null, + swish: null, + iban: null, + bic: null, + contact_person: 'Anna Andersson', + email: 'faktura@acme.example', + phone: null, + website: null, + invoice_email_texts: null, + }, + meta: { request_id: 'req_...', api_version: '2026-05-12' }, + }, + }, + scope: 'companies:write', + // Matches lib/pending-operations/risk-tiers.ts (update_company_settings): + // payment settings control where customers send money on future invoices. + risk: 'medium', + idempotent: true, + reversible: true, + dryRunSupported: true, + request: { body: V1PatchCompanySettingsSchema }, + response: { success: dataEnvelope(CompanySettingsResource) }, +}) + +export const PATCH = withApiV1<{ params: Promise<{ companyId: string }> }>( + 'companies.settings.update', + async (request, ctx) => { + let rawBody: unknown + try { + rawBody = await request.json() + } catch { + return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, { + requestId: ctx.requestId, + details: { field: 'body', message: 'Body is not valid JSON.' }, + }) + } + + if (rawBody === null || typeof rawBody !== 'object' || Array.isArray(rawBody)) { + return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, { + requestId: ctx.requestId, + details: { field: 'body', message: 'Body must be a JSON object.' }, + }) + } + const body = rawBody as Record + + // Reject unknown fields under their public names before the alias + // mapping, so the caller is told about `contact_person`, never about the + // internal column name. + const unknownKeys = Object.keys(body).filter((key) => !KNOWN_BODY_KEYS.has(key)) + if (unknownKeys.length > 0) { + return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, { + requestId: ctx.requestId, + details: { + issues: unknownKeys.map((key) => ({ field: key, message: 'Unknown field.' })), + }, + }) + } + + // Build the changes payload exactly like the MCP tool: copy the flat keys + // verbatim and alias the public contact_person field onto the + // default_our_reference column. + const rawChanges: Record = {} + for (const key of FLAT_BODY_KEYS) { + if (body[key] !== undefined) rawChanges[key] = body[key] + } + if (body.contact_person !== undefined) { + rawChanges.default_our_reference = body.contact_person + } + + // Shared Swedish-domain validation (same schema as the MCP staging tool): + // Luhn-checked bankgiro/plusgiro, placeholder whitelist on the invoice + // email texts, and the at-least-one-field rule. + const parsed = UpdateCompanySettingsParamsSchema.safeParse({ changes: rawChanges }) + if (!parsed.success) { + return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, { + requestId: ctx.requestId, + details: { + issues: parsed.error.issues.map((issue) => ({ + field: formatIssueField(issue.path), + message: issue.message, + })), + }, + }) + } + const changes = parsed.data.changes + + // Dry-run: fetch the current row, merge the proposed changes, return the + // merged preview. No DB write. + if (ctx.dryRun) { + // Literal projection (not a shared const): the schema guard + // (tests/schema/no-phantom-columns.test.ts) can only verify columns in + // inline literals. Same column set as the MCP tool; excludes tax/legal + // profile columns on purpose (see the module doc). + const { data: current, error: fetchErr } = await ctx.supabase + .from('company_settings') + .select('bank_name, clearing_number, account_number, bankgiro, plusgiro, swish, iban, bic, default_our_reference, email, phone, website, invoice_email_texts') + .eq('company_id', ctx.companyId!) + .maybeSingle() + + if (fetchErr) { + return v1ErrorResponse(fetchErr, ctx.log, { requestId: ctx.requestId }) + } + if (!current) { + ctx.log.warn('companies.settings.update dry-run: settings row not found', { + companyId: ctx.companyId, + }) + return v1ErrorResponseFromCode('NOT_FOUND', ctx.log, { + requestId: ctx.requestId, + details: { resource: 'company_settings' }, + }) + } + + return dryRunPreview( + toSettingsResource(ctx.companyId!, { ...(current as unknown as SettingsRow), ...changes }), + { requestId: ctx.requestId, log: ctx.log }, + ) + } + + // Literal payload (not the parsed object): the schema guard can then + // statically verify every column name. Fields the caller did not supply + // are `undefined` here and are dropped by supabase-js JSON serialization, + // so only supplied fields are written; explicit null still clears. + const { data, error } = await ctx.supabase + .from('company_settings') + .update({ + bank_name: changes.bank_name, + clearing_number: changes.clearing_number, + account_number: changes.account_number, + bankgiro: changes.bankgiro, + plusgiro: changes.plusgiro, + swish: changes.swish, + iban: changes.iban, + bic: changes.bic, + default_our_reference: changes.default_our_reference, + email: changes.email, + phone: changes.phone, + website: changes.website, + invoice_email_texts: changes.invoice_email_texts, + }) + .eq('company_id', ctx.companyId!) + .select('bank_name, clearing_number, account_number, bankgiro, plusgiro, swish, iban, bic, default_our_reference, email, phone, website, invoice_email_texts') + .maybeSingle() + + if (error) { + return v1ErrorResponse(error, ctx.log, { requestId: ctx.requestId }) + } + if (!data) { + ctx.log.warn('companies.settings.update: settings row not found', { + companyId: ctx.companyId, + }) + return v1ErrorResponseFromCode('NOT_FOUND', ctx.log, { + requestId: ctx.requestId, + details: { resource: 'company_settings' }, + }) + } + + return ok(toSettingsResource(ctx.companyId!, data as unknown as SettingsRow), { + requestId: ctx.requestId, + }) + }, + { requireIdempotencyKey: true }, +) 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 b77da9aa..e24c53d2 100644 --- a/lib/api/v1/__tests__/__snapshots__/spec-snapshot.test.ts.snap +++ b/lib/api/v1/__tests__/__snapshots__/spec-snapshot.test.ts.snap @@ -1,6 +1,6 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`v1 spec snapshot > matches the recorded endpoint count > endpoint-count 1`] = `123`; +exports[`v1 spec snapshot > matches the recorded endpoint count > endpoint-count 1`] = `124`; exports[`v1 spec snapshot > matches the recorded endpoint key set > endpoint-keys 1`] = ` [ @@ -69,6 +69,7 @@ exports[`v1 spec snapshot > matches the recorded endpoint key set > endpoint-key "PATCH /api/v1/companies/:companyId/invoices/:id", "PATCH /api/v1/companies/:companyId/salary-runs/:id", "PATCH /api/v1/companies/:companyId/salary-runs/:id/lines/:lineId", + "PATCH /api/v1/companies/:companyId/settings", "PATCH /api/v1/companies/:companyId/supplier-invoices/:id", "PATCH /api/v1/companies/:companyId/suppliers/:id", "PATCH /api/v1/companies/:companyId/webhooks/:id", @@ -134,6 +135,7 @@ exports[`v1 spec snapshot > matches the recorded scope catalogue > endpoint-scop [ "bookkeeping:write", "companies:read", + "companies:write", "compliance:read", "customers:read", "customers:write", diff --git a/lib/api/v1/load-routes.ts b/lib/api/v1/load-routes.ts index 411a9437..99bf0ef9 100644 --- a/lib/api/v1/load-routes.ts +++ b/lib/api/v1/load-routes.ts @@ -161,4 +161,7 @@ import '@/app/api/v1/companies/[companyId]/dimensions/[id]/values/[valueId]/rout // #895: articles read (artikelregister) for invoice line linkage. import '@/app/api/v1/companies/[companyId]/articles/route' +// #1348: company-settings write (PATCH, MCP-tool-identical field set). +import '@/app/api/v1/companies/[companyId]/settings/route' + export {} diff --git a/lib/auth/api-keys.ts b/lib/auth/api-keys.ts index fbdf214b..56ffe25c 100644 --- a/lib/auth/api-keys.ts +++ b/lib/auth/api-keys.ts @@ -23,7 +23,7 @@ export const API_KEY_SCOPES = { 'payroll:write': { label: 'Löner: skriv', description: 'Skapa lönekörning, beräkna, generera AGI (3 verktyg)' }, // v1 REST API: added Phase 1 'companies:read': { label: 'Företag: läs', description: 'Lista och visa företagsprofiler som API-nyckeln har tillgång till' }, - 'companies:write': { label: 'Företag: skriv', description: 'Uppdatera företagsinställningar via stagade verktyg' }, + 'companies:write': { label: 'Företag: skriv', description: 'Uppdatera företagsinställningar via stagade verktyg eller REST-endpointen PATCH /api/v1/companies/{companyId}/settings' }, 'events:read': { label: 'Händelser: läs', description: 'Polla händelseloggen (event_log) som webhook-fallback' }, 'webhooks:manage': { label: 'Webhooks: hantera', description: 'Skapa, lista, uppdatera och radera webhook-prenumerationer' }, 'operations:read': { label: 'Operationer: läs', description: 'Hämta status för långkörande operationer (importer, bokslut, omvärdering)' }, diff --git a/lib/auth/scopes.ts b/lib/auth/scopes.ts index 68beeb65..924b37df 100644 --- a/lib/auth/scopes.ts +++ b/lib/auth/scopes.ts @@ -39,6 +39,9 @@ export const V1_ENDPOINT_SCOPES: Record = { // Companies 'GET /api/v1/companies': 'companies:read', 'GET /api/v1/companies/:companyId': 'companies:read', + // Issue #1348: company-settings write (same field set as the MCP tool + // gnubok_update_company_settings; direct write, no staging). + 'PATCH /api/v1/companies/:companyId/settings': 'companies:write', // Operations (async long-running tasks) 'GET /api/v1/operations/:id': 'operations:read',