* feat(reconciliation): close the bank-feed loop on voucher links and re-tag mis-typed opening balances
Two related fixes to bank reconciliation correctness:
1. Auto-reconcile on voucher link. Linking an invoice or supplier invoice to
an existing voucher previously advanced only the invoice — the bank
transaction that paid it kept sitting in the Transactions inbox with a null
journal_entry_id. linkInvoiceToVoucher / linkSupplierInvoiceToVoucher now
call autoReconcileTransactionForLinkedVoucher (lib/reconciliation), which
links the bank transaction to the same verifikat when exactly one unbooked
line matches it. Best-effort and post-commit: a failure here never fails the
link. The result surfaces reconciledTransactionId; the inbox row leaves the
list and the UI shows link_success_tx_reconciled.
2. Re-tag mis-typed opening balances. getReconciliationStatus and the GL-line
matching RPCs identify a cash account's ingående balans solely by
journal_entries.source_type='opening_balance'. Companies migrated from other
systems often booked the bank IB as an ordinary voucher (source_type
'import' or 'manual'), so it was never excluded and surfaced as a phantom
reconciliation difference equal to the opening balance. Adds:
- migration mark_entry_as_opening_balance: a GUC-gated carve-out in the
immutability trigger plus a SECURITY DEFINER RPC that validates the entry
(balance-sheet lines only, dated on a fiscal-period boundary), flips the
source_type, and writes an audit row — no blanket data sweep.
- POST /api/reconciliation/bank/mark-opening-balance + MarkOpeningBalanceSchema.
- BankReconciliationView action to trigger it from the IB diff.
The gnubok_create_voucher executor now accepts a typed is_opening_balance flag
and derives source_type='opening_balance' only after validating class 1/2 lines
on the period start, so new IBs land correctly typed.
Covered by lib/reconciliation auto-reconcile tests, voucher-executors tests,
and a mark-entry-as-opening-balance pg-real test.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* chore: rebrand gnubok → Accounted and prune swarm agent skills
Product rebrand and skills housekeeping. No runtime behaviour change.
Rebrand: replace user-visible "gnubok" with "Accounted" across docs, READMEs,
in-code comments, doc-site content, MCP skill/resource prose, and the
gnubok-mcp package description. The MCP resource URI scheme is moved gnubok://
→ Accounted:// consistently across resource registrations, the event-type
comment, and the resource/skill tests. Deliberately preserved as stable
identifiers (NOT rebranded): the gnubok-company-id cookie, gnubok_sk_ / gnubok_inv_
token prefixes, the gnubok-mcp npm bridge name, and the AGI <gem:Programnamn>
value (kept 'gnubok' per its source comment — it is the software identifier sent
to Skatteverket and must not churn across visual rebrands).
Skills: remove the 27 swarm-* agent SKILL.md atoms (no longer used; already
absent from the agent_atom_registry in prod), refresh the remaining skill docs,
add the .claude/rules/ path-scoped rule set, and regenerate the
seed_agent_atom_bodies migration + .skill-body-manifest.json via
`npm run skills:generate` so the DB-backed skill bodies match the trimmed set.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
271 lines
10 KiB
TypeScript
271 lines
10 KiB
TypeScript
'use server'
|
|
|
|
import { createClient, createServiceClient } from '@/lib/supabase/server'
|
|
import { setActiveCompany } from '@/lib/company/context'
|
|
import { revalidatePath } from 'next/cache'
|
|
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
|
|
* Accounted 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
|
|
}
|
|
// Optional TIC lookup result captured during the onboarding form. When
|
|
// supplied, persisted to companies.tic_snapshot so downstream features
|
|
// (specialized accountant agent composer, MCP briefing) can read the same
|
|
// Bolagsverket-sourced data the form used. Empty for manual entry paths.
|
|
ticLookup?: CompanyLookupResult | null
|
|
}): Promise<{ companyId?: string; error?: string }> {
|
|
try {
|
|
return await createCompanyFromOnboardingImpl(params)
|
|
} catch (err) {
|
|
// Defensive top-level catch: a thrown error escapes to the client as
|
|
// an opaque Next.js server-action exception with no message in dev
|
|
// and a redacted message in prod. Logging the full error here gives
|
|
// us a server-side trace and returns a localized fallback to the UI.
|
|
console.error('[createCompanyFromOnboarding] unexpected error', err)
|
|
const message = err instanceof Error ? err.message : String(err)
|
|
return { error: message || 'Något gick fel när företaget skulle skapas. Försök igen.' }
|
|
}
|
|
}
|
|
|
|
async function createCompanyFromOnboardingImpl(params: {
|
|
teamId: string
|
|
settings: Record<string, unknown>
|
|
fiscalPeriod: { startDate: string; endDate: string; name: string }
|
|
ticLookup?: 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 = 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.' }
|
|
}
|
|
}
|
|
|
|
// Persist whatever lookup data the wizard already gathered. Do NOT call
|
|
// /profile here — that handler fans out to 13 Lens calls and the 5 s
|
|
// timeout in tic-fetch.ts ate ~530 wasted calls in May before yielding
|
|
// zero snapshots (every signup's /profile timed out, but the in-flight
|
|
// upstream fetches still counted against quota). The agent build path
|
|
// (app/(onboarding)/onboarding/agent/page.tsx) calls ensureTicSnapshot
|
|
// with upgradeV1: true lazily, which is the right place: only companies
|
|
// that actually reach agent onboarding spend the budget.
|
|
if (params.ticLookup) {
|
|
const { error: ticErr } = await supabase
|
|
.from('companies')
|
|
.update({
|
|
tic_snapshot: params.ticLookup,
|
|
tic_snapshot_fetched_at: new Date().toISOString(),
|
|
})
|
|
.eq('id', newCompanyId)
|
|
if (ticErr) {
|
|
console.warn('[createCompanyFromOnboarding] tic snapshot persist failed', ticErr)
|
|
}
|
|
}
|
|
|
|
// 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 }
|
|
}
|
|
|