From 44cff5e5e483128f40bae026704da5bedf001826 Mon Sep 17 00:00:00 2001 From: Jakob Wennberg Date: Sat, 8 Aug 2026 15:14:34 +0200 Subject: [PATCH] feat(onboarding): activation quick wins on the Hem checklist (#1461) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * docs: record the receipts-signal and moms-period-guard decisions Co-Authored-By: Claude Fable 5 * 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 --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 --- DECISIONS.md | 1 + app/(dashboard)/page.tsx | 41 ++++++ app/(dashboard)/request-context.ts | 2 +- components/dashboard/AttGoraSection.tsx | 39 +++++- components/dashboard/DashboardContent.tsx | 18 ++- components/onboarding/NewUserChecklist.tsx | 141 +++++++++++++++++---- lib/onboarding/__tests__/checklist.test.ts | 73 +++++++++++ lib/onboarding/checklist.ts | 49 +++++++ messages/en.json | 12 +- messages/sv.json | 12 +- types/index.ts | 2 + 11 files changed, 356 insertions(+), 34 deletions(-) create mode 100644 lib/onboarding/__tests__/checklist.test.ts create mode 100644 lib/onboarding/checklist.ts diff --git a/DECISIONS.md b/DECISIONS.md index bea415a5..88aab9ec 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -831,6 +831,7 @@ One line per decision: `[YYYY-MM-DD] : `. 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. diff --git a/app/(dashboard)/page.tsx b/app/(dashboard)/page.tsx index c6d48d8c..f47dfe79 100644 --- a/app/(dashboard)/page.tsx +++ b/app/(dashboard)/page.tsx @@ -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} /> ) } diff --git a/app/(dashboard)/request-context.ts b/app/(dashboard)/request-context.ts index 7743fadd..63e6d770 100644 --- a/app/(dashboard)/request-context.ts +++ b/app/(dashboard)/request-context.ts @@ -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() }) diff --git a/components/dashboard/AttGoraSection.tsx b/components/dashboard/AttGoraSection.tsx index 6cb328fa..c7a04627 100644 --- a/components/dashboard/AttGoraSection.tsx +++ b/components/dashboard/AttGoraSection.tsx @@ -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>(new Set()) const [confirmingId, setConfirmingId] = useState(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({

{t('att_gora_title')}

- {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 })}

{allClear ? ( - + emptyLedger && !postedSinceLoad ? ( + + ) : ( + + ) ) : (
{bokforRows && ( diff --git a/components/dashboard/DashboardContent.tsx b/components/dashboard/DashboardContent.tsx index 2972dcbe..b99be018 100644 --- a/components/dashboard/DashboardContent.tsx +++ b/components/dashboard/DashboardContent.tsx @@ -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) && (
{/* 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} />
diff --git a/components/onboarding/NewUserChecklist.tsx b/components/onboarding/NewUserChecklist.tsx index 87d53549..d2cd2084 100644 --- a/components/onboarding/NewUserChecklist.tsx +++ b/components/onboarding/NewUserChecklist.tsx @@ -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) { + 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(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, @@ -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 (
@@ -119,7 +177,7 @@ export default function NewUserChecklist({ @@ -205,22 +266,58 @@ export default function NewUserChecklist({ marks={} > {t('step_skv_description')} + {vatLine?.kind === 'date' && ( + <> + {' '} + + {t('step_skv_next_vat', { date: formatDateLong(vatLine.dueDate) })} + + + )} + {vatLine?.kind === 'missing_period' && ( + <> + {' '} + + {t('step_skv_choose_period')} + + + )} + + )} + + {hasInbox && ( + ( + + )} + marks={ + + {hasWhatsApp ? t('step_receipts_channels') : t('step_receipts_channels_no_wa')} + + } + > + {t('step_receipts_description')} )} ( - )} diff --git a/lib/onboarding/__tests__/checklist.test.ts b/lib/onboarding/__tests__/checklist.test.ts new file mode 100644 index 00000000..59fab5e3 --- /dev/null +++ b/lib/onboarding/__tests__/checklist.test.ts @@ -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, + }) + }) +}) diff --git a/lib/onboarding/checklist.ts b/lib/onboarding/checklist.ts new file mode 100644 index 00000000..b54db7fd --- /dev/null +++ b/lib/onboarding/checklist.ts @@ -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 } +} diff --git a/messages/en.json b/messages/en.json index 5c9217e7..3b16ed75 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/messages/sv.json b/messages/sv.json index ff537f13..e6058cc1 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -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", diff --git a/types/index.ts b/types/index.ts index 75fb57db..3f8ee28c 100644 --- a/types/index.ts +++ b/types/index.ts @@ -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'