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:
co-authored by
Claude Fable 5
Jakob Wennberg
parent
9f5a43310b
commit
86c6af6976
@@ -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",
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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)' },
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user