* fix: surface active TIC companies + block duplicate org numbers
Three fixes from live-prod testing:
1. Enrichment filter hid the user's directorships. Now accepts both
Completed and PartiallyCompleted status from TIC (tenants without
CompanyRoles enabled still get SPAR) and the /select-company role
filter no longer requires companyStatus === 'Aktivt' — real TIC
payloads have been observed with different values, and positionEnd
alone is the authoritative "currently a director" signal. Added
PII-free diagnostic logs so the next shape-mismatch is debuggable
from Vercel logs without a round trip.
2. Manual wizard silently allowed duplicate org numbers. Added:
- findExistingCompanyByOrgNumber helper in actions.ts (service role,
bypasses RLS to see cross-tenant rows)
- Server-side guard in createCompanyFromOnboarding — returns
'org_number_exists' before the create RPC so we don't leave ghost
companies
- New /api/company/check-org-number endpoint for debounced client
checks
- Warning + disabled submit in Step2CompanyDetails
- Friendly error toasts in WelcomeOnboarding + BankIdCompanyPicker
- Mirror cleaned org_number onto companies.org_number on creation so
future duplicate checks and lookups are reliable
3. /onboarding ignored ?org_number= when the picker routed there as a
fallback. Now reads searchParams and pre-fills settings; also fixed
a latent bug where Step1's entity-type change wiped the pre-fill on
*first* selection (it should only reset on a genuine change).
Tests: duplicate-org guard (with formatted-input normalization),
check-org-number route (auth + 400 + exists true/false +
normalization).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: address PR review feedback on duplicate-org guard
Greptile P1 findings + swedish-compliance feedback:
- findExistingCompanyByOrgNumber now throws on Supabase error instead
of silently returning null. Previously a DB outage or RLS
misconfiguration would bypass the entire duplicate guard and allow
duplicates through.
- createCompanyFromOnboarding catches the throw and returns a
user-facing error ("Kunde inte verifiera organisationsnummer"),
failing closed instead of open.
- companies.update({ org_number }) error is now checked and triggers a
rollback. Silent failure would leave the company without an
org_number, breaking all future duplicate checks for that entity.
- New normalizeOrgNumber helper validates 10- or 12-digit input,
strips the century prefix for 12-digit personnummer form, and
rejects anything else. Malformed input would have corrupted SIE4
(#ORGNR) and SRU (INFO.SRU) exports downstream.
- /select-company now uses loose `== null` for positionEnd — TIC has
been observed returning `undefined` for open-ended positions, which
strict `=== null` would silently filter out. Documented the two
downstream isCeased guards so future maintainers don't remove one
without the other.
- createCompanyFromTicRole refuses to provision when lookup.isCeased
(BFL 2 kap — bokföringsskyldighet ends at avregistrering).
BankIdCompanyPicker surfaces this client-side too.
- WelcomeOnboarding + BankIdCompanyPicker recognise new error codes:
org_number_invalid, company_ceased.
Tests: +4 cases covering malformed input rejection, fail-closed
behaviour on DB error, 12-digit personnummer normalization, and the
ceased-company refusal path. Full suite: 2306 passing.
Out of scope for this PR (follow-up):
- Partial unique index on companies(org_number) WHERE archived_at IS
NULL. Closes the race-condition window but needs a migration plus
any existing-duplicate cleanup — too risky for this hotfix.
- Rate limiting on /api/company/check-org-number. Endpoint is
auth-gated so not an immediate concern.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: add Luhn validation and extract org-number normalization
Third round of PR review feedback (swedish-compliance):
- Add Luhn-10 check-digit validation to normalizeOrgNumber. Rejects
structurally invalid org_numbers (wrong check digit) at the boundary
instead of letting them propagate into SIE4 #ORGNR and SRU INFO.SRU,
where Skatteverket and receiving accounting systems would reject
them later anyway. Reuses the existing luhnValidate helper from
lib/bankgiro/luhn.ts (Bankgirot 10-modulen — same algorithm applies
to both Bolagsverket org numbers and Swedish personnummer).
- Extract normalizeOrgNumber into lib/company-lookup/normalize-org-number.ts
so the server action and /api/company/check-org-number use the same
rule. Previously the API route only stripped hyphens/spaces, so a
12-digit input would miss a stored 10-digit duplicate and mislead the
client debounce check ("not a duplicate" → submit → server rejects).
- /api/company/check-org-number now returns exists=false for
Luhn-invalid input rather than querying the DB. The submit-time
server action surfaces org_number_invalid, which is the right place
for the error.
Test coverage: dedicated normalize-org-number.test.ts (10 cases
covering both-lengths, Luhn, whitespace tolerance, garbage). Updated
existing tests to use Luhn-valid numbers (real Volvo 5560125790,
synthetic personnummer 8001011231). New failing-Luhn test in
actions.test.ts. New 12-digit-normalization and
luhn-invalid-returns-false tests in route.test.ts.
Full suite: 2315 passing.
Not fixed (out of scope for this hotfix):
- 10↔12 digit round-trip fragility for personnummer born 2000+. This
is a codebase-wide architectural choice (see lib/skatteverket/format.ts
which uses a two-digit-year heuristic to choose 19/20 at export).
Migrating to 12-digit storage is a separate refactor.
- Server-side re-fetch of TIC /lookup for isCeased. The trust boundary
here is user-to-their-own-onboarding, not adversarial; doubling TIC
API cost isn't proportionate.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
359 lines
14 KiB
TypeScript
359 lines
14 KiB
TypeScript
'use server'
|
|
|
|
import { createClient, createServiceClient } from '@/lib/supabase/server'
|
|
import { setActiveCompany } from '@/lib/company/context'
|
|
import { revalidatePath } from 'next/cache'
|
|
import { computeFiscalPeriod } from '@/lib/company/compute-fiscal-period'
|
|
import { mapEntityType } from '@/lib/company-lookup/entity-type-map'
|
|
import { normalizeOrgNumber } from '@/lib/company-lookup/normalize-org-number'
|
|
import type { CompanyLookupResult } from '@/lib/company-lookup/types'
|
|
|
|
/**
|
|
* Check whether an org number is already registered in any non-archived
|
|
* gnubok company. Uses the service role because RLS hides rows the caller
|
|
* isn't a member of — and "other users' duplicates" is exactly what we
|
|
* need to detect. Returns null when `orgNumber` is empty/malformed. Throws
|
|
* if the underlying query fails — callers must not silently treat that as
|
|
* "no duplicate," or the whole guard gets bypassed on transient DB errors.
|
|
*/
|
|
async function findExistingCompanyByOrgNumber(
|
|
orgNumber: string | null | undefined,
|
|
): Promise<{ id: string; name: string } | null> {
|
|
const cleaned = normalizeOrgNumber(orgNumber)
|
|
if (!cleaned) return null
|
|
|
|
const service = createServiceClient()
|
|
const { data, error } = await service
|
|
.from('companies')
|
|
.select('id, name')
|
|
.eq('org_number', cleaned)
|
|
.is('archived_at', null)
|
|
.limit(1)
|
|
.maybeSingle()
|
|
|
|
if (error) {
|
|
throw new Error(`Duplicate-org lookup failed: ${error.message}`)
|
|
}
|
|
|
|
return data ?? null
|
|
}
|
|
|
|
export async function switchCompany(companyId: string): Promise<{ error?: string }> {
|
|
const supabase = await createClient()
|
|
const { data: { user } } = await supabase.auth.getUser()
|
|
|
|
if (!user) {
|
|
return { error: 'Unauthorized' }
|
|
}
|
|
|
|
try {
|
|
await setActiveCompany(supabase, user.id, companyId)
|
|
// No revalidatePath — the client performs a hard navigation
|
|
// (window.location.assign) after this action returns, which wipes
|
|
// every React/router/fetch cache wholesale. revalidatePath would be a
|
|
// no-op and would just race with the hard reload.
|
|
return {}
|
|
} catch {
|
|
return { error: 'Du har inte tillgång till detta företag.' }
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create a company from onboarding wizard data.
|
|
*
|
|
* This runs on the server so that if the Next.js server is unavailable when
|
|
* the user clicks the final "Fortsätt" button, the action never reaches
|
|
* Supabase and no ghost company is created. All operations (company,
|
|
* membership, chart of accounts, settings, fiscal period, active company)
|
|
* happen sequentially; if any step after company creation fails the company
|
|
* is rolled back to avoid partial state.
|
|
*/
|
|
export async function createCompanyFromOnboarding(params: {
|
|
teamId: string
|
|
settings: Record<string, unknown>
|
|
fiscalPeriod: {
|
|
startDate: string
|
|
endDate: string
|
|
name: string
|
|
}
|
|
}): Promise<{ companyId?: string; error?: string }> {
|
|
const supabase = await createClient()
|
|
const { data: { user } } = await supabase.auth.getUser()
|
|
|
|
if (!user) {
|
|
return { error: 'Unauthorized' }
|
|
}
|
|
|
|
const entityType = params.settings.entity_type as string | undefined
|
|
if (entityType !== 'enskild_firma' && entityType !== 'aktiebolag') {
|
|
return { error: 'Ogiltig företagsform.' }
|
|
}
|
|
|
|
const companyName = (params.settings.company_name as string | undefined) || 'Mitt företag'
|
|
|
|
// Duplicate-org guard. We don't have a DB unique constraint on
|
|
// companies.org_number (can't add one safely without cleaning up any
|
|
// existing duplicates first), so enforce uniqueness at the application
|
|
// boundary. Must run before the create RPC so we don't leave a ghost
|
|
// company if the duplicate is detected mid-flow.
|
|
//
|
|
// normalizeOrgNumber returns null for malformed input — we refuse rather
|
|
// than storing a value that would break SIE/SRU exports later.
|
|
const rawOrgNumber = params.settings.org_number as string | undefined
|
|
const cleanedOrgNumber = normalizeOrgNumber(rawOrgNumber)
|
|
if (rawOrgNumber && rawOrgNumber.trim() && !cleanedOrgNumber) {
|
|
return { error: 'org_number_invalid' }
|
|
}
|
|
if (cleanedOrgNumber) {
|
|
try {
|
|
const existing = await findExistingCompanyByOrgNumber(cleanedOrgNumber)
|
|
if (existing) {
|
|
return { error: 'org_number_exists' }
|
|
}
|
|
} catch (err) {
|
|
// Guard must fail closed: if we can't confirm uniqueness, don't create
|
|
// a company. A silent pass-through would let transient DB errors
|
|
// through as duplicates (exactly the bug Greptile flagged).
|
|
console.error('[createCompanyFromOnboarding] duplicate-org lookup failed', err)
|
|
return { error: 'Kunde inte verifiera organisationsnummer. Försök igen.' }
|
|
}
|
|
}
|
|
|
|
// 1. Create company + owner membership atomically via RPC
|
|
const { data: newCompanyId, error: companyError } = await supabase.rpc('create_company_with_owner', {
|
|
p_name: companyName,
|
|
p_entity_type: entityType,
|
|
p_team_id: params.teamId,
|
|
})
|
|
|
|
if (companyError || !newCompanyId) {
|
|
console.error('[createCompanyFromOnboarding] company creation failed', companyError)
|
|
return { error: 'Kunde inte skapa företag. Försök igen.' }
|
|
}
|
|
|
|
// Helper: roll back the company if a subsequent step fails. Deletes in FK order.
|
|
const rollback = async (reason: string, err: unknown) => {
|
|
console.error(`[createCompanyFromOnboarding] rolling back ${newCompanyId}: ${reason}`, err)
|
|
await supabase.from('company_settings').delete().eq('company_id', newCompanyId)
|
|
await supabase.from('fiscal_periods').delete().eq('company_id', newCompanyId)
|
|
await supabase.from('chart_of_accounts').delete().eq('company_id', newCompanyId)
|
|
await supabase.from('company_members').delete().eq('company_id', newCompanyId)
|
|
await supabase.from('companies').delete().eq('id', newCompanyId)
|
|
}
|
|
|
|
// Mirror the normalized org_number onto the companies row so future
|
|
// duplicate checks and cross-references are reliable. MUST be error-checked
|
|
// and rolled back on failure — otherwise the freshly-created company would
|
|
// exist without an org_number and the duplicate guard would never match it
|
|
// for any future user (the very guard this code is enforcing).
|
|
if (cleanedOrgNumber) {
|
|
const { error: orgUpdateError } = await supabase
|
|
.from('companies')
|
|
.update({ org_number: cleanedOrgNumber })
|
|
.eq('id', newCompanyId)
|
|
if (orgUpdateError) {
|
|
await rollback('org_number update failed', orgUpdateError)
|
|
return { error: 'Kunde inte spara organisationsnummer. Försök igen.' }
|
|
}
|
|
}
|
|
|
|
// 2. Seed chart of accounts
|
|
const { error: coaError } = await supabase.rpc('seed_chart_of_accounts', {
|
|
p_company_id: newCompanyId,
|
|
p_entity_type: entityType,
|
|
})
|
|
if (coaError) {
|
|
await rollback('COA seeding failed', coaError)
|
|
return { error: 'Kunde inte skapa kontoplan. Försök igen.' }
|
|
}
|
|
|
|
// 3. Save settings (strip UI-only and managed fields)
|
|
const {
|
|
id: _id,
|
|
user_id: _uid,
|
|
company_id: _cid,
|
|
created_at: _ca,
|
|
updated_at: _ua,
|
|
is_first_fiscal_year: _ify,
|
|
first_year_start: _fys,
|
|
first_year_end: _fye,
|
|
...settingsToSave
|
|
} = params.settings
|
|
|
|
const { error: settingsError } = await supabase
|
|
.from('company_settings')
|
|
.upsert(
|
|
{
|
|
...settingsToSave,
|
|
company_id: newCompanyId,
|
|
onboarding_complete: true,
|
|
onboarding_step: 4,
|
|
},
|
|
{ onConflict: 'company_id' },
|
|
)
|
|
|
|
if (settingsError) {
|
|
await rollback('settings upsert failed', settingsError)
|
|
return { error: 'Kunde inte spara inställningar. Försök igen.' }
|
|
}
|
|
|
|
// 4. Create fiscal period
|
|
const { error: periodError } = await supabase.from('fiscal_periods').upsert(
|
|
{
|
|
company_id: newCompanyId,
|
|
name: params.fiscalPeriod.name,
|
|
period_start: params.fiscalPeriod.startDate,
|
|
period_end: params.fiscalPeriod.endDate,
|
|
},
|
|
{ onConflict: 'company_id,period_start,period_end' },
|
|
)
|
|
|
|
if (periodError) {
|
|
await rollback('fiscal period upsert failed', periodError)
|
|
return { error: 'Kunde inte skapa räkenskapsår. Försök igen.' }
|
|
}
|
|
|
|
// 5. Set as active company
|
|
try {
|
|
await setActiveCompany(supabase, user.id, newCompanyId)
|
|
} catch (err) {
|
|
// Non-fatal: the company was created successfully; the user can switch manually
|
|
console.error('[createCompanyFromOnboarding] setActiveCompany failed', err)
|
|
}
|
|
|
|
revalidatePath('/')
|
|
return { companyId: newCompanyId }
|
|
}
|
|
|
|
/**
|
|
* One-click company setup from a TIC/Bolagsverket company role.
|
|
*
|
|
* The picker page at /select-company passes a `CompanyLookupResult` already
|
|
* fetched from `/api/extensions/ext/tic/lookup`, plus the `EnrichmentCompanyRole`
|
|
* minimums (org number, legal name, legal entity type). This action derives
|
|
* sensible defaults (accrual, quarterly moms for VAT-registered, Jan-Dec
|
|
* fiscal year), reads SPAR address from `extension_data` as a fallback, and
|
|
* then delegates to `createCompanyFromOnboarding` so the provisioning path is
|
|
* identical to the manual wizard. On success it clears the enrichment row
|
|
* consumed by this path — the manual wizard leaves it intact so a returning
|
|
* BankID user can still reach `/select-company` and pick another directorship.
|
|
*
|
|
* Requires `lookup` to be non-null: if TIC `/lookup` is unreachable, the client
|
|
* must route to the manual wizard instead. Silently defaulting `vat_registered`
|
|
* to false for a momsregistrerat bolag would violate ML 17 kap (invoices
|
|
* without moms), so we refuse to guess.
|
|
*/
|
|
export async function createCompanyFromTicRole(params: {
|
|
teamId: string
|
|
orgNumber: string
|
|
legalName: string
|
|
legalEntityType: string
|
|
lookup: CompanyLookupResult | null
|
|
}): Promise<{ companyId?: string; error?: string }> {
|
|
const supabase = await createClient()
|
|
const { data: { user } } = await supabase.auth.getUser()
|
|
|
|
if (!user) {
|
|
return { error: 'Unauthorized' }
|
|
}
|
|
|
|
const entityType = mapEntityType(params.legalEntityType)
|
|
if (!entityType) {
|
|
return { error: 'Den här företagsformen måste sättas upp manuellt.' }
|
|
}
|
|
|
|
// If the TIC lookup failed we don't know the company's VAT/F-skatt status.
|
|
// Refuse to silently guess — the caller routes to the manual wizard so the
|
|
// user can confirm these fields themselves.
|
|
if (!params.lookup) {
|
|
return { error: 'lookup_missing' }
|
|
}
|
|
|
|
// Ceased/struck-off companies must not be provisioned. Under BFL 2 kap,
|
|
// bokföringsskyldighet ends when a company is avregistrerad; creating a
|
|
// new gnubok accounting entity for a non-existent legal entity would let
|
|
// users file momsdeklarationer or årsredovisning for it.
|
|
if (params.lookup.isCeased) {
|
|
return { error: 'company_ceased' }
|
|
}
|
|
|
|
// SPAR address fallback if the TIC lookup didn't include one.
|
|
const { data: enrichmentRow } = await supabase
|
|
.from('extension_data')
|
|
.select('id, value')
|
|
.eq('user_id', user.id)
|
|
.eq('extension_id', 'tic')
|
|
.eq('key', 'bankid_enrichment')
|
|
.maybeSingle()
|
|
|
|
const spar = (enrichmentRow?.value as { spar?: Record<string, string | undefined> } | null)?.spar
|
|
const sparStreet = spar?.Folkbokforingsadress_SvenskAdress_Utdelningsadress1
|
|
const sparPostal = spar?.Folkbokforingsadress_SvenskAdress_PostNr
|
|
const sparCity = spar?.Folkbokforingsadress_SvenskAdress_Postort
|
|
|
|
const addressStreet = params.lookup.address?.street ?? sparStreet ?? null
|
|
const addressPostal = params.lookup.address?.postalCode ?? sparPostal ?? null
|
|
const addressCity = params.lookup.address?.city ?? sparCity ?? null
|
|
|
|
const fTax = params.lookup.registration.fTax
|
|
const vatRegistered = params.lookup.registration.vat
|
|
|
|
// moms_period: Skatteverket assigns the actual reporting period from
|
|
// annual beskattningsunderlag (≤1 MSEK → yearly, ≤40 MSEK → quarterly,
|
|
// >40 MSEK → monthly). TIC /lookup doesn't expose turnover, so we pick the
|
|
// middle-tier default. The user must verify it matches their Skatteverket
|
|
// assignment in /settings/tax — a mismatch causes late-filing penalties
|
|
// under SFL.
|
|
const momsPeriod = vatRegistered ? 'quarterly' : null
|
|
|
|
// EF ≤3 MSEK may use kontantmetoden under K1/BFNAR 2013:2; above that
|
|
// threshold, BFNAR 2017:3 requires bokföringsmässiga grunder. We default
|
|
// to cash because the vast majority of EF users are small; users above
|
|
// the threshold can switch in /settings/bookkeeping. Aktiebolag must use
|
|
// accrual under K2/K3.
|
|
const accountingMethod = entityType === 'enskild_firma' ? 'cash' : 'accrual'
|
|
|
|
const settings: Record<string, unknown> = {
|
|
entity_type: entityType,
|
|
company_name: params.legalName,
|
|
org_number: params.orgNumber.replace(/[\s-]/g, ''),
|
|
f_skatt: fTax,
|
|
vat_registered: vatRegistered,
|
|
moms_period: momsPeriod,
|
|
accounting_method: accountingMethod,
|
|
fiscal_year_start_month: 1,
|
|
address_line1: addressStreet,
|
|
postal_code: addressPostal,
|
|
city: addressCity,
|
|
}
|
|
|
|
const periodResult = computeFiscalPeriod(settings)
|
|
if (periodResult.error) {
|
|
return { error: 'Kunde inte beräkna räkenskapsår.' }
|
|
}
|
|
|
|
const result = await createCompanyFromOnboarding({
|
|
teamId: params.teamId,
|
|
settings,
|
|
fiscalPeriod: {
|
|
startDate: periodResult.startStr,
|
|
endDate: periodResult.endStr,
|
|
name: periodResult.periodName,
|
|
},
|
|
})
|
|
|
|
if (result.error || !result.companyId) {
|
|
return { error: result.error ?? 'Kunde inte skapa företag. Försök igen.' }
|
|
}
|
|
|
|
// One-time use: drop the enrichment row now that the user has committed to
|
|
// a TIC-suggested company. The manual wizard intentionally does NOT do this
|
|
// so a user with multiple directorships can still reach /select-company
|
|
// afterwards and provision another one.
|
|
if (enrichmentRow?.id) {
|
|
await supabase.from('extension_data').delete().eq('id', enrichmentRow.id)
|
|
}
|
|
|
|
return { companyId: result.companyId }
|
|
}
|