feat(onboarding): activation quick wins on the Hem checklist (#1461)
* feat(onboarding): activation quick wins on the Hem checklist First implementation slice of the approved activation concept (artifact de543d57, dev_docs/onboarding_activation_analysis.md §9): - New "Kvitton och underlag" checklist step, gated on the invoice-inbox extension like the Skatteverket step; done once the company has ever received an inbox item (email/WhatsApp/upload). Non-AI companies route to billing, matching the assistant step. - Personalized VAT line in the Skatteverket step: the company's real next momsdeklaration due date from the deadlines table, with an explicit "välj momsperiod" prompt when vat_registered is set but moms_period is null (that state silently generates zero VAT deadlines). - Truthful Att göra empty state: while the setup checklist is open and no journal entry is posted, the all-clear reads "Bokföringen är tom än" instead of a false "Allt klart!". - Activation funnel events (onboarding_setup_step_started / _completed / _dismissed) via posthog-js, mirroring the existing guarded capture pattern; sandbox never renders the block so no extra gate is needed. Pure helpers live in lib/onboarding/checklist.ts with tests; step numbering now adapts to both optional extensions. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: record the receipts-signal and moms-period-guard decisions Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(onboarding): review triage: error-safe emptyLedger, stale-state guard, copy - A failed posted-entries count no longer reads as an empty ledger. - Confirming a suggested match books an entry, so the empty-ledger copy retires for the rest of the session (postedSinceLoad). - 'Bokföringen är tom än så länge' reads naturally. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
Jakob Wennberg
parent
ddbe9b1379
commit
44cff5e5e4
@@ -831,6 +831,7 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
|
||||
[2026-07-28] Transaction method (structured payment rail): the trailing channel phrase ("Överföring via internet", "Kortköp/uttag") is stripped from transactions.description at INGEST and by a one-shot BACKFILL, not merely hidden at render: description is the mutable working title, original_description keeps the full bank string, and every dedup surface (external_id: date+öre only; content bridge: prefix-containment over original_description ?? description, and a trailing strip leaves a prefix) is provably unaffected. transaction_method is text + CHECK (repo convention, no PG enums) beside verbatim bank_transaction_code / proprietary_bank_transaction_code evidence columns per data_quality_master Appendix B Layer-A; the dead `enrichment` jsonb was NOT reused (the Gokind lesson: opaque blobs with no readers die). mapping-engine now also matches original_description so user rules written against the full bank text keep firing on stripped rows.
|
||||
[2026-07-29] Transaction-method backfill scope: classification and title-stripping are FEED-ROW concepts (import_source present, not manual/mcp), enforced identically at ingest and in the 20260808090100 backfill, plus an adjective guard so "Egen insättning"/"Eget uttag"/"Intern överföring" keep their full titles even on feed rows (the phrase IS the meaning after a possessive/scope adjective). Chosen over vocabulary tweaks because the failure mode for unknown bank phrasings must be "row unchanged", and user-authored titles must never be rewritten by a channel vocabulary. A read-only prod dry-run script exists for coverage measurement but prod reads were left to the founder (permission-gated).
|
||||
[2026-08-08] Compliance-bot finding on the transaction_method backfill (booked rows' titles rewritten without a rattelse trail) triaged as satisfied-by-design, not a blocker: BFL 5 kap 5 attaches to bokforingsposter, and the backfill touches no journal table; the verifikat description is snapshotted into journal_entries at commit and SIE #VER export reads journal_entries only (both verified in code, no report reads transactions.description lazily); the bank original is preserved byte-identical in original_description by the same UPDATE (enforced since 80ef1ee0, and prod has 0/25,566 feed rows lacking it). The stricter TRANSACTION_TITLE_LOCKED gate on booked rows blocks arbitrary user free-text renames, a different mutation class from a deterministic trailing-vocabulary strip that skips user-edited titles and keeps the original adjacent. Period-lock triggers sit on the journal tables and fiscal periods, not on transactions; the pg-upgrade CI run applied the backfill against seeded booked rows with all enforcement triggers active.
|
||||
[2026-08-08] Receipts checklist step (PR #1461) done-signal = any invoice_inbox_items row for the company, not a document_attachments count: document_attachments also archives outbound invoice PDFs, ROT/RUT payout files and Bolagsverket submissions, so its count would mark the step done when the user merely sends their first invoice; invoice_inbox_items is written by exactly the three intake channels (email/WhatsApp/upload) and carries a company_id index. Same PR: vat_registered with moms_period=null renders an explicit "välj momsperiod"-link in the checklist instead of silence, because that state makes the deadline engine generate zero VAT deadlines without any error (all three moms configs require a concrete period).
|
||||
[2026-08-08] Per-voucher RC basis gaps (findRcBasisGaps) downgrade from filing-blocking ERROR to WARNING only under per-rate evidence: the 44xx/45xx basis accounts grouped by momssats must match ruta30/0.25, ruta31/0.12, ruta32/0.06 two-sided within 0.5 kr, all rate boxes non-negative, and no RC_OUTPUT_MISSING present. A first cross-rate-sum predicate was refuted by /skeptic (wrong-rate fiktiv moms reached parity and unblocked a 7 800 kr under-declaration; a negative rate box made the predicate vacuous), so the certificate is per-rate, which rutor alone cannot express (rutor 20-24 are partitioned by purchase type, not rate); evidence therefore flows from the account totals. Why downgrade at all: a moms-only rattelseverifikat carries fiktiv moms whose basbelopp lives in another (often reversed) verifikat, and no voucher arrangement satisfies both the per-voucher scan and the aggregate identity in that state, so the ERROR was an unfixable dead end (Orto Engineering 3DJake case 2026-08; support vouchers A169/A175/A177 joined the blocklist they were meant to clear). Data side repaired separately with voucher A177 restoring bank parity and the basis/moms identity exactly.
|
||||
[2026-08-08] Fenced-JSON fix uses brace-slice, not fence-regex: also rescues preamble/postamble prose around the object, and degrades to the existing empty-result path when no braces exist.
|
||||
[2026-08-08] extractJsonObject upgraded from brace-slice to depth-aware balanced scan after PR 1460 review: prose containing braces around the JSON no longer poisons the slice; first parseable candidate wins.
|
||||
|
||||
@@ -3,6 +3,7 @@ import DashboardContent from '@/components/dashboard/DashboardContent'
|
||||
import { getWorklistCounts, listSuggestedMatches } from '@/lib/worklist'
|
||||
import { listResumeItems } from '@/lib/worklist/resume'
|
||||
import { shouldShowOtherAccountHint } from '@/lib/company/other-account-hint'
|
||||
import { vatDeadlineLine } from '@/lib/onboarding/checklist'
|
||||
import type { OnboardingProgress } from '@/types'
|
||||
import {
|
||||
getDashboardAuthContext,
|
||||
@@ -44,6 +45,9 @@ export default async function DashboardPage() {
|
||||
{ data: bankConnections },
|
||||
{ count: sieImportCount },
|
||||
{ count: skatteverketTokenCount },
|
||||
{ count: inboxItemCount },
|
||||
{ count: postedEntryCount, error: postedEntryError },
|
||||
{ data: nextVatDeadline },
|
||||
{ data: profile },
|
||||
agentProfile,
|
||||
worklist,
|
||||
@@ -61,6 +65,27 @@ export default async function DashboardPage() {
|
||||
// carry the active company_id; either filter would work: we use user_id
|
||||
// because that's what the token-store reads/writes against.
|
||||
supabase.from('skatteverket_tokens').select('*', { count: 'exact', head: true }).eq('user_id', user.id),
|
||||
// Any item ever received in the document inbox (email/WhatsApp/upload)
|
||||
// marks the receipts checklist step done: same "has ever done X" shape
|
||||
// as the other flags above.
|
||||
supabase.from('invoice_inbox_items').select('*', { count: 'exact', head: true }).eq('company_id', companyId),
|
||||
// Posted entries distinguish "brand-new empty ledger" from "all caught
|
||||
// up" in the Att göra empty state (hits the partial posted/reversed index).
|
||||
supabase.from('journal_entries').select('*', { count: 'exact', head: true }).eq('company_id', companyId).in('status', ['posted', 'reversed']),
|
||||
// Next upcoming momsdeklaration for the checklist's Skatteverket step.
|
||||
// Rows are system-generated per company settings; dismissed rows are
|
||||
// excluded everywhere deadlines are listed, so here too.
|
||||
supabase
|
||||
.from('deadlines')
|
||||
.select('due_date')
|
||||
.eq('company_id', companyId)
|
||||
.in('tax_deadline_type', ['moms_monthly', 'moms_quarterly', 'moms_yearly'])
|
||||
.eq('is_completed', false)
|
||||
.is('dismissed_at', null)
|
||||
.gte('due_date', now.toISOString().slice(0, 10))
|
||||
.order('due_date', { ascending: true })
|
||||
.limit(1)
|
||||
.maybeSingle(),
|
||||
// First name for the greeting.
|
||||
supabase.from('profiles').select('full_name').eq('id', user.id).maybeSingle(),
|
||||
getResolvedDashboardAgentProfile(),
|
||||
@@ -98,8 +123,22 @@ export default async function DashboardPage() {
|
||||
hasBankConnected: (bankConnections?.length || 0) > 0 || (transactionCount || 0) > 0,
|
||||
hasSIEImport: (sieImportCount || 0) > 0,
|
||||
hasSkatteverketConnected: (skatteverketTokenCount || 0) > 0,
|
||||
hasInboxItems: (inboxItemCount || 0) > 0,
|
||||
}
|
||||
|
||||
const vatLine = vatDeadlineLine({
|
||||
vatRegistered: settings.vat_registered,
|
||||
momsPeriod: settings.moms_period ?? null,
|
||||
nextVatDueDate: nextVatDeadline?.due_date ?? null,
|
||||
})
|
||||
|
||||
// "Empty ledger" only matters while the setup checklist is still open; once
|
||||
// it is completed or dismissed the ordinary all-clear copy applies. A failed
|
||||
// count must NOT read as empty: that would tell a company with real
|
||||
// bookkeeping that its ledger is blank, so errors degrade to the normal copy.
|
||||
const setupOpen = !settings.initial_setup_completed_at && !settings.initial_setup_dismissed_at
|
||||
const emptyLedger = setupOpen && !postedEntryError && (postedEntryCount || 0) === 0
|
||||
|
||||
const nowMs = now.getTime()
|
||||
const expiringBankConnections = (bankConnections || [])
|
||||
.filter(conn => {
|
||||
@@ -135,6 +174,8 @@ export default async function DashboardPage() {
|
||||
completedAt: settings.initial_setup_completed_at ?? null,
|
||||
dismissedAt: settings.initial_setup_dismissed_at ?? null,
|
||||
}}
|
||||
vatLine={vatLine}
|
||||
emptyLedger={emptyLedger}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ export const getDashboardSettings = cache(async () => {
|
||||
|
||||
return supabase
|
||||
.from('company_settings')
|
||||
.select('company_name, onboarding_complete, entity_type, pays_salaries, is_sandbox, dimensions_enabled, ore_rounding, initial_setup_path, initial_setup_completed_at, initial_setup_dismissed_at')
|
||||
.select('company_name, onboarding_complete, entity_type, pays_salaries, is_sandbox, dimensions_enabled, ore_rounding, initial_setup_path, initial_setup_completed_at, initial_setup_dismissed_at, vat_registered, moms_period')
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle()
|
||||
})
|
||||
|
||||
@@ -15,6 +15,7 @@ import { visibleWorklistTotal } from '@/lib/worklist/visible-total'
|
||||
import {
|
||||
ArrowLeftRight,
|
||||
ArrowRight,
|
||||
BookOpen,
|
||||
CalendarClock,
|
||||
CheckCircle2,
|
||||
ChevronRight,
|
||||
@@ -52,6 +53,12 @@ interface AttGoraSectionProps {
|
||||
worklist: WorklistCounts
|
||||
suggestedMatches: SuggestedMatch[]
|
||||
expiringBankConnections?: ExpiringBankConnection[]
|
||||
/**
|
||||
* True while the setup checklist is open and the company has zero posted
|
||||
* journal entries. An empty ledger is not an achievement: the all-clear
|
||||
* state then says "nothing here yet" instead of a false "all caught up".
|
||||
*/
|
||||
emptyLedger?: boolean
|
||||
}
|
||||
|
||||
interface WorklistRowProps {
|
||||
@@ -99,6 +106,7 @@ export default function AttGoraSection({
|
||||
worklist,
|
||||
suggestedMatches,
|
||||
expiringBankConnections = [],
|
||||
emptyLedger = false,
|
||||
}: AttGoraSectionProps) {
|
||||
const t = useTranslations('dashboard')
|
||||
const { toast } = useToast()
|
||||
@@ -112,6 +120,9 @@ export default function AttGoraSection({
|
||||
const [matches, setMatches] = useState(suggestedMatches)
|
||||
const [leavingIds, setLeavingIds] = useState<Set<string>>(new Set())
|
||||
const [confirmingId, setConfirmingId] = useState<string | null>(null)
|
||||
// A confirmed match books a journal entry, so the server-derived
|
||||
// emptyLedger flag goes stale the moment one succeeds in this session.
|
||||
const [postedSinceLoad, setPostedSinceLoad] = useState(false)
|
||||
|
||||
async function refetchCounts() {
|
||||
try {
|
||||
@@ -158,6 +169,7 @@ export default function AttGoraSection({
|
||||
return
|
||||
}
|
||||
toast({ title: t('suggested_confirmed_toast') })
|
||||
setPostedSinceLoad(true)
|
||||
// Fade the row out, drop it, then re-sync every count from the source
|
||||
// of truth (the match also booked a transaction, so several numbers move).
|
||||
setLeavingIds((prev) => new Set(prev).add(match.transaction_id))
|
||||
@@ -206,18 +218,31 @@ export default function AttGoraSection({
|
||||
<div className="flex items-baseline justify-between border-b border-border px-1 pb-2.5">
|
||||
<h2 className="font-sans text-sm font-medium">{t('att_gora_title')}</h2>
|
||||
<p className="text-xs text-muted-foreground tabular-nums" role="status" aria-live="polite">
|
||||
{allClear ? t('all_done') : t('att_gora_left', { count: displayTotal })}
|
||||
{allClear
|
||||
? emptyLedger && !postedSinceLoad
|
||||
? t('att_gora_new_status')
|
||||
: t('all_done')
|
||||
: t('att_gora_left', { count: displayTotal })}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{allClear ? (
|
||||
<EmptyState
|
||||
icon={CheckCircle2}
|
||||
title={t('att_gora_empty_title')}
|
||||
description={t('att_gora_empty_body')}
|
||||
className="py-10"
|
||||
/>
|
||||
emptyLedger && !postedSinceLoad ? (
|
||||
<EmptyState
|
||||
icon={BookOpen}
|
||||
title={t('att_gora_new_title')}
|
||||
description={t('att_gora_new_body')}
|
||||
className="py-10"
|
||||
/>
|
||||
) : (
|
||||
<EmptyState
|
||||
icon={CheckCircle2}
|
||||
title={t('att_gora_empty_title')}
|
||||
description={t('att_gora_empty_body')}
|
||||
className="py-10"
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<div className="pb-2">
|
||||
{bokforRows && (
|
||||
|
||||
@@ -19,6 +19,7 @@ import { ArrowRight } from 'lucide-react'
|
||||
import type { InitialSetupState, OnboardingProgress } from '@/types'
|
||||
import type { SuggestedMatch, WorklistCounts } from '@/lib/worklist/types'
|
||||
import type { ResumeItem } from '@/lib/worklist/resume'
|
||||
import type { VatDeadlineLine } from '@/lib/onboarding/checklist'
|
||||
|
||||
interface DashboardContentProps {
|
||||
companyId: string
|
||||
@@ -48,6 +49,14 @@ interface DashboardContentProps {
|
||||
* full-screen onboarding takeover.
|
||||
*/
|
||||
agentBuilt?: boolean
|
||||
/** Personalized VAT-deadline line for the checklist's Skatteverket step. */
|
||||
vatLine?: VatDeadlineLine
|
||||
/**
|
||||
* True while the setup checklist is still open and the company has zero
|
||||
* posted journal entries: Att göra's all-clear then reads as "empty, get
|
||||
* started" instead of a false "all caught up".
|
||||
*/
|
||||
emptyLedger?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -68,6 +77,8 @@ export default function DashboardContent({
|
||||
onboardingProgress,
|
||||
initialSetup,
|
||||
agentBuilt = true,
|
||||
vatLine = null,
|
||||
emptyLedger = false,
|
||||
}: DashboardContentProps) {
|
||||
const t = useTranslations('dashboard')
|
||||
const hasAi = useCapability(CAPABILITY.ai)
|
||||
@@ -123,14 +134,16 @@ export default function DashboardContent({
|
||||
hasBookkeepingImported={!!onboardingProgress?.hasSIEImport}
|
||||
hasBankConnected={!!onboardingProgress?.hasBankConnected}
|
||||
hasSkatteverketConnected={!!onboardingProgress?.hasSkatteverketConnected}
|
||||
hasInboxItems={!!onboardingProgress?.hasInboxItems}
|
||||
hasAgentBuilt={agentBuilt}
|
||||
vatLine={vatLine}
|
||||
/>
|
||||
|
||||
{/* Build-assistant hero: shown only until the company has a verified
|
||||
agent_profile, so existing/migrated users get a clear prompt instead
|
||||
of a full-screen onboarding takeover. While the stepped first-run
|
||||
checklist is visible it already carries the assistant as step 3, so
|
||||
the hero waits until that block is dismissed or completed. */}
|
||||
checklist is visible it already carries the assistant as its last
|
||||
step, so the hero waits until that block is dismissed or completed. */}
|
||||
{!agentBuilt && (initialSetup.dismissedAt || initialSetup.completedAt) && (
|
||||
<section>
|
||||
{/* Non-payers keep seeing the hero (conversion surface) but it
|
||||
@@ -170,6 +183,7 @@ export default function DashboardContent({
|
||||
worklist={worklist}
|
||||
suggestedMatches={suggestedMatches}
|
||||
expiringBankConnections={expiringBankConnections}
|
||||
emptyLedger={emptyLedger}
|
||||
/>
|
||||
<ResumePane items={resumeItems} />
|
||||
</div>
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { Check } from 'lucide-react'
|
||||
import posthog from 'posthog-js'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useErrorToast } from '@/lib/hooks/use-error-toast'
|
||||
import { useFormat } from '@/lib/hooks/use-format'
|
||||
import { isAnalyticsEnabled } from '@/lib/analytics/enabled'
|
||||
import { checklistNumbers, type VatDeadlineLine } from '@/lib/onboarding/checklist'
|
||||
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
|
||||
import { useCapability } from '@/contexts/CompanyContext'
|
||||
import { CAPABILITY } from '@/lib/entitlements/keys'
|
||||
@@ -19,13 +24,32 @@ interface NewUserChecklistProps {
|
||||
hasBookkeepingImported?: boolean
|
||||
hasBankConnected?: boolean
|
||||
hasSkatteverketConnected?: boolean
|
||||
hasInboxItems?: boolean
|
||||
hasAgentBuilt?: boolean
|
||||
/** Personalized VAT-deadline line for the Skatteverket step (null = say nothing). */
|
||||
vatLine?: VatDeadlineLine
|
||||
}
|
||||
|
||||
/**
|
||||
* Activation funnel events, mirroring the one existing product-event site
|
||||
* (lib/support/submit-feedback.ts): guarded, try/caught, no PII in
|
||||
* properties. Sandbox companies never render this block (their
|
||||
* initial_setup is seeded completed+dismissed), so no sandbox gate needed.
|
||||
*/
|
||||
function captureSetup(event: string, properties?: Record<string, unknown>) {
|
||||
if (!isAnalyticsEnabled()) return
|
||||
try {
|
||||
posthog.capture(event, properties)
|
||||
} catch {
|
||||
// Telemetry must never affect the checklist.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* First-run getting-started block on Hem, in the founder-picked stepped
|
||||
* shape: a numbered thread (get the books in, connect the bank, connect
|
||||
* Skatteverket, build the assistant) on a hairline spine.
|
||||
* Skatteverket, get receipts flowing, build the assistant) on a hairline
|
||||
* spine.
|
||||
* Only the step you are on argues its case: it carries the description and
|
||||
* the partner marks next to a filled action. Steps you have not reached yet
|
||||
* drop the pitch but keep a quiet outline action, so any step stays one
|
||||
@@ -41,11 +65,14 @@ export default function NewUserChecklist({
|
||||
hasBookkeepingImported = false,
|
||||
hasBankConnected = false,
|
||||
hasSkatteverketConnected = false,
|
||||
hasInboxItems = false,
|
||||
hasAgentBuilt = false,
|
||||
vatLine = null,
|
||||
}: NewUserChecklistProps) {
|
||||
const t = useTranslations('initial_setup')
|
||||
const router = useRouter()
|
||||
const showError = useErrorToast()
|
||||
const { formatDateLong } = useFormat()
|
||||
const hasAi = useCapability(CAPABILITY.ai)
|
||||
const [state, setState] = useState(initialState)
|
||||
const [saving, setSaving] = useState<InitialSetupPath | 'dismiss' | 'complete' | null>(null)
|
||||
@@ -53,6 +80,8 @@ export default function NewUserChecklist({
|
||||
const hasMigration = ENABLED_EXTENSION_IDS.has('arcim-migration')
|
||||
const hasBanking = ENABLED_EXTENSION_IDS.has('enable-banking')
|
||||
const hasSkatteverket = ENABLED_EXTENSION_IDS.has('skatteverket')
|
||||
const hasInbox = ENABLED_EXTENSION_IDS.has('invoice-inbox')
|
||||
const hasWhatsApp = ENABLED_EXTENSION_IDS.has('whatsapp-inbox')
|
||||
|
||||
const persist = async (
|
||||
body: Record<string, unknown>,
|
||||
@@ -82,35 +111,64 @@ export default function NewUserChecklist({
|
||||
|
||||
const step1Done = hasBookkeepingImported || state.path === 'fresh'
|
||||
const step2Done = hasBankConnected
|
||||
// Companies built without the skatteverket extension skip that step.
|
||||
// Companies built without the skatteverket/inbox extensions skip those steps.
|
||||
const step3Done = !hasSkatteverket || hasSkatteverketConnected
|
||||
const step4Done = hasAgentBuilt
|
||||
const step4Done = !hasInbox || hasInboxItems
|
||||
const step5Done = hasAgentBuilt
|
||||
|
||||
useEffect(() => {
|
||||
// The block retires itself once every step is done; Dölj remains the
|
||||
// manual way out.
|
||||
if (!state.completedAt && step1Done && step2Done && step3Done && step4Done && saving === null) {
|
||||
void persist({ completed: true }, 'complete')
|
||||
if (
|
||||
!state.completedAt &&
|
||||
step1Done && step2Done && step3Done && step4Done && step5Done &&
|
||||
saving === null
|
||||
) {
|
||||
void persist({ completed: true }, 'complete').then((updated) => {
|
||||
if (updated) captureSetup('onboarding_setup_completed', { path: updated.path })
|
||||
})
|
||||
}
|
||||
// persist intentionally stays out: its identity follows the toast hook and
|
||||
// would retrigger this completion sync after every render.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [step1Done, step2Done, step3Done, step4Done, saving, state.completedAt])
|
||||
}, [step1Done, step2Done, step3Done, step4Done, step5Done, saving, state.completedAt])
|
||||
|
||||
if (state.dismissedAt || state.completedAt) return null
|
||||
|
||||
const goMigration = async () => {
|
||||
const updated = await persist({ path: 'migration' }, 'migration')
|
||||
if (updated) router.push(hasMigration ? '/import?mode=migration' : '/import?mode=sie')
|
||||
if (updated) {
|
||||
captureSetup('onboarding_setup_step_started', { step: 'books', path: 'migration' })
|
||||
router.push(hasMigration ? '/import?mode=migration' : '/import?mode=sie')
|
||||
}
|
||||
}
|
||||
const goFresh = () => void persist({ path: 'fresh' }, 'fresh')
|
||||
const goFresh = () =>
|
||||
void persist({ path: 'fresh' }, 'fresh').then((updated) => {
|
||||
if (updated) captureSetup('onboarding_setup_step_started', { step: 'books', path: 'fresh' })
|
||||
})
|
||||
const goBank = async () => {
|
||||
const updated = await persist({ path: state.path ?? 'bank' }, 'bank')
|
||||
if (updated) router.push(hasBanking ? '/import?mode=psd2' : '/import?mode=bank')
|
||||
if (updated) {
|
||||
captureSetup('onboarding_setup_step_started', { step: 'bank' })
|
||||
router.push(hasBanking ? '/import?mode=psd2' : '/import?mode=bank')
|
||||
}
|
||||
}
|
||||
const goReceipts = () => {
|
||||
captureSetup('onboarding_setup_step_started', { step: 'receipts' })
|
||||
router.push(hasAi ? '/e/general/invoice-inbox' : '/settings/billing')
|
||||
}
|
||||
const goAssistant = () => {
|
||||
captureSetup('onboarding_setup_step_started', { step: 'assistant' })
|
||||
router.push(hasAi ? '/onboarding/agent' : '/settings/billing')
|
||||
}
|
||||
const dismiss = () =>
|
||||
void persist({ dismissed: true }, 'dismiss').then((updated) => {
|
||||
if (updated) captureSetup('onboarding_setup_dismissed', {})
|
||||
})
|
||||
|
||||
const activeStep = !step1Done ? 1 : !step2Done ? 2 : !step3Done ? 3 : 4
|
||||
const stepCount = hasSkatteverket ? 4 : 3
|
||||
const activeStep = !step1Done ? 1 : !step2Done ? 2 : !step3Done ? 3 : !step4Done ? 4 : 5
|
||||
const numbers = checklistNumbers({ hasSkatteverket, hasInbox })
|
||||
const stepCount = numbers.count
|
||||
|
||||
return (
|
||||
<section className={className} aria-label={t('title', { count: stepCount })}>
|
||||
@@ -119,7 +177,7 @@ export default function NewUserChecklist({
|
||||
<button
|
||||
type="button"
|
||||
disabled={saving !== null}
|
||||
onClick={() => void persist({ dismissed: true }, 'dismiss')}
|
||||
onClick={dismiss}
|
||||
className="shrink-0 text-xs text-muted-foreground underline decoration-border underline-offset-4 transition-colors hover:text-foreground"
|
||||
>
|
||||
{t('dismiss')}
|
||||
@@ -189,7 +247,7 @@ export default function NewUserChecklist({
|
||||
|
||||
{hasSkatteverket && (
|
||||
<Step
|
||||
number={3}
|
||||
number={numbers.skv}
|
||||
done={step3Done}
|
||||
active={activeStep === 3}
|
||||
title={t('step_skv_title')}
|
||||
@@ -197,7 +255,10 @@ export default function NewUserChecklist({
|
||||
<Button size="sm" variant={variant} asChild>
|
||||
{/* The authorize endpoint redirects off-site to Skatteverket. */}
|
||||
{/* eslint-disable-next-line @next/next/no-html-link-for-pages */}
|
||||
<a href="/api/extensions/ext/skatteverket/authorize?return_to=/">
|
||||
<a
|
||||
href="/api/extensions/ext/skatteverket/authorize?return_to=/"
|
||||
onClick={() => captureSetup('onboarding_setup_step_started', { step: 'skatteverket' })}
|
||||
>
|
||||
{t('step_skv_action')}
|
||||
</a>
|
||||
</Button>
|
||||
@@ -205,22 +266,58 @@ export default function NewUserChecklist({
|
||||
marks={<LogoMark src="/logos/skatteverket_color.svg" name="Skatteverket" />}
|
||||
>
|
||||
{t('step_skv_description')}
|
||||
{vatLine?.kind === 'date' && (
|
||||
<>
|
||||
{' '}
|
||||
<span className="text-foreground">
|
||||
{t('step_skv_next_vat', { date: formatDateLong(vatLine.dueDate) })}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{vatLine?.kind === 'missing_period' && (
|
||||
<>
|
||||
{' '}
|
||||
<Link
|
||||
href="/settings/tax"
|
||||
className="underline decoration-border underline-offset-4 transition-colors hover:text-foreground"
|
||||
>
|
||||
{t('step_skv_choose_period')}
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</Step>
|
||||
)}
|
||||
|
||||
{hasInbox && (
|
||||
<Step
|
||||
number={numbers.receipts}
|
||||
done={step4Done}
|
||||
active={activeStep === 4}
|
||||
title={t('step_receipts_title')}
|
||||
action={(variant) => (
|
||||
<Button size="sm" variant={variant} onClick={goReceipts}>
|
||||
{t('step_receipts_action')}
|
||||
</Button>
|
||||
)}
|
||||
marks={
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{hasWhatsApp ? t('step_receipts_channels') : t('step_receipts_channels_no_wa')}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
{t('step_receipts_description')}
|
||||
</Step>
|
||||
)}
|
||||
|
||||
<Step
|
||||
number={hasSkatteverket ? 4 : 3}
|
||||
done={step4Done}
|
||||
active={activeStep === 4 || (!hasSkatteverket && activeStep === 3)}
|
||||
number={numbers.assistant}
|
||||
done={step5Done}
|
||||
active={activeStep === 5}
|
||||
title={t('step_assistant_title')}
|
||||
badge={t('step_assistant_beta')}
|
||||
last
|
||||
action={(variant) => (
|
||||
<Button
|
||||
size="sm"
|
||||
variant={variant}
|
||||
onClick={() => router.push(hasAi ? '/onboarding/agent' : '/settings/billing')}
|
||||
>
|
||||
<Button size="sm" variant={variant} onClick={goAssistant}>
|
||||
{t('step_assistant_action')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { checklistNumbers, vatDeadlineLine } from '../checklist'
|
||||
|
||||
describe('vatDeadlineLine', () => {
|
||||
it('returns null when the company is not VAT-registered', () => {
|
||||
expect(
|
||||
vatDeadlineLine({ vatRegistered: false, momsPeriod: 'quarterly', nextVatDueDate: '2026-11-12' })
|
||||
).toBeNull()
|
||||
expect(
|
||||
vatDeadlineLine({ vatRegistered: null, momsPeriod: null, nextVatDueDate: null })
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('flags the silent zero-deadline misconfiguration when moms_period is unset', () => {
|
||||
expect(
|
||||
vatDeadlineLine({ vatRegistered: true, momsPeriod: null, nextVatDueDate: null })
|
||||
).toEqual({ kind: 'missing_period' })
|
||||
// Even with a stray row, an unset period is still a misconfiguration to surface.
|
||||
expect(
|
||||
vatDeadlineLine({ vatRegistered: true, momsPeriod: undefined, nextVatDueDate: '2026-11-12' })
|
||||
).toEqual({ kind: 'missing_period' })
|
||||
})
|
||||
|
||||
it('returns the due date when registered with a period and an upcoming row', () => {
|
||||
expect(
|
||||
vatDeadlineLine({ vatRegistered: true, momsPeriod: 'quarterly', nextVatDueDate: '2026-11-12' })
|
||||
).toEqual({ kind: 'date', dueDate: '2026-11-12' })
|
||||
})
|
||||
|
||||
it('says nothing when a period is set but no upcoming row surfaced', () => {
|
||||
expect(
|
||||
vatDeadlineLine({ vatRegistered: true, momsPeriod: 'yearly', nextVatDueDate: null })
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('checklistNumbers', () => {
|
||||
it('numbers all five steps when both extensions are on', () => {
|
||||
expect(checklistNumbers({ hasSkatteverket: true, hasInbox: true })).toEqual({
|
||||
count: 5,
|
||||
skv: 3,
|
||||
receipts: 4,
|
||||
assistant: 5,
|
||||
})
|
||||
})
|
||||
|
||||
it('collapses to four steps without the inbox extension', () => {
|
||||
expect(checklistNumbers({ hasSkatteverket: true, hasInbox: false })).toEqual({
|
||||
count: 4,
|
||||
skv: 3,
|
||||
receipts: 4,
|
||||
assistant: 4,
|
||||
})
|
||||
})
|
||||
|
||||
it('collapses to four steps without the skatteverket extension', () => {
|
||||
expect(checklistNumbers({ hasSkatteverket: false, hasInbox: true })).toEqual({
|
||||
count: 4,
|
||||
skv: 3,
|
||||
receipts: 3,
|
||||
assistant: 4,
|
||||
})
|
||||
})
|
||||
|
||||
it('collapses to three steps with neither extension', () => {
|
||||
expect(checklistNumbers({ hasSkatteverket: false, hasInbox: false })).toEqual({
|
||||
count: 3,
|
||||
skv: 3,
|
||||
receipts: 3,
|
||||
assistant: 3,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { MomsPeriod } from '@/types'
|
||||
|
||||
/**
|
||||
* What the Skatteverket checklist step should say about VAT deadlines.
|
||||
*
|
||||
* - 'date': the company's next momsdeklaration due date is known; show it.
|
||||
* - 'missing_period': the company is VAT-registered but moms_period is unset,
|
||||
* which makes the deadline engine silently generate ZERO VAT deadlines
|
||||
* (lib/tax/deadline-config.ts conditions all require a concrete period).
|
||||
* An empty deadlines query in that state means misconfiguration, not
|
||||
* "no VAT duty", so the UI must prompt for the period instead of showing
|
||||
* nothing.
|
||||
* - null: not VAT-registered (no line), or VAT-registered with a period set
|
||||
* but no upcoming row surfaced (transient or horizon gap; say nothing
|
||||
* rather than guessing).
|
||||
*/
|
||||
export type VatDeadlineLine =
|
||||
| { kind: 'date'; dueDate: string }
|
||||
| { kind: 'missing_period' }
|
||||
| null
|
||||
|
||||
export function vatDeadlineLine(input: {
|
||||
vatRegistered: boolean | null | undefined
|
||||
momsPeriod: MomsPeriod | null | undefined
|
||||
nextVatDueDate: string | null | undefined
|
||||
}): VatDeadlineLine {
|
||||
if (!input.vatRegistered) return null
|
||||
if (!input.momsPeriod) return { kind: 'missing_period' }
|
||||
if (!input.nextVatDueDate) return null
|
||||
return { kind: 'date', dueDate: input.nextVatDueDate }
|
||||
}
|
||||
|
||||
/**
|
||||
* Display ordinals for the setup checklist steps. Books and bank are always
|
||||
* present; Skatteverket and the receipts/inbox step render only when their
|
||||
* extensions are enabled; the assistant step is always last. `count` drives
|
||||
* the "{count} steg så är bokföringen igång" title.
|
||||
*/
|
||||
export function checklistNumbers(gates: { hasSkatteverket: boolean; hasInbox: boolean }): {
|
||||
count: number
|
||||
skv: number
|
||||
receipts: number
|
||||
assistant: number
|
||||
} {
|
||||
const skv = 3
|
||||
const receipts = 3 + (gates.hasSkatteverket ? 1 : 0)
|
||||
const assistant = receipts + (gates.hasInbox ? 1 : 0)
|
||||
return { count: assistant, skv, receipts, assistant }
|
||||
}
|
||||
+11
-1
@@ -1325,7 +1325,14 @@
|
||||
"dismiss": "Hide",
|
||||
"step_skv_title": "Connect Skatteverket",
|
||||
"step_skv_description": "See the tax account and file VAT and employer declarations right from here. Connects with BankID in a couple of minutes.",
|
||||
"step_skv_action": "Connect Skatteverket"
|
||||
"step_skv_action": "Connect Skatteverket",
|
||||
"step_skv_next_vat": "First VAT return: {date}.",
|
||||
"step_skv_choose_period": "Pick a VAT period in settings, otherwise no VAT deadlines are tracked.",
|
||||
"step_receipts_title": "Receipts and documents",
|
||||
"step_receipts_description": "Photo the receipt or forward the email to your inbox address, and the document is read and waiting for you.",
|
||||
"step_receipts_action": "Open the inbox",
|
||||
"step_receipts_channels": "Email · WhatsApp · upload",
|
||||
"step_receipts_channels_no_wa": "Email · upload"
|
||||
},
|
||||
"tax_assessment_notices": {
|
||||
"title": "Final tax notices and remaining tax",
|
||||
@@ -5659,6 +5666,9 @@
|
||||
"att_gora_left": "{count} left",
|
||||
"att_gora_empty_title": "All caught up!",
|
||||
"att_gora_empty_body": "No transactions to record and no documents to handle.",
|
||||
"att_gora_new_status": "Nothing here yet",
|
||||
"att_gora_new_title": "Your books are empty so far",
|
||||
"att_gora_new_body": "The get-started steps above will fill them: your history, the bank and the receipts.",
|
||||
"band_bokfor": "Record",
|
||||
"band_granska": "Review & complete",
|
||||
"band_bevaka": "Monitor",
|
||||
|
||||
+11
-1
@@ -1325,7 +1325,14 @@
|
||||
"dismiss": "Dölj",
|
||||
"step_skv_title": "Anslut Skatteverket",
|
||||
"step_skv_description": "Se skattekontot och lämna moms- och arbetsgivardeklarationer direkt härifrån. Ansluts med BankID på ett par minuter.",
|
||||
"step_skv_action": "Anslut Skatteverket"
|
||||
"step_skv_action": "Anslut Skatteverket",
|
||||
"step_skv_next_vat": "Första momsdeklarationen: {date}.",
|
||||
"step_skv_choose_period": "Välj momsperiod i inställningarna, annars bevakas inga momsdeadlines.",
|
||||
"step_receipts_title": "Kvitton och underlag",
|
||||
"step_receipts_description": "Fota kvittot eller vidarebefordra mejlet till er inkorgsadress, så tolkas underlaget och väntar på dig.",
|
||||
"step_receipts_action": "Öppna inkorgen",
|
||||
"step_receipts_channels": "Mejl · WhatsApp · uppladdning",
|
||||
"step_receipts_channels_no_wa": "Mejl · uppladdning"
|
||||
},
|
||||
"tax_assessment_notices": {
|
||||
"title": "Slutskattebesked och kvarskatt",
|
||||
@@ -5659,6 +5666,9 @@
|
||||
"att_gora_left": "{count} kvar",
|
||||
"att_gora_empty_title": "Allt klart!",
|
||||
"att_gora_empty_body": "Inga transaktioner att bokföra och inga underlag att hantera.",
|
||||
"att_gora_new_status": "Inget här än",
|
||||
"att_gora_new_title": "Bokföringen är tom än så länge",
|
||||
"att_gora_new_body": "Kom igång-stegen ovan fyller den: historiken, banken och kvittona.",
|
||||
"band_bokfor": "Bokför",
|
||||
"band_granska": "Granska & komplettera",
|
||||
"band_bevaka": "Bevaka",
|
||||
|
||||
@@ -2354,6 +2354,8 @@ export interface OnboardingProgress {
|
||||
hasSIEImport: boolean
|
||||
/** True when the active user has a stored Skatteverket OAuth token. */
|
||||
hasSkatteverketConnected: boolean
|
||||
/** True when the company has ever received an item in the document inbox. */
|
||||
hasInboxItems: boolean
|
||||
}
|
||||
|
||||
export type InitialSetupPath = 'migration' | 'bank' | 'fresh'
|
||||
|
||||
Reference in New Issue
Block a user