Files
accounted/lib/company/actions.ts
T
Mattsson fa7d4075cf Supp/invoice bfl errors (#390)
* feat(accounting): update accounting method validation and messaging for aktiebolag and enskild firma

* Remove AI subsystem and related code

- Deleted AI proposals and requests persistence logic from `lib/ai/proposals/persist.ts`.
- Removed re-validation logic for proposals in `lib/ai/proposals/re-validate.ts`.
- Cleaned up schemas related to AI flows in `lib/api/schemas.ts`.
- Removed AI-related fields from bookkeeping engine in `lib/bookkeeping/engine.ts`.
- Eliminated AI event types from `lib/events/types.ts`.
- Updated tests to reflect the removal of AI-related functionality in `lib/extensions/__tests__/sectors.test.ts`.
- Adjusted initialization logic in `lib/init.ts` to exclude AI proposal handler registration.
- Cleaned up transaction ingestion logic in `lib/transactions/ingest.ts` to remove AI flow checks.
- Updated helper functions in `tests/helpers.ts` to remove AI-related settings.
- Removed AI-related types and interfaces from `types/index.ts`.
- Added migration script to drop AI-related tables and settings from the database.

* fix(migrations): ensure foreign key constraint is dropped before removing AI tables

* feat(invoice-inbox): implement deterministic invoice field extraction and inbox provisioning

- Added `extract-invoice-fields.ts` for extracting fields from PDF invoices using regex and pdfjs-dist, replacing the previous AI classifier.
- Introduced `inbox-provisioning.ts` to manage company inbox addresses and rotation of inboxes using Supabase RPCs.
- Created `resend-inbound.ts` for handling inbound email events and attachments via the Resend API.
- Defined the extension manifest for the invoice inbox, specifying required environment variables and descriptions.
- Migrated database schema to remove AI-related columns and tighten the status enum in `invoice_inbox_items`.

* feat(invoice-inbox): remove AI-specific columns and tighten status enum

* fix(skattekonto): remove manual entry creation reference from transaction input

* fix(schemas): remove accounting method validation for aktiebolag in UpdateSettingsSchema
2026-05-05 09:53:37 +02:00

354 lines
13 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) and 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' }
}
// Look up the enrichment row so we can delete it after successful
// provisioning (one-time use). We only need `id` here; the picker has
// already used the `companyRoles` field server-side to render the cards.
const { data: enrichmentRow } = await supabase
.from('extension_data')
.select('id')
.eq('user_id', user.id)
.eq('extension_id', 'tic')
.eq('key', 'bankid_enrichment')
.maybeSingle()
const addressStreet = params.lookup.address?.street ?? null
const addressPostal = params.lookup.address?.postalCode ?? null
const addressCity = params.lookup.address?.city ?? 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
// Default by entity_type: EF → kontantmetoden, AB → faktureringsmetoden.
// Both forms may use either method under BFL 5 kap. 2 § when annual net
// turnover is normally ≤ 3 MSEK; users can change in /settings/bookkeeping.
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 }
}