feat(api): v1 REST company-settings write endpoint (PATCH) (#1405)

* feat(api): v1 REST company-settings write endpoint (PATCH)

Adds PATCH /api/v1/companies/{companyId}/settings, closing the gap where
the v1 REST surface had no company-settings write (only the staged MCP
tool gnubok_update_company_settings could change them).

- Field set is identical to the MCP tool: payment details (bank account,
  bankgiro, plusgiro, swish, iban, bic), invoice contact details (email,
  phone, website), contact_person (aliased onto default_our_reference,
  exactly as the MCP tool maps it), and invoice_email_texts.
- Validation reuses the shared UpdateCompanySettingsParamsSchema (Luhn
  bankgiro/plusgiro, invoice email placeholder whitelist), so REST and
  MCP can never drift apart on the Swedish-domain rules.
- Writes directly with an explicit .eq('company_id', ...) filter,
  following the v1 customers PATCH precedent: no staged operation, since
  REST callers are already gated by the companies:write scope.
- Dry-runnable, mandatory Idempotency-Key, registered in the endpoint
  catalogue, scope map, and load-routes; spec snapshot updated.
- The companies:write scope description now mentions the REST endpoint.

No GET endpoint yet (possible follow-up); reads stay on the MCP tool.

Fixes #1348

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

* test(v1): harden company-settings PATCH contract, align risk tier

Adversarial-review follow-up for the settings PATCH endpoint (#1348):

- Declare risk: 'medium' in registerEndpoint, matching the
  update_company_settings tier in lib/pending-operations/risk-tiers.ts
  (payment settings control where customers send money on future
  invoices). The spec snapshot does not pin the risk field, so no
  snapshot regeneration is needed.
- Pin the partial-PATCH contract: every column the caller did not
  supply must arrive as undefined in the update payload, never null.
  A future ?? null on the literal 13-column payload would silently
  clear every unsupplied column; the new test fails on exactly that
  regression (verified by mutation).
- Cover the body-parsing branches: invalid JSON and non-object JSON
  bodies (bare array, string, number, null) each return 400 with the
  handler's respective message and never reach the update call.

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-08-04 19:46:13 +02:00
committed by GitHub
co-authored by Claude Fable 5 Jakob Wennberg
parent 9f5a43310b
commit 86c6af6976
7 changed files with 796 additions and 2 deletions
+1
View File
@@ -768,3 +768,4 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. 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.
@@ -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<typeof import('@/lib/auth/api-keys')>('@/lib/auth/api-keys')
return {
...actual,
validateApiKey: vi.fn(),
createServiceClientNoCookies: vi.fn(),
}
})
vi.mock('@supabase/supabase-js', async () => {
const actual = await vi.importActual<typeof import('@supabase/supabase-js')>('@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<typeof vi.fn>
const mockServiceClient = createServiceClientNoCookies as ReturnType<typeof vi.fn>
function makeFlexibleSupabase(byTable: Record<string, { data?: unknown; error?: unknown }>) {
// 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<string, string[]>
} = { update: [], selects: {} }
const buildChain = (table: string): unknown => {
const handler: ProxyHandler<object> = {
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<string, string> = {}): 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<string, unknown>
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<string, unknown>
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)
})
})
@@ -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<string> = 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<PropertyKey>): 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<string, unknown>
// 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<string, unknown> = {}
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 },
)
@@ -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",
+3
View File
@@ -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 {}
+1 -1
View File
@@ -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)' },
+3
View File
@@ -39,6 +39,9 @@ export const V1_ENDPOINT_SCOPES: Record<string, ApiKeyScope> = {
// 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',