Files
accounted/lib/company/__tests__/actions.test.ts
T
Jakob WennbergandClaude Opus 4.7 f3a3d07ed3 feat: one-click company setup from BankID directorships (#309)
* feat: one-click company setup from BankID directorships

After BankID auth, surface Bolagsverket companies where the user is a
director and provision a fully-configured gnubok company with one click
instead of walking the 4-step wizard. Also exposed via CompanySwitcher's
"Lägg till företag" for returning users.

- New /select-company route merges gnubok memberships with TIC
  CompanyRoles; cards flag already-registered org numbers.
- createCompanyFromTicRole server action derives entity_type, f-skatt,
  VAT, moms_period, and SPAR address defaults, then delegates to
  createCompanyFromOnboarding for consistent provisioning.
- TIC /bankid/complete now requests enrichment on login too, so
  returning users see fresh CompanyRoles in the picker.
- Middleware routes zero-membership users to /select-company when
  enrichment is available, /onboarding otherwise.
- Inline enrichment picker removed from WelcomeOnboarding (wizard is
  now the manual fallback); SPAR address pre-fill preserved.
- Unit tests for mapEntityType helper and createCompanyFromTicRole
  defaults (VAT-AB, non-VAT EF, unmappable, unauth).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: address PR review feedback on BankID company picker

Greptile P1 + swedish-compliance bot findings:

- Move enrichment row cleanup out of createCompanyFromOnboarding and
  into createCompanyFromTicRole. The manual wizard also goes through
  createCompanyFromOnboarding, and was wiping the enrichment row before
  the returning-user "Lägg till företag" flow could use it.
- Refuse to provision when TIC /lookup is missing. Silently defaulting
  vat_registered to false for a momsregistrerat bolag would create a
  company that issues invoices without moms (ML 17 kap violation). The
  picker now routes to the manual wizard with org_number pre-filled
  when the lookup fails, so the user confirms VAT/F-skatt manually.
- Default accounting_method by entity type: enskild firma → cash
  (K1/kontantmetoden per BFNAR 2013:2), aktiebolag → accrual (K2/K3).
- Document that moms_period='quarterly' is a provisional middle-tier
  default; Skatteverket's assigned period depends on turnover and the
  user can correct it in /settings/tax.
- Fix the misleading "re-fetch from BankID" comment — /select-company
  only reads the cached enrichment row; it's refreshed only on the next
  BankID auth.
- Extend test coverage: lookup-missing refusal, EF kontantmetoden default.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: tighten entity-type mapping and clarify K1 threshold

Second round of PR review fixes (swedish-compliance bot):

- mapEntityType now uses explicit allow-lists instead of substring
  matches. "Enskild stiftelse" / "Enskild näringsverksamhet utan firma"
  no longer false-match as enskild_firma (would have provisioned with
  K1/kontantmetoden — ML/BFL risk). Regression guard test added.
- Publikt aktiebolag explicitly included (same K2/K3 regime as private
  AB); Bankaktiebolag / Försäkringsaktiebolag excluded (FFFS regime).
- Remove misleading claim that onboarding UI flags moms_period as
  provisional — no such UI exists by design (approved one-click UX).
- Expand accounting_method comment to cite the 3 MSEK K1→K3 threshold
  (BFNAR 2013:2 vs 2017:3) so the EF→cash default is honest about its
  scope.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 13:21:55 +02:00

236 lines
7.9 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest'
vi.mock('next/cache', () => ({
revalidatePath: vi.fn(),
}))
vi.mock('@/lib/supabase/server', () => ({
createClient: vi.fn(),
}))
vi.mock('@/lib/company/context', () => ({
setActiveCompany: vi.fn().mockResolvedValue(undefined),
}))
import { createClient } from '@/lib/supabase/server'
import { createCompanyFromTicRole } from '../actions'
import type { CompanyLookupResult } from '@/lib/company-lookup/types'
const mockCreateClient = vi.mocked(createClient)
type CapturedCall = { table: string; method: string; args: unknown[] }
/**
* Builds a chainable Supabase mock that records every method call, allows
* per-table result seeding, and returns a capture log the test can assert on.
*
* - `results[table][method]` (optional) is returned when the chain ends on
* that method. Chains otherwise resolve to `{ data: null, error: null }`.
* - Unknown methods on the chain no-op and return the chain so callers can
* keep chaining freely.
*/
function buildSupabase(opts: {
user: { id: string } | null
results?: Record<string, Record<string, { data?: unknown; error?: unknown }>>
rpcResults?: Record<string, { data?: unknown; error?: unknown }>
}) {
const calls: CapturedCall[] = []
const { user, results = {}, rpcResults = {} } = opts
function makeChain(table: string) {
const record = (method: string, args: unknown[]) => {
calls.push({ table, method, args })
}
const chain: Record<string, unknown> = {}
const methods = ['select', 'eq', 'is', 'in', 'order', 'limit', 'maybeSingle', 'single', 'insert', 'upsert', 'delete', 'update']
for (const m of methods) {
chain[m] = (...args: unknown[]) => {
record(m, args)
const canTerminate = results[table]?.[m]
if (canTerminate) {
return Promise.resolve({
data: canTerminate.data ?? null,
error: canTerminate.error ?? null,
})
}
return chain
}
}
chain.then = (resolve: (v: unknown) => void) => resolve({ data: null, error: null })
return chain
}
const supabase = {
auth: {
getUser: vi.fn().mockResolvedValue({ data: { user } }),
},
from: vi.fn().mockImplementation((table: string) => makeChain(table)),
rpc: vi.fn().mockImplementation((name: string) => {
const result = rpcResults[name]
if (result) {
return Promise.resolve({ data: result.data ?? null, error: result.error ?? null })
}
return Promise.resolve({ data: null, error: null })
}),
}
return { supabase, calls }
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('createCompanyFromTicRole', () => {
it('returns Unauthorized when no user session', async () => {
const { supabase } = buildSupabase({ user: null })
mockCreateClient.mockResolvedValue(supabase as never)
const result = await createCompanyFromTicRole({
teamId: 'team-1',
orgNumber: '5566778899',
legalName: 'Acme AB',
legalEntityType: 'AB',
lookup: null,
})
expect(result.error).toBe('Unauthorized')
})
it('rejects unmappable entity types before any DB work', async () => {
const { supabase, calls } = buildSupabase({ user: { id: 'user-1' } })
mockCreateClient.mockResolvedValue(supabase as never)
const result = await createCompanyFromTicRole({
teamId: 'team-1',
orgNumber: '969696-1212',
legalName: 'Beta HB',
legalEntityType: 'Handelsbolag',
lookup: null,
})
expect(result.error).toMatch(/manuellt/i)
// Entity-type rejection should short-circuit — no table writes.
const writes = calls.filter((c) => ['insert', 'upsert', 'delete', 'update'].includes(c.method))
expect(writes).toEqual([])
})
it('refuses to guess when TIC lookup is missing (prevents silent ML 17 kap violation)', async () => {
const { supabase, calls } = buildSupabase({ user: { id: 'user-1' } })
mockCreateClient.mockResolvedValue(supabase as never)
const result = await createCompanyFromTicRole({
teamId: 'team-1',
orgNumber: '5566778899',
legalName: 'Acme AB',
legalEntityType: 'AB',
lookup: null,
})
expect(result.error).toBe('lookup_missing')
// Must not have provisioned anything with a guessed VAT status.
const writes = calls.filter((c) => ['insert', 'upsert', 'delete', 'update'].includes(c.method))
expect(writes).toEqual([])
})
it('provisions with sensible defaults for a VAT-registered aktiebolag', async () => {
const lookup: CompanyLookupResult = {
companyName: 'Acme Konsult AB',
isCeased: false,
address: { street: 'Storgatan 1', postalCode: '11122', city: 'Stockholm' },
registration: { fTax: true, vat: true },
bankAccounts: [],
email: null,
phone: null,
sniCodes: [],
}
const { supabase, calls } = buildSupabase({
user: { id: 'user-1' },
results: {
// Seed an enrichment row so the cleanup branch runs and the test
// can verify it fires.
extension_data: {
maybeSingle: { data: { id: 'enrichment-1', value: {} } },
},
},
rpcResults: {
create_company_with_owner: { data: 'new-company-id' },
seed_chart_of_accounts: { data: null },
},
})
mockCreateClient.mockResolvedValue(supabase as never)
const result = await createCompanyFromTicRole({
teamId: 'team-1',
orgNumber: '5566778899',
legalName: 'Acme Konsult AB',
legalEntityType: 'AB',
lookup,
})
expect(result.companyId).toBe('new-company-id')
expect(result.error).toBeUndefined()
// The settings upsert on company_settings should reflect our derived defaults.
const settingsUpsert = calls.find((c) => c.table === 'company_settings' && c.method === 'upsert')
expect(settingsUpsert).toBeDefined()
const settings = (settingsUpsert!.args[0] as Record<string, unknown>)
expect(settings.entity_type).toBe('aktiebolag')
expect(settings.company_name).toBe('Acme Konsult AB')
expect(settings.org_number).toBe('5566778899')
expect(settings.f_skatt).toBe(true)
expect(settings.vat_registered).toBe(true)
expect(settings.moms_period).toBe('quarterly')
expect(settings.accounting_method).toBe('accrual')
expect(settings.address_line1).toBe('Storgatan 1')
expect(settings.postal_code).toBe('11122')
expect(settings.city).toBe('Stockholm')
// The enrichment row must be cleaned up by the one-click path so the
// picker doesn't re-offer this company on a return visit.
const enrichmentDelete = calls.find(
(c) => c.table === 'extension_data' && c.method === 'delete',
)
expect(enrichmentDelete).toBeDefined()
})
it('defaults enskild firma to kontantmetoden (K1), leaves moms_period null when non-VAT', async () => {
const lookup: CompanyLookupResult = {
companyName: 'Liten EF',
isCeased: false,
address: null,
registration: { fTax: true, vat: false },
bankAccounts: [],
email: null,
phone: null,
sniCodes: [],
}
const { supabase, calls } = buildSupabase({
user: { id: 'user-1' },
rpcResults: {
create_company_with_owner: { data: 'new-company-id' },
seed_chart_of_accounts: { data: null },
},
})
mockCreateClient.mockResolvedValue(supabase as never)
await createCompanyFromTicRole({
teamId: 'team-1',
orgNumber: '8001011234',
legalName: 'Liten EF',
legalEntityType: 'Enskild firma',
lookup,
})
const settingsUpsert = calls.find((c) => c.table === 'company_settings' && c.method === 'upsert')
const settings = settingsUpsert!.args[0] as Record<string, unknown>
expect(settings.entity_type).toBe('enskild_firma')
expect(settings.vat_registered).toBe(false)
expect(settings.moms_period).toBeNull()
// EF entities default to cash per K1/BFNAR 2013:2; AB must use accrual (K2/K3).
expect(settings.accounting_method).toBe('cash')
})
})