From 38b658205d136b5447ca25a442fa18409cbe3490 Mon Sep 17 00:00:00 2001 From: Jakob Wennberg <149234542+jakobwennberg@users.noreply.github.com> Date: Thu, 19 Mar 2026 17:05:39 +0100 Subject: [PATCH] feat: BFL-compliant descriptions, cancelled status, and setup gate (#58) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: include reversed entries in all reports (general ledger, trial balance, VAT, SIE, NE, INK2) Reversed entries (storno) must appear alongside their original posted entries in reports for a complete audit trail. Previously, filtering by status='posted' excluded them, causing discrepancies when corrections had been made. Co-Authored-By: Claude Opus 4.6 (1M context) * feat: semi-manual invoice payment booking with editable journal lines When marking an invoice as paid, users now see a dialog where they can: - Choose which bank/cash account the payment goes to (1910, 1920, 1930, etc.) - Review and edit the proposed journal entry lines before committing - The happy path remains fast — lines are pre-filled correctly Implementation: - Pure proposePaymentLines() function for line computation (accrual + cash) - PaymentBookingDialog with AccountCombobox, balance validation, date picker - API accepts optional custom lines, falls back to auto-generation without them - 18 tests (8 unit + 10 API) all passing Co-Authored-By: Claude Opus 4.6 (1M context) * fix: address Greptile review — validation fallback, balance check, error handling - P1: Return 400 on invalid body instead of silently falling back to auto-generated lines (split JSON parse from schema validation) - P1: Add server-side balance check for custom lines before committing (debit must equal credit, totalDebit > 0) - P2: Wrap PaymentBookingDialog init() in try/catch with toast on failure and auto-close instead of silent empty state - Add 2 new tests: unbalanced lines → 400, invalid schema → 400 Co-Authored-By: Claude Opus 4.6 (1M context) * fix: OAuth callback redirect for local dev and timeout resilience - Pass redirectUri dynamically from NEXT_PUBLIC_APP_URL so OAuth callbacks work on localhost (not just production) - Encode consentId/provider in OAuth state (base64url JSON) so the callback doesn't depend on session storage - Add skipAuth flag to extension API routes for OAuth callbacks (external provider redirects have no user session cookie) - Wrap AbortError in descriptive timeout messages in arcim-client - Make preview endpoint resilient to partial failures (company info and SIE fetch are individually non-blocking) - Simplify login page (remove unused magic link auth mode) Co-Authored-By: Claude Opus 4.6 (1M context) * fix: create journal entry before marking invoice as paid Move journal entry creation before the invoice status update so that if accounting fails, the invoice is not permanently marked paid without a corresponding entry. Previously the error was silently swallowed. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: update mark-paid tests for journal-first ordering Reorder mock queue to match new flow (settings before update), update failure test to expect 500 instead of silent success, add try-catch with proper error response in route handler. Co-Authored-By: Claude Opus 4.6 (1M context) * feat: add reverse charge VAT (ruta 20-32) and improve mobile UX across dashboard Add full reverse charge (omvänd skattskyldighet) support to the VAT declaration: - Map accounts 2614/2624/2634 to ruta 30/31/32 for self-assessed output VAT - Calculate purchase bases (ruta 20-24) from supplier invoices by supplier type - Include ruta 30-32 in ruta 49 formula and totalOutputVat summary - Display reverse charge section in reports UI and composition chart - Add comprehensive test coverage for all reverse charge scenarios Improve mobile UX across the app: - Convert nav drawer to bottom sheet with drag handle and safe area padding - Add mobile card layout for PaymentBookingDialog journal lines - Replace settings tab pills with dropdown selector on mobile - Make wizard step indicators responsive (collapsed on mobile) - Ensure all dialog footers stack buttons full-width on mobile - Add 44px minimum touch targets throughout - Make onboarding buttons full-width on mobile Co-Authored-By: Claude Opus 4.6 (1M context) * fix: address Greptile review — indentation, query efficiency, tab dedup - Fix misleading try-block indentation in mark-paid route - Filter reversed entries at DB level (.eq('status', 'posted')) instead of fetching then discarding in memory - Extract shared settingsTabs array so mobile Select and desktop TabsList stay in sync automatically Co-Authored-By: Claude Opus 4.6 (1M context) * feat: add resilience fallbacks, Arcim retry logic, and client tests Add FallbackPrompt component and integrate it across banking and migration error states so users always have a manual import escape hatch. Add retry with exponential backoff to Arcim API client for transient failures (429, 502, 503, 504) and timeouts. Expand import page deep-linking with ?mode= parameter. Add persistent error banner on settings page for bank connection failures. Include 18 new tests for the Arcim client covering retry, backoff, pagination, timeout, env validation, and singleton resource unwrapping. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: address Greptile review — setActiveTab, test cleanup, redundant clearTimeout - Add missing setActiveTab('banking') when handling bank_error query param so the error banner is actually visible (P1) - Guard env-var cleanup with try/finally in arcim-client tests to prevent state leakage on assertion failure (P2) - Only mock retry-range setTimeout delays in backoff test, letting AbortController timers pass through real setTimeout (P2) - Remove redundant clearTimeout in catch block — finally handles it (P2) Co-Authored-By: Claude Opus 4.6 (1M context) * feat: add BFL-compliant counterparty names to journal descriptions and cancelled entry status Journal descriptions now include customer/supplier names for traceability (e.g. "Kundfaktura 1001, Foretag AB"). Failed draft entries are marked as 'cancelled' instead of deleted, respecting immutability constraints. Includes DB migration for the new journal_entries status value. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: address Greptile review — Swedish typos, missing source type, trigger and reversal cleanup - Fix Swedish spelling: leverantor → leverantör in all supplier description prefixes - Add supplier_credit_note to supplierSourceTypes in VAT declaration so credit notes correctly reduce reverse-charge bases (ruta 20–24) - Mark orphaned concurrent reversals as cancelled instead of attempting deletion that the immutability trigger blocks - Allow posted → cancelled transition in trigger for orphaned reversal cleanup - Restrict cancelled entry line trigger to DELETE-only (block INSERT/UPDATE) Co-Authored-By: Claude Opus 4.6 (1M context) * fix: use main's Step3TaxRegistration (onboarding restructured in PR #54) Co-Authored-By: Claude Opus 4.6 (1M context) * chore: retrigger Greptile review Co-Authored-By: Claude Opus 4.6 (1M context) * feat: add TIC company lookup extension, extension nav items, and legacy toggle fallback Introduces the TIC (Bolagsuppgifter) extension for automatic company data lookup via org number during onboarding. Adds dynamic extension nav items in the sidebar, legacy general extension fallback for toggle checks, and company lookup type definitions in core. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: address Greptile review — restore push-notifications, filter nav by toggles, fix timeout error name - Restore push-notifications to LEGACY_GENERAL_EXTENSIONS (was silently dropped when extracting the shared constant) - Remove tic and arcim-migration from legacy defaults (new extensions should not default to enabled for all users) - Filter getExtensionNavItems() against user's enabled extensions so disabled extensions don't appear in the sidebar - Fix AbortSignal.timeout() error name check — Node.js throws TimeoutError, not AbortError Co-Authored-By: Claude Opus 4.6 (1M context) * fix: add all bundled extensions to legacy defaults (email, arcim-migration, tic) Bundled extensions configured in extensions.config.json should default to enabled. Adds email, arcim-migration, and tic alongside the existing legacy defaults so they are accessible without explicit toggle rows. Co-Authored-By: Claude Opus 4.6 (1M context) * feat: replace welcome checklist with full-page setup gate New users now see a full-page onboarding gate instead of a small dismissible card. The gate enforces a clear two-step sequential flow: (1) migrate bookkeeping from Fortnox/Bokio/Visma or import SIE file, (2) connect bank account. The gate blocks all dashboard content until the user has imported data or explicitly chooses "fresh start". Co-Authored-By: Claude Opus 4.6 (1M context) * fix: clean up journal lines after cancelling failed reversal entries When a reversal fails at the line-insert, post, or CAS stage, the entry is correctly marked as cancelled. Now also delete the attached lines to prevent stale data accumulation — the migration trigger allows DELETE on lines of cancelled entries for this purpose. Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- components/dashboard/DashboardContent.tsx | 39 +- components/onboarding/NewUserChecklist.tsx | 394 +++++++-------------- lib/bookkeeping/engine.ts | 3 + 3 files changed, 158 insertions(+), 278 deletions(-) diff --git a/components/dashboard/DashboardContent.tsx b/components/dashboard/DashboardContent.tsx index 1996fe9a..ea4e23fc 100644 --- a/components/dashboard/DashboardContent.tsx +++ b/components/dashboard/DashboardContent.tsx @@ -24,6 +24,8 @@ import { resolveIcon } from '@/lib/extensions/icon-resolver' import type { QuickActionDefinition } from '@/lib/extensions/types' import type { CompanySettings, Deadline, ReceiptQueueSummary, OnboardingProgress } from '@/types' +const SETUP_FRESH_START_KEY = 'erp_setup_fresh_start' + interface DashboardContentProps { firstName?: string | null settings: CompanySettings | null @@ -71,6 +73,31 @@ export default function DashboardContent({ firstName, settings, summary, onboard return () => window.removeEventListener('extension-toggle-changed', handler) }, []) + // Setup gate — blocks dashboard until user imports data or chooses fresh start + const needsSetup = onboardingProgress && !onboardingProgress.hasBankConnected && !onboardingProgress.hasSIEImport + const [setupGateActive, setSetupGateActive] = useState(!!needsSetup) + + useEffect(() => { + if (!needsSetup) { + setSetupGateActive(false) + return + } + const freshStart = localStorage.getItem(SETUP_FRESH_START_KEY) === 'true' + const oldDismissed = localStorage.getItem('erp_checklist_dismissed') === 'true' + if (freshStart || oldDismissed) setSetupGateActive(false) + }, [needsSetup]) + + if (setupGateActive) { + return ( + { + localStorage.setItem(SETUP_FRESH_START_KEY, 'true') + setSetupGateActive(false) + }} + /> + ) + } + const formatLargeNumber = (amount: number) => { return new Intl.NumberFormat('sv-SE', { style: 'decimal', @@ -264,18 +291,6 @@ export default function DashboardContent({ firstName, settings, summary, onboard - {/* New user checklist */} - {onboardingProgress && ( -
- -
- )} - {/* 4 Key Summary Cards */}
diff --git a/components/onboarding/NewUserChecklist.tsx b/components/onboarding/NewUserChecklist.tsx index a9da8179..20a1b766 100644 --- a/components/onboarding/NewUserChecklist.tsx +++ b/components/onboarding/NewUserChecklist.tsx @@ -1,298 +1,160 @@ 'use client' -import { useState, useEffect } from 'react' import Link from 'next/link' -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' -import { Button } from '@/components/ui/button' -import { Progress } from '@/components/ui/progress' import { - Check, - Circle, - PartyPopper, - Rocket, - X, - FileUp, ArrowRight, - UserPlus, - Receipt, - ArrowLeftRight, + FileText, + Landmark, + ArrowRightLeft, + ShieldCheck, } from 'lucide-react' import { cn } from '@/lib/utils' - -interface ChecklistItem { - id: string - label: string - description: string - href: string - completed: boolean - icon: React.ElementType -} +import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions' interface NewUserChecklistProps { - hasCustomers: boolean - hasInvoices: boolean - hasBankConnected: boolean - hasSIEImport: boolean - onDismiss?: () => void + onFreshStart: () => void className?: string } -const CHECKLIST_DISMISSED_KEY = 'erp_checklist_dismissed' -const FRESH_START_KEY = 'erp_onboarding_fresh' - export default function NewUserChecklist({ - hasCustomers, - hasInvoices, - hasBankConnected, - onDismiss, + onFreshStart, className, }: NewUserChecklistProps) { - const [isDismissed, setIsDismissed] = useState(true) - const [isAnimatingOut, setIsAnimatingOut] = useState(false) - const [showChecklist, setShowChecklist] = useState(false) + const hasMigration = ENABLED_EXTENSION_IDS.has('arcim-migration') + const hasBanking = ENABLED_EXTENSION_IDS.has('enable-banking') - useEffect(() => { - const dismissed = localStorage.getItem(CHECKLIST_DISMISSED_KEY) - setIsDismissed(dismissed === 'true') + return ( +
+
+ {/* Header */} +
+

+ Välkommen till gnubok +

+

+ Börja med att flytta din bokföring, sedan kopplar du banken. + Ingenting ändras i ditt nuvarande system. +

+
- const freshStart = localStorage.getItem(FRESH_START_KEY) - if (freshStart === 'true') { - setShowChecklist(true) - } - }, []) - - const handleFreshStart = () => { - setShowChecklist(true) - localStorage.setItem(FRESH_START_KEY, 'true') - } - - const handleDismiss = () => { - setIsAnimatingOut(true) - setTimeout(() => { - localStorage.setItem(CHECKLIST_DISMISSED_KEY, 'true') - setIsDismissed(true) - onDismiss?.() - }, 300) - } - - const items: ChecklistItem[] = [ - { - id: 'customer', - label: 'Lägg till din första kund', - description: 'Spara kunduppgifter för enkel fakturering', - href: '/customers/new', - completed: hasCustomers, - icon: UserPlus, - }, - { - id: 'invoice', - label: 'Skicka din första faktura', - description: 'Skapa en professionell faktura på 60 sekunder', - href: '/invoices/new', - completed: hasInvoices, - icon: Receipt, - }, - { - id: 'bank', - label: 'Importera transaktioner', - description: 'Koppla bank eller ladda upp kontoutdrag', - href: '/import', - completed: hasBankConnected, - icon: ArrowLeftRight, - }, - ] - - const completedCount = items.filter((item) => item.completed).length - const progress = (completedCount / items.length) * 100 - const allCompleted = completedCount === items.length - - if (isDismissed) { - return null - } - - // Celebration state when all checklist items done - if (showChecklist && allCompleted && !isAnimatingOut) { - return ( - - -
-
- -
-
-

Snyggt jobbat!

-

- Du har slutfört alla steg. Nu är du redo att köra! -

-
- + {/* Step 1: Migrate bookkeeping */} +
+
+ + 1 + +

+ Flytta din bokföring +

- - - ) - } - // Path selection — shown initially - if (!showChecklist) { - return ( - - -
-
- Välkommen till gnubok -

- Hur vill du komma igång? -

-
- -
-
- -
- {/* Migration path — routes directly to import/migration wizard */} +
+ {hasMigration && ( + +
+
+ +
+
+

+ Migrera från Fortnox, Bokio eller Visma +

+

+ Flytta bokföring, kunder, leverantörer och fakturor automatiskt. +

+
+ +
+ + )} + -
-
- +
+
+
-

- Migrera från annat system +

+ Importera SIE-fil

-

- Flytta bokföring, kunder, leverantörer och fakturor från Fortnox, Visma, Bokio, Björn Lundén eller Briox +

+ Exportera en SIE4-fil från ditt nuvarande bokföringsprogram och ladda upp den här.

- +
- - {/* Fresh start path */} - -
- - - ) - } - - // Fresh start checklist - return ( - - -
-
- Kom igång -

- {completedCount} av {items.length} steg klara -

-
-
- -
- -
- -
    - {items.map((item) => { - const Icon = item.icon - return ( -
  • - {item.completed ? ( -
    -
    - -
    - - {item.label} - -
    - ) : ( - -
    - -
    -
    -

    - - {item.label} -

    -

    - {item.description} -

    -
    - - )} -
  • - ) - })} -
-
-
+ + {/* Step 2: Connect bank */} +
+
+ + 2 + +

+ Koppla din bank +

+
+ +
+ +
+
+ +
+
+

+ Anslut ditt bankkonto +

+

+ {hasBanking + ? 'Koppla via PSD2 — transaktioner synkas automatiskt varje dag.' + : 'Importera kontoutdrag från din bank — CSV, OFX och de flesta svenska banker.'} +

+
+ +
+ +
+
+ + {/* Escape hatch */} +
+
+
+ eller +
+
+ +
+ +
+ +
+ +

+ Din data är krypterad och lagras säkert i Sverige +

+
+
+
+
) } diff --git a/lib/bookkeeping/engine.ts b/lib/bookkeeping/engine.ts index 85b079fd..c2befac6 100644 --- a/lib/bookkeeping/engine.ts +++ b/lib/bookkeeping/engine.ts @@ -379,6 +379,7 @@ export async function reverseEntry( if (linesError) { await supabase.from('journal_entries').update({ status: 'cancelled' }).eq('id', reversalEntry.id) + await supabase.from('journal_entry_lines').delete().eq('journal_entry_id', reversalEntry.id) throw new Error(`Failed to create reversal lines: ${linesError.message}`) } @@ -390,6 +391,7 @@ export async function reverseEntry( if (postError) { await supabase.from('journal_entries').update({ status: 'cancelled' }).eq('id', reversalEntry.id) + await supabase.from('journal_entry_lines').delete().eq('journal_entry_id', reversalEntry.id) throw new Error(`Failed to post reversal entry: ${postError.message}`) } @@ -408,6 +410,7 @@ export async function reverseEntry( // Another concurrent reversal already changed the status — mark the orphaned // reversal as cancelled so it's excluded from reports but remains traceable. await supabase.from('journal_entries').update({ status: 'cancelled' }).eq('id', reversalEntry.id) + await supabase.from('journal_entry_lines').delete().eq('journal_entry_id', reversalEntry.id) throw new Error('Entry was already reversed by a concurrent operation') }