Files
accounted/app/api/v1/companies/route.ts
T
f266c386f3 chore: repo-wide bloat sweep, remove dead code and fold duplicate helpers (#2150)
* chore: repo-wide bloat sweep, remove dead code and fold duplicate helpers

Remove 33 dead files, ~270 unreferenced exports/types, 13 dead i18n
namespaces and 4 unused dependencies; fold byte-identical helper copies
into one canonical home each (lib/utils chunk/sleep/utcDateStamp,
lib/dates/iso, lib/invariants/uuid, lib/xml/escape, lib/reports/sru/format,
lib/pdf/number-text, lib/browser/panel-request, lib/api/v1/body +
v1ValidationError rolled out to ~55 v1 routes, booking-template schemas).

No behaviour change: v1 bodies and status codes, MCP tool schemas, DB
writes and money math are untouched. Naive ore rounding was deliberately
not swapped for roundOre; see DECISIONS.md 2026-09-02 for the full list
of things left alone on purpose.

tsc, lint, 19588 unit tests and check:guards green; antipattern baseline
ratcheted (naive-ore-round 622 -> 620, hand-rolled-invariant 115 -> 113).

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

* test(transactions): import RawTransaction from @/types after the ingest re-export removal

CI's type ratchet (check:types, full tsconfig) caught the one test file
that still imported the type through lib/transactions/ingest.

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 11:51:16 +02:00

357 lines
13 KiB
TypeScript

/**
* GET /api/v1/companies: list companies the calling API key can access.
*
* The API key is bound to a user; that user may be a member of multiple
* companies (consultant-style). This endpoint returns every company the user
* has a non-archived membership for, in stable created_at order.
*
* Used by 3rd-party integrations to discover which company IDs to scope
* subsequent calls to.
*/
import { z } from 'zod'
import { paginated, created } from '@/lib/api/v1/response'
import {
encodeDefaultCursor,
parsePaginationParams,
decodeDefaultCursor,
} from '@/lib/api/v1/pagination'
import { registerEndpoint, listEnvelope, dataEnvelope } from '@/lib/api/v1/registry'
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { v1ErrorResponse, v1ErrorResponseFromCode, v1ValidationError } from '@/lib/api/v1/errors'
import { readV1JsonBody } from '@/lib/api/v1/body'
import { dryRunPreview } from '@/lib/api/v1/dry-run'
import { createCompanyCore } from '@/lib/company/create-company'
import { CompanySetupSchema, planCompanySetup } from '@/lib/company/onboarding-input'
const Company = z.object({
id: z.string().uuid(),
name: z.string(),
org_number: z.string().nullable(),
entity_type: z.string(),
role: z.enum(['owner', 'admin', 'member', 'viewer']),
created_at: z.string(),
})
const CompaniesListResponse = listEnvelope(Company)
registerEndpoint({
operation: 'companies.list',
method: 'GET',
path: '/api/v1/companies',
summary: 'List companies the API key can access.',
description:
'Returns every non-archived company the API key user is a member of, together with their role. ' +
'Use the returned `id` as `{companyId}` in subsequent endpoints.',
useWhen:
'You need to discover which company IDs an API key has access to before calling company-scoped endpoints.',
doNotUseFor:
'Fetching a single company you already know the id of: use GET /api/v1/companies/{companyId} for that.',
pitfalls: [
'Multi-company keys (e.g. consultants) will see >1 result. Always pass the correct companyId in subsequent paths.',
'Archived companies are excluded; if a company disappears the user has been removed from it or it was archived.',
],
example: {
response: {
data: [
{
id: '8fd5b1f4-…',
name: 'Acme AB',
org_number: '556677-8899',
entity_type: 'aktiebolag',
role: 'owner',
created_at: '2025-01-04T08:00:00Z',
},
],
meta: { request_id: 'req_…', api_version: '2026-05-12', next_cursor: null },
},
},
scope: 'companies:read',
risk: 'low',
idempotent: true,
reversible: false,
dryRunSupported: false,
response: { success: CompaniesListResponse },
})
const CreatedCompany = z.object({
id: z.string().uuid(),
name: z.string(),
entity_type: z.enum(['enskild_firma', 'aktiebolag']),
org_number: z.string().nullable(),
vat_registered: z.boolean(),
moms_period: z.enum(['monthly', 'quarterly', 'yearly']).nullable(),
accounting_method: z.enum(['accrual', 'cash']),
fiscal_period: z.object({ start_date: z.string(), end_date: z.string(), name: z.string() }),
team_id: z.string().uuid().nullable(),
})
registerEndpoint({
operation: 'companies.create',
method: 'POST',
path: '/api/v1/companies',
summary: 'Create a company and set it up for bookkeeping.',
description:
'Creates a new company owned by the API key user (or attached to one of their teams) and sets it up in one call: ' +
'owner membership, BAS chart of accounts for the company form, compliance settings, the first fiscal period and the ' +
'automatic tax deadlines. A 30-day trial with every paid capability starts immediately. ' +
'Intended for partner platforms provisioning client companies (byrå/vertical SaaS) and for agents onboarding a user.',
useWhen:
'A platform or agent needs to provision a company that does not exist in Accounted yet. The caller becomes its owner; invite the end customer afterwards.',
doNotUseFor:
'Companies that already exist (list them with GET /api/v1/companies), or changing settings on an existing company (PATCH /api/v1/companies/{companyId}/settings).',
pitfalls: [
'A VAT-registered company MUST send moms_period (monthly / quarterly / yearly); the request is refused otherwise, because a missing period silently produces zero VAT deadlines.',
'Bookkeeping duty under BFL starts when the company exists with a fiscal period: do not create companies to try things out. Use a test-mode key (dry run) for that.',
'Enskild firma always runs on the calendar year; fiscal_year_start_month is ignored for it.',
'first_fiscal_year is only for a company in its first year (BFL 3 kap.: up to 18 months). Omit it for an established company.',
'Not idempotent, and Idempotency-Key is not honoured on this company-less route: a retry after a network failure creates a second company. List GET /api/v1/companies before retrying.',
'org_number is required for a VAT-registered company (the invoice momsregistreringsnummer derives from it), and f_skatt must be stated explicitly: F-skatt approval is never assumed.',
'accounting_method may be omitted: it then defaults by form (aktiebolag accrual, enskild firma cash) and the response shows the resolved value. The cash default is only legal when turnover normally stays under 3 MSEK (BFL 4 kap 4 §): send accrual explicitly for a larger enskild firma.',
],
example: {
request: {
name: 'Acme AB',
entity_type: 'aktiebolag',
org_number: '5566778899',
vat_registered: true,
moms_period: 'quarterly',
accounting_method: 'accrual',
f_skatt: true,
},
response: {
data: {
id: '8fd5b1f4-…',
name: 'Acme AB',
entity_type: 'aktiebolag',
org_number: '5566778899',
vat_registered: true,
moms_period: 'quarterly',
accounting_method: 'accrual',
fiscal_period: { start_date: '2026-01-01', end_date: '2026-12-31', name: 'Räkenskapsår 2026' },
team_id: null,
},
meta: { request_id: 'req_…', api_version: '2026-05-12' },
},
},
scope: 'companies:write',
risk: 'medium',
idempotent: false,
reversible: false,
dryRunSupported: true,
request: { body: CompanySetupSchema },
response: { success: dataEnvelope(CreatedCompany), errorCodes: ['VALIDATION_ERROR', 'FORBIDDEN', 'INTERNAL_ERROR'] },
})
export const POST = withApiV1('companies.create', async (request, ctx) => {
const rawBodyResult = await readV1JsonBody(request, ctx)
if (!rawBodyResult.ok) return rawBodyResult.response
const rawBody = rawBodyResult.body
const parsed = CompanySetupSchema.safeParse(rawBody)
if (!parsed.success) return v1ValidationError(ctx, parsed.error)
const setup = parsed.data
const plan = planCompanySetup(setup)
if (!plan.ok) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { field: 'first_fiscal_year', message: plan.error },
})
}
// Team: explicit, else the caller's PERSONAL team only (WL-08: companies
// created through the normal flow always attach to the personal team;
// cockpit flows pass the byrå team id explicitly). Picking the first
// membership regardless of kind attached a consultant's private company to
// the byrå team, exposing their books to the whole byrå and suppressing the
// trial. Mirrors ensure_user_team: earliest teams row with kind='personal'.
// create_company_for_user re-checks membership (and byrå role) in the DB.
let teamId: string | null = setup.team_id ?? null
if (!teamId) {
const { data: membership } = await ctx.supabase
.from('team_members')
.select('team_id, teams!inner(kind, created_at)')
.eq('user_id', ctx.userId)
.eq('teams.kind', 'personal')
.order('teams(created_at)', { ascending: true })
.order('teams(id)', { ascending: true })
.limit(1)
.maybeSingle()
teamId = (membership?.team_id as string | undefined) ?? null
}
const shape = (id: string) => ({
id,
name: setup.name,
entity_type: setup.entity_type,
org_number: (plan.input.settings.org_number as string | null) ?? null,
vat_registered: setup.vat_registered,
moms_period: setup.vat_registered ? setup.moms_period ?? null : null,
accounting_method: plan.resolved.accountingMethod,
fiscal_period: {
start_date: plan.fiscalPeriod.startDate,
end_date: plan.fiscalPeriod.endDate,
name: plan.fiscalPeriod.name,
},
team_id: teamId,
})
if (ctx.dryRun) {
return dryRunPreview(shape('00000000-0000-4000-8000-000000000000'), {
requestId: ctx.requestId,
log: ctx.log,
})
}
const result = await createCompanyCore(ctx.supabase, plan.input, () =>
ctx.supabase.rpc('create_company_for_user', {
p_user_id: ctx.userId,
p_name: setup.name,
p_entity_type: setup.entity_type,
p_team_id: teamId,
}),
)
if (result.error !== undefined) {
if (result.error === 'org_number_invalid') {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { field: 'org_number', message: 'Invalid organisationsnummer.' },
})
}
ctx.log.error('companies.create failed', { reason: result.error })
return v1ErrorResponseFromCode('INTERNAL_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { message: result.error },
})
}
return created(shape(result.companyId), { requestId: ctx.requestId })
})
export const GET = withApiV1('companies.list', async (request, ctx) => {
const url = new URL(request.url)
const { limit, cursor } = parsePaginationParams(url)
const decoded = decodeDefaultCursor(cursor)
// Authorization boundary: the query below filters by `user_id = ctx.userId`
// BEFORE the cursor's keyset is applied, so a tampered cursor can only
// reorder rows the caller is already entitled to see. Cursors are not
// signed; that's intentional. See PR #450 review for the trade-off.
//
// Keyset pagination uses (joined_at ASC, id ASC) on `company_members`. Two
// memberships sharing a `joined_at` (bulk-imported, concurrent registrations)
// are disambiguated by the `company_members.id` tiebreaker so rows on a page
// boundary are never skipped or duplicated.
// We over-fetch by one to determine whether a next page exists.
let query = ctx.supabase
.from('company_members')
.select(
`
id,
role,
joined_at,
companies:company_id (
id,
name,
org_number,
entity_type,
archived_at,
created_at
)
`,
)
.eq('user_id', ctx.userId)
.is('companies.archived_at', null)
.order('joined_at', { ascending: true })
.order('id', { ascending: true })
.limit(limit + 1)
if (decoded) {
// Compound keyset: joined_at > cursor.ts OR (joined_at = cursor.ts AND id > cursor.id).
// `.or()` takes a comma-separated PostgREST filter string.
query = query.or(
`joined_at.gt.${decoded.ts},and(joined_at.eq.${decoded.ts},id.gt.${decoded.id})`,
)
}
const { data, error } = await query
if (error) {
return v1ErrorResponse(error, ctx.log, { requestId: ctx.requestId })
}
type CompanyRow = {
id: string
name: string
org_number: string | null
entity_type: string
archived_at: string | null
created_at: string
}
type Row = {
// `company_members.id`: the membership row's own UUID, used as the
// cursor's secondary key. Not exposed in the response.
id: string
role: 'owner' | 'admin' | 'member' | 'viewer'
joined_at: string
// PostgREST returns the joined company as either an object (one-to-one FK
// resolution) or an array. Accept both: Supabase's auto-typing chooses
// the array shape, but actual responses for a single-row FK are objects.
companies: CompanyRow | CompanyRow[] | null
}
const rows = ((data ?? []) as unknown) as Row[]
const trimmed = rows.slice(0, limit)
const hasMore = rows.length > limit
const pickCompany = (r: Row): CompanyRow | null => {
if (!r.companies) return null
return Array.isArray(r.companies) ? (r.companies[0] ?? null) : r.companies
}
// Defense in depth: PostgREST's `.is('companies.archived_at', null)` is
// expected to filter out archived companies before the row reaches us.
// If a `company_members` row arrives without an associated company object,
// the join filter behaved differently than expected: drop the row AND
// surface it as a warn so we notice silent data-integrity regressions.
let droppedNulls = 0
const companies = trimmed
.map((r) => {
const c = pickCompany(r)
if (!c) {
droppedNulls += 1
return null
}
return {
id: c.id,
name: c.name,
org_number: c.org_number,
entity_type: c.entity_type,
role: r.role,
created_at: c.created_at,
}
})
.filter((x): x is NonNullable<typeof x> => x !== null)
if (droppedNulls > 0) {
ctx.log.warn('companies.list: dropped rows with null company join', { droppedNulls })
}
// Cursor encodes the LAST row's `(joined_at, company_members.id)`: always
// present, no null-guard needed. Independent of whether the joined company
// dropped out of the response shape.
const last = trimmed[trimmed.length - 1]
const nextCursor = hasMore && last
? encodeDefaultCursor({ id: last.id, created_at: last.joined_at })
: null
return paginated(companies, {
requestId: ctx.requestId,
nextCursor: nextCursor ?? undefined,
})
})