* fix(entitlements): gate remaining paid-feature UI and drop stale beta pricing copy Second post-cutover sweep. Non-payers still hit live controls that 403 at the server gate, plus dead plan names in the inbox guide: - document-inbox guide: remove "Gratis under beta för Open-användare / Pro-planen / Se priser" (plans that no longer exist); inbox stays free, AI extraction is pitched as part of the subscription - payslip send button (RunProgressBar): disabled + tooltip without email_send; the free PDF download next to it is untouched - skattekonto "Synkronisera nu": disabled + tooltip without skatteverket - invoice post-create send-now dialog: skipped without email_send (the invoice page's SendInvoiceDialog carries the upsell) - recurring-invoice auto_send checkbox: disabled + UpgradeNote without email_send, and force-unchecked so edit mode can't PATCH it back on - AgentChat composer: replaced with UpgradeNote without ai, covering deep links to /chat/* and conversations opened before expiry - /onboarding/agent server page: hasCapability(ai) redirect to /settings/billing before the gated composer stream starts Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(entitlements): address review findings on the gating sweep - AgentChat: the mount effect auto-fired the first /api/agent/invoke on a fresh start regardless of capability; now returns early without ai, and Regenerate/Correction re-invokes are guarded too (real find by review bot) - recurring auto_send upsell copy moved to i18n (auto_send_requires_subscription, sv+en) matching the rest of the dialog - disabled-button tooltips wrapped in a span (payslip send + skattekonto sync): browsers suppress title on disabled elements Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
276 lines
10 KiB
TypeScript
276 lines
10 KiB
TypeScript
import { createClient } from '@/lib/supabase/server'
|
|
import { redirect } from 'next/navigation'
|
|
import { headers } from 'next/headers'
|
|
import { getActiveCompanyId } from '@/lib/company/context'
|
|
import { hasCapability } from '@/lib/entitlements/has-capability'
|
|
import { CAPABILITY } from '@/lib/entitlements/keys'
|
|
import { ensureTicSnapshot } from '@/lib/agent/composer/tic-fetch'
|
|
import AgentOnboarding from '@/components/onboarding/agent/AgentOnboarding'
|
|
|
|
export const dynamic = 'force-dynamic'
|
|
|
|
// /onboarding/agent: Phase A (real-timed build) and Phase B (review) of the
|
|
// specialized accountant agent build sequence.
|
|
//
|
|
// Plan refs: dev_docs/specialized-agent-plan.md §7 (Build-sequence UX),
|
|
// §15 Phase 2 (Build-sequence UX).
|
|
export default async function AgentOnboardingPage() {
|
|
const supabase = await createClient()
|
|
|
|
const { data: { user } } = await supabase.auth.getUser()
|
|
if (!user) redirect('/login')
|
|
|
|
const companyId = await getActiveCompanyId(supabase, user.id)
|
|
if (!companyId) redirect('/onboarding')
|
|
|
|
// Paywall: the build flow drives the gated composer stream (ai capability).
|
|
// Send non-payers to billing where the assistant is pitched, instead of a
|
|
// build sequence that 403s mid-stream.
|
|
if (!(await hasCapability(supabase, companyId, CAPABILITY.ai))) {
|
|
redirect('/settings/billing')
|
|
}
|
|
|
|
// Everything that doesn't depend on the TIC snapshot loads in one batch:
|
|
// the settings row (carrying the is_sandbox gate + the onboarding-form
|
|
// data, moms_period, fiscal_year_start_month, f_skatt, city, …, that
|
|
// never makes it onto `companies` proper), the greeting profile, any
|
|
// existing agent profile, and the atom registry titles ("Konsult It"-style
|
|
// slug labels look ugly; the registry has them as authored).
|
|
const [
|
|
{ data: settings },
|
|
{ data: profile },
|
|
{ data: existingProfile },
|
|
{ data: atomRows },
|
|
hdrs,
|
|
] = await Promise.all([
|
|
supabase
|
|
.from('company_settings')
|
|
.select(
|
|
'is_sandbox, city, address_line1, postal_code, f_skatt, vat_registered, moms_period, fiscal_year_start_month, employee_count, has_employees',
|
|
)
|
|
.eq('company_id', companyId)
|
|
.maybeSingle(),
|
|
supabase.from('profiles').select('full_name').eq('id', user.id).single(),
|
|
supabase
|
|
.from('agent_profiles')
|
|
.select('company_id, profile_summary, verified_at')
|
|
.eq('company_id', companyId)
|
|
.maybeSingle(),
|
|
supabase
|
|
.from('agent_atom_registry')
|
|
.select('id, title')
|
|
.eq('is_active', true)
|
|
.is('parent_atom_id', null), // skill titles only; reference children never appear as profile chips
|
|
headers(),
|
|
])
|
|
|
|
// Sandbox companies ship with a pre-built verified agent_profile: the
|
|
// build flow on this page would call TIC and the gated composer stream,
|
|
// both of which 403. Send them back to the dashboard where the demo
|
|
// assistant is already visible via the sheet preview.
|
|
if (settings?.is_sandbox) redirect('/')
|
|
|
|
// Trigger the TIC live-fetch + cache before the field-resolving query
|
|
// below. ensureTicSnapshot is fast on cache-hit (single SELECT) and
|
|
// best-effort on miss: it never throws. Phase A still runs through the
|
|
// streaming endpoint; this just lets the initial Phase B render show the
|
|
// SNI/verksamhetsbeskrivning when the user returns to the page after
|
|
// stream completion.
|
|
const cookieHeader = hdrs.get('cookie') ?? ''
|
|
const host = hdrs.get('host') ?? 'localhost:3000'
|
|
const proto = hdrs.get('x-forwarded-proto') ?? (host.startsWith('localhost') ? 'http' : 'https')
|
|
const origin = `${proto}://${host}`
|
|
// upgradeV1: this is the one place the v2-only sections (statuses,
|
|
// beneficialOwners, payrolls, …) materially drive the composer, and it's
|
|
// a deliberate once-per-company action: safe to spend the TIC calls to
|
|
// bring a pre-v2 snapshot up to date.
|
|
await ensureTicSnapshot({ supabase, companyId, cookieHeader, origin, upgradeV1: true })
|
|
|
|
// Fetch the small handful of fields we render directly into Phase B so the
|
|
// user sees real values (not "Laddar…") the moment the stream finishes.
|
|
// Must run AFTER ensureTicSnapshot, it reads the tic_snapshot that call
|
|
// may have just written.
|
|
const { data: company } = await supabase
|
|
.from('companies')
|
|
.select('name, entity_type, org_number, tic_snapshot')
|
|
.eq('id', companyId)
|
|
.single()
|
|
|
|
if (!company) redirect('/onboarding')
|
|
|
|
const firstName = profile?.full_name?.split(' ')[0] ?? null
|
|
// Pre-render-friendly snapshot of company info: used to seed Phase B fields
|
|
// before the stream completes so the layout doesn't jump.
|
|
const initialFields = buildInitialFields(company, settings)
|
|
|
|
const atomTitles: Record<string, string> = {}
|
|
for (const row of (atomRows ?? []) as { id: string; title: string }[]) {
|
|
atomTitles[row.id] = row.title
|
|
}
|
|
|
|
return (
|
|
<AgentOnboarding
|
|
companyId={companyId}
|
|
companyName={company.name}
|
|
firstName={firstName}
|
|
initialFields={initialFields}
|
|
atomTitles={atomTitles}
|
|
alreadyVerified={Boolean(existingProfile?.verified_at)}
|
|
existingSummary={existingProfile?.profile_summary ?? null}
|
|
/>
|
|
)
|
|
}
|
|
|
|
interface InitialFields {
|
|
entity_type_label: string
|
|
// Multiple SNI codes: most companies have one but some are
|
|
// multi-vertical (e.g. konsult + lagerförsäljning).
|
|
sni_codes: { code: string; name: string }[]
|
|
// Verksamhetsbeskrivning (purpose) from Bolagsverket via TIC.
|
|
purpose: string | null
|
|
city: string | null
|
|
fiscal_period: string | null
|
|
vat_period: string | null
|
|
f_skatt: string | null
|
|
employees: string | null
|
|
}
|
|
|
|
interface CompanySettingsForPhaseB {
|
|
city: string | null
|
|
address_line1: string | null
|
|
postal_code: string | null
|
|
f_skatt: boolean | null
|
|
vat_registered: boolean | null
|
|
moms_period: string | null
|
|
fiscal_year_start_month: number | null
|
|
employee_count: number | null
|
|
has_employees: boolean | null
|
|
}
|
|
|
|
function buildInitialFields(
|
|
company: {
|
|
name: string
|
|
entity_type: string
|
|
org_number: string | null
|
|
tic_snapshot: Record<string, unknown> | null
|
|
},
|
|
settings: CompanySettingsForPhaseB | null,
|
|
): InitialFields {
|
|
const tic = (company.tic_snapshot ?? null) as Record<string, unknown> | null
|
|
const entityLabel =
|
|
company.entity_type === 'aktiebolag'
|
|
? 'AB'
|
|
: company.entity_type === 'enskild_firma'
|
|
? 'Enskild firma'
|
|
: company.entity_type
|
|
|
|
// Tier the resolution: TIC snapshot (if cached) wins because it's the
|
|
// authoritative Bolagsverket data; company_settings is the user-entered
|
|
// fallback from onboarding. Either can be missing.
|
|
let sniCodes: { code: string; name: string }[] = []
|
|
let purpose: string | null = null
|
|
let city: string | null = null
|
|
let fSkatt: string | null = null
|
|
let vatPeriod: string | null = null
|
|
let fiscalPeriod: string | null = null
|
|
let employees: string | null = null
|
|
|
|
if (tic) {
|
|
const sni = (tic.sniCodes as { code: string; name: string }[] | undefined) ?? []
|
|
if (Array.isArray(sni)) sniCodes = sni
|
|
const tp = tic.purpose
|
|
if (typeof tp === 'string' && tp.trim().length > 0) purpose = tp.trim()
|
|
const addr = tic.address as { city: string | null } | null
|
|
if (addr?.city) city = addr.city
|
|
const reg = tic.registration as { fTax?: boolean } | undefined
|
|
if (reg) fSkatt = reg.fTax ? 'Aktivt' : 'Saknas'
|
|
if (tic.employeeRange) employees = tic.employeeRange as string
|
|
// v2 caches `fiscalYear` (current fiscal-year configuration) on the
|
|
// snapshot. Prefer it over the user-entered value below so onboarding
|
|
// can show the registered fiscal period from Bolagsverket without
|
|
// making the user re-enter it.
|
|
const ticFiscal = tic.fiscalYear as { startMonthDay?: string | null } | null
|
|
const startMonth = parseTicStartMonth(ticFiscal?.startMonthDay)
|
|
if (startMonth != null) fiscalPeriod = fiscalYearLabel(startMonth)
|
|
}
|
|
|
|
if (settings) {
|
|
if (!city && settings.city) city = settings.city
|
|
if (!fSkatt && settings.f_skatt != null) {
|
|
fSkatt = settings.f_skatt ? 'Aktivt' : 'Saknas'
|
|
}
|
|
if (settings.moms_period) {
|
|
vatPeriod = momsPeriodLabel(settings.moms_period)
|
|
}
|
|
// settings.fiscal_year_start_month is the user-confirmed value: only
|
|
// overwrite the TIC-derived label if the user has explicitly set it
|
|
// (i.e. when there was no TIC fiscalYear AND they entered it manually).
|
|
if (!fiscalPeriod && settings.fiscal_year_start_month != null) {
|
|
fiscalPeriod = fiscalYearLabel(settings.fiscal_year_start_month)
|
|
}
|
|
if (!employees && settings.employee_count != null) {
|
|
employees = String(settings.employee_count)
|
|
} else if (!employees && settings.has_employees != null) {
|
|
employees = settings.has_employees ? 'Ja' : 'Nej'
|
|
}
|
|
}
|
|
|
|
return {
|
|
entity_type_label: entityLabel,
|
|
sni_codes: sniCodes,
|
|
purpose,
|
|
city,
|
|
fiscal_period: fiscalPeriod,
|
|
vat_period: vatPeriod,
|
|
f_skatt: fSkatt,
|
|
employees,
|
|
}
|
|
}
|
|
|
|
// Parse TIC v2's `startMonthDay` ("MM-DD") into a month number 1-12. Returns
|
|
// null when the field is missing or malformed so the caller falls back to
|
|
// company_settings.fiscal_year_start_month.
|
|
function parseTicStartMonth(value: string | null | undefined): number | null {
|
|
if (!value) return null
|
|
const match = /^(\d{1,2})-\d{1,2}$/.exec(value)
|
|
if (!match) return null
|
|
const month = Number(match[1])
|
|
if (!Number.isInteger(month) || month < 1 || month > 12) return null
|
|
return month
|
|
}
|
|
|
|
function momsPeriodLabel(period: string): string {
|
|
switch (period) {
|
|
case 'monthly':
|
|
return 'Månadsmoms'
|
|
case 'quarterly':
|
|
return 'Kvartalsmoms'
|
|
case 'yearly':
|
|
return 'Årsmoms'
|
|
default:
|
|
return period
|
|
}
|
|
}
|
|
|
|
// "fiscal_year_start_month=1" → "januari-december".
|
|
function fiscalYearLabel(startMonth: number): string {
|
|
const months = [
|
|
'januari',
|
|
'februari',
|
|
'mars',
|
|
'april',
|
|
'maj',
|
|
'juni',
|
|
'juli',
|
|
'augusti',
|
|
'september',
|
|
'oktober',
|
|
'november',
|
|
'december',
|
|
]
|
|
if (startMonth < 1 || startMonth > 12) return ''
|
|
const startIdx = startMonth - 1
|
|
const endIdx = (startIdx + 11) % 12
|
|
return `${months[startIdx]}-${months[endIdx]}`
|
|
}
|