From ceec8c02a8377c7df7916c4036ee1f3e9a1415f4 Mon Sep 17 00:00:00 2001 From: Jakob Wennberg <149234542+jakobwennberg@users.noreply.github.com> Date: Wed, 18 Mar 2026 10:42:57 +0100 Subject: [PATCH] feat: reverse charge VAT (ruta 20-32) + mobile UX improvements (#50) 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) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- app/(dashboard)/import/page.tsx | 17 +- app/(dashboard)/reports/page.tsx | 37 +- app/(dashboard)/settings/page.tsx | 68 +-- app/api/invoices/[id]/mark-paid/route.ts | 96 ++--- components/dashboard/DashboardNav.tsx | 124 +++--- .../general/ArcimMigrationWorkspace.tsx | 9 +- components/invoices/PaymentBookingDialog.tsx | 67 ++- components/onboarding/Step1EntityType.tsx | 3 +- components/onboarding/Step2CompanyDetails.tsx | 5 +- .../onboarding/Step3TaxRegistration.tsx | 5 +- components/reports/VatCompositionChart.tsx | 6 + components/transactions/InboxZeroState.tsx | 2 +- components/ui/confirmation-dialog.tsx | 3 +- components/ui/destructive-confirm-dialog.tsx | 10 +- components/ui/dialog.tsx | 2 +- lib/reports/__tests__/vat-declaration.test.ts | 394 +++++++++++++++++- lib/reports/vat-declaration.ts | 129 +++++- types/index.ts | 30 ++ 18 files changed, 824 insertions(+), 183 deletions(-) diff --git a/app/(dashboard)/import/page.tsx b/app/(dashboard)/import/page.tsx index 6552895c..157d1c1c 100644 --- a/app/(dashboard)/import/page.tsx +++ b/app/(dashboard)/import/page.tsx @@ -6,6 +6,7 @@ import { Progress } from '@/components/ui/progress' import { Button } from '@/components/ui/button' import { useToast } from '@/components/ui/use-toast' import { ArrowLeftRight, ArrowRightLeft, FileText, ArrowLeft, Landmark, Loader2 } from 'lucide-react' +import { cn } from '@/lib/utils' import { createClient } from '@/lib/supabase/client' import { BankSelector, type Bank } from '@/extensions/general/enable-banking/components/BankSelector' import { BankConnectionStatus } from '@/extensions/general/enable-banking/components/BankConnectionStatus' @@ -223,12 +224,16 @@ function BankFileImportWizard() {
+ + Steg {currentStepIndex + 1}/{steps.length}: {BANK_STEP_LABELS[bankStep]} + {steps.map((s, i) => ( {BANK_STEP_LABELS[s]} @@ -517,8 +522,14 @@ function SIEImportWizard() {
+ + Steg {currentStepIndex + 1}/{sieSteps.length}: {SIE_STEP_LABELS[step]} + {sieSteps.map((s, i) => ( - + {SIE_STEP_LABELS[s]} ))} diff --git a/app/(dashboard)/reports/page.tsx b/app/(dashboard)/reports/page.tsx index 216e7756..e14196d2 100644 --- a/app/(dashboard)/reports/page.tsx +++ b/app/(dashboard)/reports/page.tsx @@ -949,11 +949,34 @@ function VatDeclarationView() { Summa utgående - {formatAmount(data.rutor.ruta10 + data.rutor.ruta11 + data.rutor.ruta12)} kr + {formatAmount( + data.rutor.ruta10 + data.rutor.ruta11 + data.rutor.ruta12 + + data.rutor.ruta30 + data.rutor.ruta31 + data.rutor.ruta32 + )} kr
+ + {/* Omvänd skattskyldighet (inköp) */} + {(data.rutor.ruta20 > 0 || data.rutor.ruta21 > 0 || data.rutor.ruta22 > 0 || data.rutor.ruta23 > 0 || data.rutor.ruta24 > 0 || + data.rutor.ruta30 > 0 || data.rutor.ruta31 > 0 || data.rutor.ruta32 > 0) && ( + <> +

Omvänd skattskyldighet (inköp)

+
+ + + + + + + + + + +
+ + )}
{/* Ingående moms */} @@ -1056,12 +1079,14 @@ function VatRutaRow({ {ruta} {label} - {noVat ? '-' : `${formatAmount(amount)} kr`} - - - Underlag - {formatAmount(baseAmount)} kr + {noVat ? `${formatAmount(baseAmount)} kr` : `${formatAmount(amount)} kr`} + {!noVat && baseAmount > 0 && ( + + Underlag + {formatAmount(baseAmount)} kr + + )} ) } diff --git a/app/(dashboard)/settings/page.tsx b/app/(dashboard)/settings/page.tsx index f3aff86c..4e97cf58 100644 --- a/app/(dashboard)/settings/page.tsx +++ b/app/(dashboard)/settings/page.tsx @@ -10,6 +10,7 @@ import { Input } from '@/components/ui/input' import { Textarea } from '@/components/ui/textarea' import { Label } from '@/components/ui/label' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { useToast } from '@/components/ui/use-toast' import { Separator } from '@/components/ui/separator' import { @@ -60,6 +61,16 @@ export default function SettingsPage() { const [mounted, setMounted] = useState(false) const initialTab = searchParams.get('tab') || 'company' + const [activeTab, setActiveTab] = useState(initialTab) + + const settingsTabs = [ + { value: 'company', label: 'Företag', show: true }, + { value: 'banking', label: 'Bank (PSD2)', show: !settings?.is_sandbox }, + { value: 'calendar', label: 'Kalender', show: hasCalendarExtension }, + { value: 'security', label: 'Säkerhet', show: true }, + { value: 'appearance', label: 'Utseende', show: true }, + { value: 'account', label: 'Konto', show: true }, + ].filter(t => t.show) useEffect(() => { setMounted(true) @@ -159,9 +170,11 @@ export default function SettingsPage() { const formData = new FormData(e.currentTarget) + // Disabled inputs are excluded from FormData by the browser, + // so only include company_name/org_number when not locked const updates: Record = { - company_name: formData.get('company_name') as string, - org_number: formData.get('org_number') as string, + ...(formData.has('company_name') && { company_name: formData.get('company_name') as string }), + ...(formData.has('org_number') && { org_number: formData.get('org_number') as string }), address_line1: formData.get('address_line1') as string, postal_code: formData.get('postal_code') as string, city: formData.get('city') as string, @@ -290,30 +303,26 @@ export default function SettingsPage() {

- - - - Företag - - {!settings?.is_sandbox && ( - - Bank (PSD2) - - )} - {hasCalendarExtension && ( - - Kalender - - )} - - Säkerhet - - - Utseende - - - Konto - + + {/* Mobile: dropdown selector */} +
+ +
+ + {/* Desktop: tab pills */} + + {settingsTabs.map(t => ( + {t.label} + ))} {/* Company settings */} @@ -432,7 +441,7 @@ export default function SettingsPage() { -
+
-
-
- {/* Mobile menu drawer */} + {/* Mobile menu — bottom sheet */} {isMobileMenuOpen && ( <> {/* Backdrop */} @@ -327,39 +327,41 @@ export default function DashboardNav({ companyName, entityType, uncategorizedTra onClick={closeMobileMenu} aria-hidden="true" /> - {/* Drawer */} + {/* Bottom sheet */}
-
-
-

{companyName}

-

Meny

-
+ {/* Drag handle */} +
+
+
+ + {/* Header */} +
+

{companyName}

- {/* Grouped navigation */} -
- {/* Main section */} -
-

- Huvudmeny -

+ {/* Navigation */} +
+ {/* Main items */} +
{mainItems.map((item) => { const Icon = item.icon const active = isActive(item.href) @@ -369,51 +371,65 @@ export default function DashboardNav({ companyName, entityType, uncategorizedTra href={item.href} onClick={closeMobileMenu} className={cn( - 'flex items-center gap-3 px-3 py-2.5 rounded-lg transition-colors', + 'flex items-center gap-3 px-3 min-h-[44px] rounded-lg transition-colors', active ? 'bg-primary/10 text-primary font-medium' - : 'text-muted-foreground hover:bg-secondary/50 hover:text-foreground' + : 'text-foreground active:bg-muted/60' )} > - - {item.label} + + {item.label} ) })}
- {/* Finance section */} -
-

- Finans -

+ {/* Finans divider */} +
+ Finans +
+
+ + {/* Finance items */} +
{finansItems.map((item) => { const Icon = item.icon const active = isActive(item.href) + const badge = item.href === '/transactions' && uncategorizedTransactionCount > 0 + ? uncategorizedTransactionCount + : null return ( - - {item.label} + + {item.label} + {badge !== null && ( + + {badge > 99 ? '99+' : badge} + + )} ) })}
- {/* Other section */} -
-

- Övrigt -

+ {/* Övrigt divider */} +
+ Övrigt +
+
+ + {/* Other items */} +
{övrigtItems.map((item) => { const Icon = item.icon const active = isActive(item.href) @@ -423,33 +439,33 @@ export default function DashboardNav({ companyName, entityType, uncategorizedTra href={item.href} onClick={closeMobileMenu} className={cn( - 'flex items-center gap-3 px-3 py-2.5 rounded-lg transition-colors', + 'flex items-center gap-3 px-3 min-h-[44px] rounded-lg transition-colors', active ? 'bg-primary/10 text-primary font-medium' - : 'text-muted-foreground hover:bg-secondary/50 hover:text-foreground' + : 'text-foreground active:bg-muted/60' )} > - - {item.label} + + {item.label} ) })}
+
- {/* Logout */} -
- -
+ {/* Logout */} +
+
diff --git a/components/extensions/general/ArcimMigrationWorkspace.tsx b/components/extensions/general/ArcimMigrationWorkspace.tsx index 3dc7ecd0..976f3ad4 100644 --- a/components/extensions/general/ArcimMigrationWorkspace.tsx +++ b/components/extensions/general/ArcimMigrationWorkspace.tsx @@ -7,6 +7,7 @@ import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Switch } from '@/components/ui/switch' import { useToast } from '@/components/ui/use-toast' +import { cn } from '@/lib/utils' import { ConfirmationDialog } from '@/components/ui/confirmation-dialog' import Link from 'next/link' import { @@ -1513,10 +1514,16 @@ export default function ArcimMigrationWorkspace(_props: WorkspaceComponentProps)
+ + Steg {currentUserStepIndex + 1}/{userSteps.length}: {STEP_LABELS[step]} + {userSteps.map((s) => ( {STEP_LABELS[s]} diff --git a/components/invoices/PaymentBookingDialog.tsx b/components/invoices/PaymentBookingDialog.tsx index 51682bc6..e70e0c74 100644 --- a/components/invoices/PaymentBookingDialog.tsx +++ b/components/invoices/PaymentBookingDialog.tsx @@ -225,12 +225,71 @@ export default function PaymentBookingDialog({ type="date" value={paymentDate} onChange={(e) => setPaymentDate(e.target.value)} - className="w-48" + className="w-full sm:w-48" />
{/* Journal entry lines */} -
+ {/* Mobile card layout */} +
+ {lines.map((line, index) => ( +
+
+
+ updateLine(index, 'account_number', val)} + /> +
+ +
+
+
+ + updateLine(index, 'debit_amount', e.target.value)} + className="font-mono text-right" + inputMode="decimal" + /> +
+
+ + updateLine(index, 'credit_amount', e.target.value)} + className="font-mono text-right" + inputMode="decimal" + /> +
+
+
+ ))} + +
+ + {/* Desktop table layout */} +
{/* Header */}
Konto @@ -316,10 +375,10 @@ export default function PaymentBookingDialog({ )} - - diff --git a/components/onboarding/Step1EntityType.tsx b/components/onboarding/Step1EntityType.tsx index 75c0b391..d81282ac 100644 --- a/components/onboarding/Step1EntityType.tsx +++ b/components/onboarding/Step1EntityType.tsx @@ -104,11 +104,12 @@ export default function Step1EntityType({ initialData, onNext, isSaving }: Step1 })}
-
+
-
+
-
-
+
- {extraActions} - @@ -85,11 +86,10 @@ export function DestructiveConfirmDialog({ variant={variant === 'destructive' ? 'destructive' : 'default'} onClick={handleConfirm} disabled={isLoading} - className={ - variant === 'warning' - ? 'bg-warning hover:bg-warning/90 text-warning-foreground' - : undefined - } + className={cn( + 'min-h-11 w-full sm:w-auto', + variant === 'warning' && 'bg-warning hover:bg-warning/90 text-warning-foreground' + )} > {isLoading ? ( diff --git a/components/ui/dialog.tsx b/components/ui/dialog.tsx index e02e3c06..75045d53 100644 --- a/components/ui/dialog.tsx +++ b/components/ui/dialog.tsx @@ -72,7 +72,7 @@ const DialogFooter = ({ }: React.HTMLAttributes) => (
{ }) describe('getVatDeclarationSummary', () => { + const emptyRc = { ruta20: 0, ruta21: 0, ruta22: 0, ruta23: 0, ruta24: 0, ruta30: 0, ruta31: 0, ruta32: 0 } + it('calculates totals and detects payment', () => { const declaration: VatDeclaration = { period: { type: 'monthly', year: 2024, period: 1, start: '2024-01-01', end: '2024-01-31' }, rutor: { - ruta05: 10000, // domestic taxable sales - ruta06: 0, - ruta07: 0, - ruta10: 2500, // output VAT 25% - ruta11: 0, - ruta12: 0, - ruta39: 0, - ruta40: 0, - ruta48: 1000, - ruta49: 1500, // 2500 - 1000 + ruta05: 10000, ruta06: 0, ruta07: 0, + ruta10: 2500, ruta11: 0, ruta12: 0, + ruta20: 0, ruta21: 0, ruta22: 0, ruta23: 0, ruta24: 0, + ruta30: 0, ruta31: 0, ruta32: 0, + ruta39: 0, ruta40: 0, + ruta48: 1000, ruta49: 1500, }, invoiceCount: 5, transactionCount: 10, @@ -113,6 +111,7 @@ describe('getVatDeclarationSummary', () => { invoices: { ruta05: 10000, ruta06: 0, ruta07: 0, ruta10: 2500, ruta11: 0, ruta12: 0, ruta39: 0, ruta40: 0, base25: 10000, base12: 0, base6: 0 }, transactions: { ruta48: 1000 }, receipts: { ruta48: 0 }, + reverseCharge: emptyRc, }, } @@ -127,16 +126,12 @@ describe('getVatDeclarationSummary', () => { const declaration: VatDeclaration = { period: { type: 'monthly', year: 2024, period: 1, start: '2024-01-01', end: '2024-01-31' }, rutor: { - ruta05: 2000, // domestic taxable sales - ruta06: 0, - ruta07: 0, - ruta10: 500, // output VAT 25% - ruta11: 0, - ruta12: 0, - ruta39: 0, - ruta40: 0, - ruta48: 3000, - ruta49: -2500, // 500 - 3000 + ruta05: 2000, ruta06: 0, ruta07: 0, + ruta10: 500, ruta11: 0, ruta12: 0, + ruta20: 0, ruta21: 0, ruta22: 0, ruta23: 0, ruta24: 0, + ruta30: 0, ruta31: 0, ruta32: 0, + ruta39: 0, ruta40: 0, + ruta48: 3000, ruta49: -2500, }, invoiceCount: 1, transactionCount: 20, @@ -144,6 +139,7 @@ describe('getVatDeclarationSummary', () => { invoices: { ruta05: 2000, ruta06: 0, ruta07: 0, ruta10: 500, ruta11: 0, ruta12: 0, ruta39: 0, ruta40: 0, base25: 2000, base12: 0, base6: 0 }, transactions: { ruta48: 3000 }, receipts: { ruta48: 0 }, + reverseCharge: emptyRc, }, } @@ -151,6 +147,32 @@ describe('getVatDeclarationSummary', () => { expect(summary.isRefund).toBe(true) expect(summary.vatToPay).toBe(-2500) }) + + it('includes ruta30-32 in totalOutputVat', () => { + const declaration: VatDeclaration = { + period: { type: 'monthly', year: 2024, period: 1, start: '2024-01-01', end: '2024-01-31' }, + rutor: { + ruta05: 10000, ruta06: 0, ruta07: 0, + ruta10: 2500, ruta11: 0, ruta12: 0, + ruta20: 0, ruta21: 5000, ruta22: 0, ruta23: 0, ruta24: 0, + ruta30: 1250, ruta31: 0, ruta32: 0, + ruta39: 0, ruta40: 0, + ruta48: 2250, ruta49: 1500, + }, + invoiceCount: 2, + transactionCount: 0, + breakdown: { + invoices: { ruta05: 10000, ruta06: 0, ruta07: 0, ruta10: 2500, ruta11: 0, ruta12: 0, ruta39: 0, ruta40: 0, base25: 10000, base12: 0, base6: 0 }, + transactions: { ruta48: 0 }, + receipts: { ruta48: 0 }, + reverseCharge: { ruta20: 0, ruta21: 5000, ruta22: 0, ruta23: 0, ruta24: 0, ruta30: 1250, ruta31: 0, ruta32: 0 }, + }, + } + + const summary = getVatDeclarationSummary(declaration) + // totalOutputVat = ruta10 + ruta30 = 2500 + 1250 = 3750 + expect(summary.totalOutputVat).toBe(3750) + }) }) // ============================================================ @@ -158,14 +180,17 @@ describe('getVatDeclarationSummary', () => { // // Mock queue order per call: // [0] fetchAllRows: journal_entry_lines (VAT-relevant accounts) -// [1] entry counts: journal_entries source_type +// [1] fetchAllRows: journal_entries for reverse charge bases +// [2] (if rc entries found) fetchAllRows: supplier_invoices +// [N] entry counts: journal_entries source_type // ============================================================ describe('calculateVatDeclaration', () => { it('returns all zeros when no ledger lines exist', async () => { results = [ - { data: [], error: null }, - { data: [], error: null }, + { data: [], error: null }, // journal_entry_lines + { data: [], error: null }, // rc journal entries + { data: [], error: null }, // entry counts ] const result = await calculateVatDeclaration(supabase, 'user-1', 'monthly', 2024, 1) @@ -174,6 +199,9 @@ describe('calculateVatDeclaration', () => { expect(result.rutor.ruta10).toBe(0) expect(result.rutor.ruta11).toBe(0) expect(result.rutor.ruta12).toBe(0) + expect(result.rutor.ruta30).toBe(0) + expect(result.rutor.ruta31).toBe(0) + expect(result.rutor.ruta32).toBe(0) expect(result.rutor.ruta48).toBe(0) expect(result.rutor.ruta49).toBe(0) expect(result.invoiceCount).toBe(0) @@ -193,6 +221,7 @@ describe('calculateVatDeclaration', () => { ], error: null, }, + { data: [], error: null }, // rc journal entries { data: [{ source_type: 'invoice_created' }, { source_type: 'invoice_created' }], error: null }, ] @@ -220,6 +249,7 @@ describe('calculateVatDeclaration', () => { ], error: null, }, + { data: [], error: null }, // rc journal entries { data: [{ source_type: 'bank_transaction' }, { source_type: 'bank_transaction' }], error: null }, ] @@ -238,6 +268,7 @@ describe('calculateVatDeclaration', () => { ], error: null, }, + { data: [], error: null }, // rc journal entries { data: [], error: null }, ] @@ -256,6 +287,7 @@ describe('calculateVatDeclaration', () => { ], error: null, }, + { data: [], error: null }, // rc journal entries { data: [], error: null }, ] @@ -278,6 +310,7 @@ describe('calculateVatDeclaration', () => { ], error: null, }, + { data: [], error: null }, // rc journal entries { data: [{ source_type: 'invoice_created' }, { source_type: 'credit_note' }], error: null }, ] @@ -299,6 +332,7 @@ describe('calculateVatDeclaration', () => { ], error: null, }, + { data: [], error: null }, // rc journal entries { data: [], error: null }, ] @@ -319,6 +353,7 @@ describe('calculateVatDeclaration', () => { ], error: null, }, + { data: [], error: null }, // rc journal entries { data: [], error: null }, ] @@ -330,6 +365,7 @@ describe('calculateVatDeclaration', () => { it('accepts accountingMethod parameter for backward compatibility', async () => { results = [ { data: [], error: null }, + { data: [], error: null }, // rc journal entries { data: [], error: null }, ] @@ -356,6 +392,7 @@ describe('calculateVatDeclaration', () => { ], error: null, }, + { data: [], error: null }, // rc journal entries { data: [], error: null }, ] @@ -372,3 +409,314 @@ describe('calculateVatDeclaration', () => { expect(result.rutor.ruta49).toBe(2280) }) }) + +// ============================================================ +// Reverse charge (ruta 20-24, 30-32) tests +// ============================================================ + +describe('calculateVatDeclaration — reverse charge', () => { + it('maps 2614/2624/2634 credit balances to ruta30/31/32', async () => { + results = [ + { + data: [ + // Reverse charge output VAT accounts + { account_number: '2614', debit_amount: 0, credit_amount: 1250 }, + { account_number: '2624', debit_amount: 0, credit_amount: 120 }, + { account_number: '2634', debit_amount: 0, credit_amount: 60 }, + // Corresponding input VAT (2645) + { account_number: '2645', debit_amount: 1430, credit_amount: 0 }, + ], + error: null, + }, + { data: [], error: null }, // rc journal entries (no supplier invoices for base query) + { data: [], error: null }, // entry counts + ] + + const result = await calculateVatDeclaration(supabase, 'user-1', 'monthly', 2024, 1) + + expect(result.rutor.ruta30).toBe(1250) + expect(result.rutor.ruta31).toBe(120) + expect(result.rutor.ruta32).toBe(60) + expect(result.rutor.ruta48).toBe(1430) + // ruta49 = (0+0+0 + 1250+120+60) - 1430 = 0 + expect(result.rutor.ruta49).toBe(0) + }) + + it('includes ruta30-32 in ruta49 formula', async () => { + results = [ + { + data: [ + // Regular output VAT + { account_number: '2611', debit_amount: 0, credit_amount: 2500 }, + { account_number: '3001', debit_amount: 0, credit_amount: 10000 }, + // Reverse charge output VAT + { account_number: '2614', debit_amount: 0, credit_amount: 500 }, + // Input VAT (regular + calculated) + { account_number: '2641', debit_amount: 300, credit_amount: 0 }, + { account_number: '2645', debit_amount: 500, credit_amount: 0 }, + ], + error: null, + }, + { data: [], error: null }, // rc journal entries + { data: [], error: null }, // entry counts + ] + + const result = await calculateVatDeclaration(supabase, 'user-1', 'monthly', 2024, 1) + + expect(result.rutor.ruta10).toBe(2500) + expect(result.rutor.ruta30).toBe(500) + expect(result.rutor.ruta48).toBe(800) + // ruta49 = (2500 + 0 + 0 + 500 + 0 + 0) - 800 = 2200 + expect(result.rutor.ruta49).toBe(2200) + }) + + it('populates ruta21 for EU services reverse charge base', async () => { + results = [ + { + data: [ + { account_number: '2614', debit_amount: 0, credit_amount: 1250 }, + { account_number: '2645', debit_amount: 1250, credit_amount: 0 }, + ], + error: null, + }, + // rc journal entries — found a posted supplier invoice entry + { + data: [ + { id: 'je-1', source_id: 'si-1' }, + ], + error: null, + }, + // supplier_invoices lookup + { + data: [ + { + id: 'si-1', + supplier_id: 'sup-1', + reverse_charge: true, + is_credit_note: false, + subtotal_sek: null, + subtotal: 5000, + currency: 'SEK', + exchange_rate: null, + suppliers: { supplier_type: 'eu_business' }, + }, + ], + error: null, + }, + { data: [], error: null }, // entry counts + ] + + const result = await calculateVatDeclaration(supabase, 'user-1', 'monthly', 2024, 1) + + expect(result.rutor.ruta21).toBe(5000) + expect(result.rutor.ruta20).toBe(0) + expect(result.rutor.ruta22).toBe(0) + expect(result.rutor.ruta30).toBe(1250) + expect(result.breakdown.reverseCharge.ruta21).toBe(5000) + expect(result.breakdown.reverseCharge.ruta30).toBe(1250) + }) + + it('populates ruta22 for non-EU services reverse charge base', async () => { + results = [ + { + data: [ + { account_number: '2614', debit_amount: 0, credit_amount: 750 }, + { account_number: '2645', debit_amount: 750, credit_amount: 0 }, + ], + error: null, + }, + { + data: [ + { id: 'je-1', source_id: 'si-1' }, + ], + error: null, + }, + { + data: [ + { + id: 'si-1', + supplier_id: 'sup-1', + reverse_charge: true, + is_credit_note: false, + subtotal_sek: 3000, + subtotal: 300, + currency: 'USD', + exchange_rate: 10, + suppliers: { supplier_type: 'non_eu_business' }, + }, + ], + error: null, + }, + { data: [], error: null }, + ] + + const result = await calculateVatDeclaration(supabase, 'user-1', 'monthly', 2024, 1) + + // Uses subtotal_sek when available + expect(result.rutor.ruta22).toBe(3000) + expect(result.rutor.ruta21).toBe(0) + }) + + it('populates ruta24 for domestic reverse charge base', async () => { + results = [ + { + data: [ + { account_number: '2614', debit_amount: 0, credit_amount: 500 }, + { account_number: '2645', debit_amount: 500, credit_amount: 0 }, + ], + error: null, + }, + { + data: [ + { id: 'je-1', source_id: 'si-1' }, + ], + error: null, + }, + { + data: [ + { + id: 'si-1', + supplier_id: 'sup-1', + reverse_charge: true, + is_credit_note: false, + subtotal_sek: null, + subtotal: 2000, + currency: 'SEK', + exchange_rate: null, + suppliers: { supplier_type: 'swedish_business' }, + }, + ], + error: null, + }, + { data: [], error: null }, + ] + + const result = await calculateVatDeclaration(supabase, 'user-1', 'monthly', 2024, 1) + + expect(result.rutor.ruta24).toBe(2000) + expect(result.rutor.ruta21).toBe(0) + expect(result.rutor.ruta22).toBe(0) + }) + + it('returns zero ruta20-24 when no reverse charge entries exist', async () => { + results = [ + { + data: [ + { account_number: '2611', debit_amount: 0, credit_amount: 2500 }, + { account_number: '3001', debit_amount: 0, credit_amount: 10000 }, + ], + error: null, + }, + { data: [], error: null }, // no rc journal entries + { data: [], error: null }, + ] + + const result = await calculateVatDeclaration(supabase, 'user-1', 'monthly', 2024, 1) + + expect(result.rutor.ruta20).toBe(0) + expect(result.rutor.ruta21).toBe(0) + expect(result.rutor.ruta22).toBe(0) + expect(result.rutor.ruta23).toBe(0) + expect(result.rutor.ruta24).toBe(0) + }) + + it('credit notes reduce reverse charge bases', async () => { + results = [ + { + data: [ + // Original invoice RC VAT + { account_number: '2614', debit_amount: 0, credit_amount: 1250 }, + { account_number: '2645', debit_amount: 1250, credit_amount: 0 }, + // Credit note reversal + { account_number: '2614', debit_amount: 250, credit_amount: 0 }, + { account_number: '2645', debit_amount: 0, credit_amount: 250 }, + ], + error: null, + }, + { + data: [ + { id: 'je-1', source_id: 'si-1' }, + { id: 'je-2', source_id: 'si-2' }, + ], + error: null, + }, + { + data: [ + { + id: 'si-1', + supplier_id: 'sup-1', + reverse_charge: true, + is_credit_note: false, + subtotal_sek: null, + subtotal: 5000, + currency: 'SEK', + exchange_rate: null, + suppliers: { supplier_type: 'eu_business' }, + }, + { + id: 'si-2', + supplier_id: 'sup-1', + reverse_charge: true, + is_credit_note: true, + subtotal_sek: null, + subtotal: 1000, + currency: 'SEK', + exchange_rate: null, + suppliers: { supplier_type: 'eu_business' }, + }, + ], + error: null, + }, + { data: [], error: null }, + ] + + const result = await calculateVatDeclaration(supabase, 'user-1', 'monthly', 2024, 1) + + // 5000 - 1000 = 4000 net base for EU services + expect(result.rutor.ruta21).toBe(4000) + // Net RC output VAT: 1250 - 250 = 1000 + expect(result.rutor.ruta30).toBe(1000) + }) + + it('only includes posted journal entries for reverse charge bases (reversed filtered at DB level)', async () => { + // The query uses .eq('status', 'posted'), so reversed entries never appear + results = [ + { + data: [ + { account_number: '2614', debit_amount: 0, credit_amount: 1250 }, + { account_number: '2645', debit_amount: 1250, credit_amount: 0 }, + ], + error: null, + }, + { + data: [ + // Only posted entries returned by DB query + { id: 'je-1', source_id: 'si-1' }, + ], + error: null, + }, + { + data: [ + { + id: 'si-1', + supplier_id: 'sup-1', + reverse_charge: true, + is_credit_note: false, + subtotal_sek: null, + subtotal: 5000, + currency: 'SEK', + exchange_rate: null, + suppliers: { supplier_type: 'eu_business' }, + }, + ], + error: null, + }, + { data: [], error: null }, + ] + + const result = await calculateVatDeclaration(supabase, 'user-1', 'monthly', 2024, 1) + + // Only the posted entry's invoice (5000) should count + expect(result.rutor.ruta21).toBe(5000) + }) +}) diff --git a/lib/reports/vat-declaration.ts b/lib/reports/vat-declaration.ts index e91917dc..40076476 100644 --- a/lib/reports/vat-declaration.ts +++ b/lib/reports/vat-declaration.ts @@ -32,6 +32,10 @@ const ACCOUNT_RUTA: Record { + const result = { ruta20: 0, ruta21: 0, ruta22: 0, ruta23: 0, ruta24: 0 } + + // Step 1: Find journal entries from supplier invoices in this period + const supplierSourceTypes = [ + 'supplier_invoice_registered', + 'supplier_invoice_cash_payment', + ] + const entries = await fetchAllRows<{ + id: string + source_id: string + }>(({ from, to }) => + supabase + .from('journal_entries') + .select('id, source_id') + .eq('user_id', userId) + .in('source_type', supplierSourceTypes) + .eq('status', 'posted') + .gte('entry_date', start) + .lte('entry_date', end) + .range(from, to) + ) + + if (entries.length === 0) return result + + const sourceIds = [...new Set(entries.map(e => e.source_id).filter(Boolean))] + if (sourceIds.length === 0) return result + + // Step 2: Fetch supplier invoices that are reverse charge, with supplier type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const invoices = await fetchAllRows>(({ from, to }) => + supabase + .from('supplier_invoices') + .select('id, supplier_id, reverse_charge, is_credit_note, subtotal_sek, subtotal, currency, exchange_rate, suppliers!inner(supplier_type)') + .in('id', sourceIds) + .eq('reverse_charge', true) + .eq('user_id', userId) + .range(from, to) + ) + + if (invoices.length === 0) return result + + // Step 3: Sum tax bases by supplier type + for (const inv of invoices) { + // Use subtotal_sek if available, otherwise convert via exchange_rate + let baseSek: number + if (inv.subtotal_sek != null) { + baseSek = Number(inv.subtotal_sek) + } else if (inv.currency !== 'SEK' && inv.exchange_rate) { + baseSek = Math.round(Number(inv.subtotal) * Number(inv.exchange_rate) * 100) / 100 + } else { + baseSek = Number(inv.subtotal) + } + + // Credit notes reduce the base + if (inv.is_credit_note) baseSek = -baseSek + + // !inner join: Supabase returns the related row as an object (1-to-1 FK) + const supplier = Array.isArray(inv.suppliers) ? inv.suppliers[0] : inv.suppliers + const supplierType = supplier?.supplier_type as string + switch (supplierType) { + case 'eu_business': + result.ruta21 = round(result.ruta21 + baseSek) + break + case 'non_eu_business': + result.ruta22 = round(result.ruta22 + baseSek) + break + case 'swedish_business': + result.ruta24 = round(result.ruta24 + baseSek) + break + } + } + + return result +} + /** * Get a summary of the VAT declaration for display */ @@ -244,7 +366,10 @@ export function getVatDeclarationSummary(declaration: VatDeclaration): { const totalOutputVat = round( declaration.rutor.ruta10 + declaration.rutor.ruta11 + - declaration.rutor.ruta12 + declaration.rutor.ruta12 + + declaration.rutor.ruta30 + + declaration.rutor.ruta31 + + declaration.rutor.ruta32 ) const totalInputVat = declaration.rutor.ruta48 diff --git a/types/index.ts b/types/index.ts index ccfbd8a8..ba0f646a 100644 --- a/types/index.ts +++ b/types/index.ts @@ -1550,6 +1550,18 @@ export interface VatDeclarationRutor { ruta11: number // Utgående moms 12% ruta12: number // Utgående moms 6% + // Inköp vid omvänd skattskyldighet (reverse charge purchase bases) + ruta20: number // Inköp av varor från annat EU-land (unused, goods via Tullverket) + ruta21: number // Inköp av tjänster från annat EU-land + ruta22: number // Inköp av tjänster från land utanför EU + ruta23: number // Inköp av varor i Sverige (unused, construction reverse charge goods) + ruta24: number // Övriga inköp av tjänster i Sverige (domestic reverse charge) + + // Utgående moms omvänd skattskyldighet (self-assessed output VAT on reverse charge) + ruta30: number // Utgående moms 25% omvänd skattskyldighet + ruta31: number // Utgående moms 12% omvänd skattskyldighet + ruta32: number // Utgående moms 6% omvänd skattskyldighet + // EU och export ruta39: number // Försäljning av tjänster till annat EU-land (reverse charge) ruta40: number // Export utanför EU @@ -1596,6 +1608,16 @@ export interface VatDeclaration { receipts: { ruta48: number // Ingående moms from receipts } + reverseCharge: { + ruta20: number + ruta21: number + ruta22: number + ruta23: number + ruta24: number + ruta30: number + ruta31: number + ruta32: number + } } } @@ -1614,6 +1636,14 @@ export const VAT_RUTA_LABELS: Record = { ruta10: 'Utgående moms 25%', ruta11: 'Utgående moms 12%', ruta12: 'Utgående moms 6%', + ruta20: 'Inköp av varor från annat EU-land', + ruta21: 'Inköp av tjänster från annat EU-land', + ruta22: 'Inköp av tjänster från land utanför EU', + ruta23: 'Inköp av varor i Sverige', + ruta24: 'Övriga inköp av tjänster i Sverige', + ruta30: 'Utgående moms 25% (omvänd skattskyldighet)', + ruta31: 'Utgående moms 12% (omvänd skattskyldighet)', + ruta32: 'Utgående moms 6% (omvänd skattskyldighet)', ruta39: 'Försäljning av tjänster till EU-land', ruta40: 'Export utanför EU', ruta48: 'Ingående moms att dra av',