From a9bf24ce0b9cf5426af72d37a44e7140f8e0e2d4 Mon Sep 17 00:00:00 2001
From: Jakob Wennberg
Date: Tue, 3 Mar 2026 17:08:26 +0100
Subject: [PATCH] feat: enhance supplier invoices, entity-aware categorization,
and new tests
Expand supplier invoice module with overdue cron job, credit note journal
entries, and event emissions on approve/mark-paid/create flows. Add entity
type (EF/AB) awareness to transaction categorization UI and category
mapping logic. Add comprehensive tests for supplier-invoice-entries,
transaction-entries, and expanded API route coverage.
Co-Authored-By: Claude Opus 4.6
---
.claude/skills/docker-patterns/SKILL.md | 364 +++++++
app/(dashboard)/transactions/page.tsx | 2 +
.../[id]/approve/__tests__/route.test.ts | 35 +-
.../supplier-invoices/[id]/approve/route.ts | 16 +-
.../supplier-invoices/[id]/credit/route.ts | 17 +
.../[id]/mark-paid/__tests__/route.test.ts | 46 +
.../supplier-invoices/[id]/mark-paid/route.ts | 13 +
.../supplier-invoices/__tests__/route.test.ts | 42 +
app/api/supplier-invoices/route.ts | 10 +
.../transactions/CategoryExpandedDialog.tsx | 14 +-
.../transactions/JournalEntryPreview.tsx | 8 +-
components/transactions/QuickReviewDialog.tsx | 7 +-
.../transactions/SwipeCategorizationView.tsx | 12 +-
components/transactions/transaction-types.ts | 37 +-
.../__tests__/category-mapping.test.ts | 80 +-
.../supplier-invoice-entries.test.ts | 907 ++++++++++++++++++
.../__tests__/transaction-entries.test.ts | 524 ++++++++++
lib/bookkeeping/category-mapping.ts | 43 +-
lib/bookkeeping/client-account-names.ts | 2 +
lib/bookkeeping/supplier-invoice-entries.ts | 186 ++--
lib/events/types.ts | 5 +
lib/tax/expense-warnings.ts | 6 +-
...01000046_supplier_invoice_overdue_cron.sql | 30 +
23 files changed, 2293 insertions(+), 113 deletions(-)
create mode 100644 .claude/skills/docker-patterns/SKILL.md
create mode 100644 lib/bookkeeping/__tests__/supplier-invoice-entries.test.ts
create mode 100644 lib/bookkeeping/__tests__/transaction-entries.test.ts
create mode 100644 supabase/migrations/20240101000046_supplier_invoice_overdue_cron.sql
diff --git a/.claude/skills/docker-patterns/SKILL.md b/.claude/skills/docker-patterns/SKILL.md
new file mode 100644
index 00000000..c438c4ad
--- /dev/null
+++ b/.claude/skills/docker-patterns/SKILL.md
@@ -0,0 +1,364 @@
+---
+name: docker-patterns
+description: Docker and Docker Compose patterns for local development, container security, networking, volume strategies, and multi-service orchestration.
+origin: ECC
+---
+
+# Docker Patterns
+
+Docker and Docker Compose best practices for containerized development.
+
+## When to Activate
+
+- Setting up Docker Compose for local development
+- Designing multi-container architectures
+- Troubleshooting container networking or volume issues
+- Reviewing Dockerfiles for security and size
+- Migrating from local dev to containerized workflow
+
+## Docker Compose for Local Development
+
+### Standard Web App Stack
+
+```yaml
+# docker-compose.yml
+services:
+ app:
+ build:
+ context: .
+ target: dev # Use dev stage of multi-stage Dockerfile
+ ports:
+ - "3000:3000"
+ volumes:
+ - .:/app # Bind mount for hot reload
+ - /app/node_modules # Anonymous volume -- preserves container deps
+ environment:
+ - DATABASE_URL=postgres://postgres:postgres@db:5432/app_dev
+ - REDIS_URL=redis://redis:6379/0
+ - NODE_ENV=development
+ depends_on:
+ db:
+ condition: service_healthy
+ redis:
+ condition: service_started
+ command: npm run dev
+
+ db:
+ image: postgres:16-alpine
+ ports:
+ - "5432:5432"
+ environment:
+ POSTGRES_USER: postgres
+ POSTGRES_PASSWORD: postgres
+ POSTGRES_DB: app_dev
+ volumes:
+ - pgdata:/var/lib/postgresql/data
+ - ./scripts/init-db.sql:/docker-entrypoint-initdb.d/init.sql
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U postgres"]
+ interval: 5s
+ timeout: 3s
+ retries: 5
+
+ redis:
+ image: redis:7-alpine
+ ports:
+ - "6379:6379"
+ volumes:
+ - redisdata:/data
+
+ mailpit: # Local email testing
+ image: axllent/mailpit
+ ports:
+ - "8025:8025" # Web UI
+ - "1025:1025" # SMTP
+
+volumes:
+ pgdata:
+ redisdata:
+```
+
+### Development vs Production Dockerfile
+
+```dockerfile
+# Stage: dependencies
+FROM node:22-alpine AS deps
+WORKDIR /app
+COPY package.json package-lock.json ./
+RUN npm ci
+
+# Stage: dev (hot reload, debug tools)
+FROM node:22-alpine AS dev
+WORKDIR /app
+COPY --from=deps /app/node_modules ./node_modules
+COPY . .
+EXPOSE 3000
+CMD ["npm", "run", "dev"]
+
+# Stage: build
+FROM node:22-alpine AS build
+WORKDIR /app
+COPY --from=deps /app/node_modules ./node_modules
+COPY . .
+RUN npm run build && npm prune --production
+
+# Stage: production (minimal image)
+FROM node:22-alpine AS production
+WORKDIR /app
+RUN addgroup -g 1001 -S appgroup && adduser -S appuser -u 1001
+USER appuser
+COPY --from=build --chown=appuser:appgroup /app/dist ./dist
+COPY --from=build --chown=appuser:appgroup /app/node_modules ./node_modules
+COPY --from=build --chown=appuser:appgroup /app/package.json ./
+ENV NODE_ENV=production
+EXPOSE 3000
+HEALTHCHECK --interval=30s --timeout=3s CMD wget -qO- http://localhost:3000/health || exit 1
+CMD ["node", "dist/server.js"]
+```
+
+### Override Files
+
+```yaml
+# docker-compose.override.yml (auto-loaded, dev-only settings)
+services:
+ app:
+ environment:
+ - DEBUG=app:*
+ - LOG_LEVEL=debug
+ ports:
+ - "9229:9229" # Node.js debugger
+
+# docker-compose.prod.yml (explicit for production)
+services:
+ app:
+ build:
+ target: production
+ restart: always
+ deploy:
+ resources:
+ limits:
+ cpus: "1.0"
+ memory: 512M
+```
+
+```bash
+# Development (auto-loads override)
+docker compose up
+
+# Production
+docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
+```
+
+## Networking
+
+### Service Discovery
+
+Services in the same Compose network resolve by service name:
+```
+# From "app" container:
+postgres://postgres:postgres@db:5432/app_dev # "db" resolves to the db container
+redis://redis:6379/0 # "redis" resolves to the redis container
+```
+
+### Custom Networks
+
+```yaml
+services:
+ frontend:
+ networks:
+ - frontend-net
+
+ api:
+ networks:
+ - frontend-net
+ - backend-net
+
+ db:
+ networks:
+ - backend-net # Only reachable from api, not frontend
+
+networks:
+ frontend-net:
+ backend-net:
+```
+
+### Exposing Only What's Needed
+
+```yaml
+services:
+ db:
+ ports:
+ - "127.0.0.1:5432:5432" # Only accessible from host, not network
+ # Omit ports entirely in production -- accessible only within Docker network
+```
+
+## Volume Strategies
+
+```yaml
+volumes:
+ # Named volume: persists across container restarts, managed by Docker
+ pgdata:
+
+ # Bind mount: maps host directory into container (for development)
+ # - ./src:/app/src
+
+ # Anonymous volume: preserves container-generated content from bind mount override
+ # - /app/node_modules
+```
+
+### Common Patterns
+
+```yaml
+services:
+ app:
+ volumes:
+ - .:/app # Source code (bind mount for hot reload)
+ - /app/node_modules # Protect container's node_modules from host
+ - /app/.next # Protect build cache
+
+ db:
+ volumes:
+ - pgdata:/var/lib/postgresql/data # Persistent data
+ - ./scripts/init.sql:/docker-entrypoint-initdb.d/init.sql # Init scripts
+```
+
+## Container Security
+
+### Dockerfile Hardening
+
+```dockerfile
+# 1. Use specific tags (never :latest)
+FROM node:22.12-alpine3.20
+
+# 2. Run as non-root
+RUN addgroup -g 1001 -S app && adduser -S app -u 1001
+USER app
+
+# 3. Drop capabilities (in compose)
+# 4. Read-only root filesystem where possible
+# 5. No secrets in image layers
+```
+
+### Compose Security
+
+```yaml
+services:
+ app:
+ security_opt:
+ - no-new-privileges:true
+ read_only: true
+ tmpfs:
+ - /tmp
+ - /app/.cache
+ cap_drop:
+ - ALL
+ cap_add:
+ - NET_BIND_SERVICE # Only if binding to ports < 1024
+```
+
+### Secret Management
+
+```yaml
+# GOOD: Use environment variables (injected at runtime)
+services:
+ app:
+ env_file:
+ - .env # Never commit .env to git
+ environment:
+ - API_KEY # Inherits from host environment
+
+# GOOD: Docker secrets (Swarm mode)
+secrets:
+ db_password:
+ file: ./secrets/db_password.txt
+
+services:
+ db:
+ secrets:
+ - db_password
+
+# BAD: Hardcoded in image
+# ENV API_KEY=sk-proj-xxxxx # NEVER DO THIS
+```
+
+## .dockerignore
+
+```
+node_modules
+.git
+.env
+.env.*
+dist
+coverage
+*.log
+.next
+.cache
+docker-compose*.yml
+Dockerfile*
+README.md
+tests/
+```
+
+## Debugging
+
+### Common Commands
+
+```bash
+# View logs
+docker compose logs -f app # Follow app logs
+docker compose logs --tail=50 db # Last 50 lines from db
+
+# Execute commands in running container
+docker compose exec app sh # Shell into app
+docker compose exec db psql -U postgres # Connect to postgres
+
+# Inspect
+docker compose ps # Running services
+docker compose top # Processes in each container
+docker stats # Resource usage
+
+# Rebuild
+docker compose up --build # Rebuild images
+docker compose build --no-cache app # Force full rebuild
+
+# Clean up
+docker compose down # Stop and remove containers
+docker compose down -v # Also remove volumes (DESTRUCTIVE)
+docker system prune # Remove unused images/containers
+```
+
+### Debugging Network Issues
+
+```bash
+# Check DNS resolution inside container
+docker compose exec app nslookup db
+
+# Check connectivity
+docker compose exec app wget -qO- http://api:3000/health
+
+# Inspect network
+docker network ls
+docker network inspect _default
+```
+
+## Anti-Patterns
+
+```
+# BAD: Using docker compose in production without orchestration
+# Use Kubernetes, ECS, or Docker Swarm for production multi-container workloads
+
+# BAD: Storing data in containers without volumes
+# Containers are ephemeral -- all data lost on restart without volumes
+
+# BAD: Running as root
+# Always create and use a non-root user
+
+# BAD: Using :latest tag
+# Pin to specific versions for reproducible builds
+
+# BAD: One giant container with all services
+# Separate concerns: one process per container
+
+# BAD: Putting secrets in docker-compose.yml
+# Use .env files (gitignored) or Docker secrets
+```
diff --git a/app/(dashboard)/transactions/page.tsx b/app/(dashboard)/transactions/page.tsx
index 4615aa25..c268754e 100644
--- a/app/(dashboard)/transactions/page.tsx
+++ b/app/(dashboard)/transactions/page.tsx
@@ -567,6 +567,7 @@ export default function TransactionsPage() {
onCategorize={handleCategorize}
onMatchInvoice={handleMatchInvoice}
onClose={() => setShowSwipeView(false)}
+ entityType={entityType as import('@/types').EntityType}
/>
)
}
@@ -692,6 +693,7 @@ export default function TransactionsPage() {
categoryLabel={quickReviewLabel}
defaultAccount={quickReviewCategory ? getDefaultAccountForCategory(quickReviewCategory) : ''}
defaultVat={quickReviewCategory ? (getDefaultVatTreatmentForCategory(quickReviewCategory) ?? 'none') : 'none'}
+ entityType={entityType as import('@/types').EntityType}
onConfirm={handleQuickReviewConfirm}
/>
diff --git a/app/api/supplier-invoices/[id]/approve/__tests__/route.test.ts b/app/api/supplier-invoices/[id]/approve/__tests__/route.test.ts
index ce637037..c1666595 100644
--- a/app/api/supplier-invoices/[id]/approve/__tests__/route.test.ts
+++ b/app/api/supplier-invoices/[id]/approve/__tests__/route.test.ts
@@ -12,6 +12,12 @@ vi.mock('@/lib/supabase/server', () => ({
createClient: () => Promise.resolve(mockSupabase),
}))
+vi.mock('@/lib/init', () => ({
+ ensureInitialized: vi.fn(),
+}))
+
+import { eventBus } from '@/lib/events'
+
import { POST } from '../route'
describe('POST /api/supplier-invoices/[id]/approve', () => {
@@ -20,6 +26,7 @@ describe('POST /api/supplier-invoices/[id]/approve', () => {
beforeEach(() => {
vi.clearAllMocks()
reset()
+ eventBus.clear()
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
})
@@ -46,7 +53,7 @@ describe('POST /api/supplier-invoices/[id]/approve', () => {
})
it('returns 400 when invoice is not in registered status', async () => {
- enqueue({ data: { status: 'approved' }, error: null })
+ enqueue({ data: makeSupplierInvoice({ status: 'approved' }), error: null })
const request = createMockRequest('/api/supplier-invoices/inv-1/approve', { method: 'POST' })
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
@@ -60,8 +67,8 @@ describe('POST /api/supplier-invoices/[id]/approve', () => {
const invoice = makeSupplierInvoice({ id: 'inv-1', status: 'registered' })
const approvedInvoice = { ...invoice, status: 'approved' }
- // First call: fetch invoice status
- enqueue({ data: { status: 'registered' }, error: null })
+ // First call: fetch full invoice
+ enqueue({ data: invoice, error: null })
// Second call: update + select
enqueue({ data: approvedInvoice, error: null })
@@ -72,4 +79,26 @@ describe('POST /api/supplier-invoices/[id]/approve', () => {
expect(status).toBe(200)
expect(body.data).toEqual(approvedInvoice)
})
+
+ it('emits supplier_invoice.approved event', async () => {
+ const invoice = makeSupplierInvoice({ id: 'inv-1', status: 'registered' })
+ const approvedInvoice = { ...invoice, status: 'approved' }
+
+ enqueue({ data: invoice, error: null })
+ enqueue({ data: approvedInvoice, error: null })
+
+ const emitSpy = vi.spyOn(eventBus, 'emit')
+
+ const request = createMockRequest('/api/supplier-invoices/inv-1/approve', { method: 'POST' })
+ const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
+ const { status } = await parseJsonResponse(response)
+
+ expect(status).toBe(200)
+ expect(emitSpy).toHaveBeenCalledWith(
+ expect.objectContaining({
+ type: 'supplier_invoice.approved',
+ payload: expect.objectContaining({ userId: 'user-1' }),
+ })
+ )
+ })
})
diff --git a/app/api/supplier-invoices/[id]/approve/route.ts b/app/api/supplier-invoices/[id]/approve/route.ts
index 737f6948..30dd3c04 100644
--- a/app/api/supplier-invoices/[id]/approve/route.ts
+++ b/app/api/supplier-invoices/[id]/approve/route.ts
@@ -1,5 +1,10 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
+import { eventBus } from '@/lib/events'
+import { ensureInitialized } from '@/lib/init'
+import type { SupplierInvoice } from '@/types'
+
+ensureInitialized()
export async function POST(
_request: Request,
@@ -16,7 +21,7 @@ export async function POST(
const { data: invoice } = await supabase
.from('supplier_invoices')
- .select('status')
+ .select('*')
.eq('id', id)
.eq('user_id', user.id)
.single()
@@ -44,5 +49,14 @@ export async function POST(
return NextResponse.json({ error: error.message }, { status: 500 })
}
+ try {
+ await eventBus.emit({
+ type: 'supplier_invoice.approved',
+ payload: { supplierInvoice: data as SupplierInvoice, userId: user.id },
+ })
+ } catch {
+ // Non-blocking
+ }
+
return NextResponse.json({ data })
}
diff --git a/app/api/supplier-invoices/[id]/credit/route.ts b/app/api/supplier-invoices/[id]/credit/route.ts
index 607f9c74..bb7b5aba 100644
--- a/app/api/supplier-invoices/[id]/credit/route.ts
+++ b/app/api/supplier-invoices/[id]/credit/route.ts
@@ -1,8 +1,12 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
+import { eventBus } from '@/lib/events'
+import { ensureInitialized } from '@/lib/init'
import { createSupplierCreditNoteEntry } from '@/lib/bookkeeping/supplier-invoice-entries'
import type { SupplierInvoice, SupplierInvoiceItem, AccountingMethod } from '@/types'
+ensureInitialized()
+
export async function POST(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
@@ -133,6 +137,19 @@ export async function POST(
})
.eq('id', id)
+ try {
+ await eventBus.emit({
+ type: 'supplier_invoice.credited',
+ payload: {
+ supplierInvoice: original as SupplierInvoice,
+ creditNote: creditNote as SupplierInvoice,
+ userId: user.id,
+ },
+ })
+ } catch {
+ // Non-blocking
+ }
+
return NextResponse.json({
data: creditNote,
journal_entry_id: journalEntryId,
diff --git a/app/api/supplier-invoices/[id]/mark-paid/__tests__/route.test.ts b/app/api/supplier-invoices/[id]/mark-paid/__tests__/route.test.ts
index e0661b7f..4692cdb5 100644
--- a/app/api/supplier-invoices/[id]/mark-paid/__tests__/route.test.ts
+++ b/app/api/supplier-invoices/[id]/mark-paid/__tests__/route.test.ts
@@ -13,6 +13,10 @@ vi.mock('@/lib/supabase/server', () => ({
createClient: () => Promise.resolve(mockSupabase),
}))
+vi.mock('@/lib/init', () => ({
+ ensureInitialized: vi.fn(),
+}))
+
const mockCreateSupplierInvoicePaymentEntry = vi.fn()
const mockCreateSupplierInvoiceCashEntry = vi.fn()
vi.mock('@/lib/bookkeeping/supplier-invoice-entries', () => ({
@@ -22,6 +26,8 @@ vi.mock('@/lib/bookkeeping/supplier-invoice-entries', () => ({
mockCreateSupplierInvoiceCashEntry(...args),
}))
+import { eventBus } from '@/lib/events'
+
import { POST } from '../route'
describe('POST /api/supplier-invoices/[id]/mark-paid', () => {
@@ -30,6 +36,7 @@ describe('POST /api/supplier-invoices/[id]/mark-paid', () => {
beforeEach(() => {
vi.clearAllMocks()
reset()
+ eventBus.clear()
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
})
@@ -252,4 +259,43 @@ describe('POST /api/supplier-invoices/[id]/mark-paid', () => {
expect(body.success).toBe(true)
expect(body.journal_entry_id).toBeNull()
})
+
+ it('emits supplier_invoice.paid event', async () => {
+ const supplier = makeSupplier()
+ const invoice = makeSupplierInvoice({
+ id: 'si-1',
+ status: 'approved',
+ total: 10000,
+ remaining_amount: 10000,
+ paid_amount: 0,
+ supplier,
+ items: [],
+ })
+
+ enqueue({ data: invoice, error: null })
+ enqueue({ data: { accounting_method: 'accrual' }, error: null })
+ mockCreateSupplierInvoicePaymentEntry.mockResolvedValue({ id: 'je-1' })
+ enqueue({ data: null, error: null })
+ enqueue({ data: null, error: null })
+
+ const emitSpy = vi.spyOn(eventBus, 'emit')
+
+ const request = createMockRequest('/api/supplier-invoices/si-1/mark-paid', {
+ method: 'POST',
+ body: {},
+ })
+ const response = await POST(request, createMockRouteParams({ id: 'si-1' }))
+ const { status } = await parseJsonResponse(response)
+
+ expect(status).toBe(200)
+ expect(emitSpy).toHaveBeenCalledWith(
+ expect.objectContaining({
+ type: 'supplier_invoice.paid',
+ payload: expect.objectContaining({
+ userId: 'user-1',
+ paymentAmount: 10000,
+ }),
+ })
+ )
+ })
})
diff --git a/app/api/supplier-invoices/[id]/mark-paid/route.ts b/app/api/supplier-invoices/[id]/mark-paid/route.ts
index c6f92189..2efbf4f6 100644
--- a/app/api/supplier-invoices/[id]/mark-paid/route.ts
+++ b/app/api/supplier-invoices/[id]/mark-paid/route.ts
@@ -1,5 +1,7 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
+import { eventBus } from '@/lib/events'
+import { ensureInitialized } from '@/lib/init'
import {
createSupplierInvoicePaymentEntry,
createSupplierInvoiceCashEntry,
@@ -8,6 +10,8 @@ import { validateBody } from '@/lib/api/validate'
import { MarkSupplierInvoicePaidSchema } from '@/lib/api/schemas'
import type { SupplierInvoice, SupplierInvoiceItem } from '@/types'
+ensureInitialized()
+
export async function POST(
request: Request,
{ params }: { params: Promise<{ id: string }> }
@@ -125,6 +129,15 @@ export async function POST(
console.error('Failed to record payment:', paymentError)
}
+ try {
+ await eventBus.emit({
+ type: 'supplier_invoice.paid',
+ payload: { supplierInvoice: invoice as SupplierInvoice, paymentAmount, userId: user.id },
+ })
+ } catch {
+ // Non-blocking
+ }
+
return NextResponse.json({
success: true,
status: newStatus,
diff --git a/app/api/supplier-invoices/__tests__/route.test.ts b/app/api/supplier-invoices/__tests__/route.test.ts
index d138a037..09d93030 100644
--- a/app/api/supplier-invoices/__tests__/route.test.ts
+++ b/app/api/supplier-invoices/__tests__/route.test.ts
@@ -27,6 +27,8 @@ vi.mock('@/lib/bookkeeping/supplier-invoice-entries', () => ({
mockCreateSupplierInvoiceRegistrationEntry(...args),
}))
+import { eventBus } from '@/lib/events'
+
import { GET, POST } from '../route'
describe('GET /api/supplier-invoices', () => {
@@ -107,6 +109,7 @@ describe('POST /api/supplier-invoices', () => {
beforeEach(() => {
vi.clearAllMocks()
reset()
+ eventBus.clear()
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
})
@@ -192,6 +195,45 @@ describe('POST /api/supplier-invoices', () => {
expect(mockCreateSupplierInvoiceRegistrationEntry).toHaveBeenCalled()
})
+ it('emits supplier_invoice.registered event', async () => {
+ const supplier = makeSupplier({ id: VALID_UUID })
+ const createdInvoice = makeSupplierInvoice({ id: 'si-1' })
+
+ enqueue({ data: supplier, error: null })
+ enqueue({ data: 5 })
+ enqueue({ data: createdInvoice, error: null })
+ enqueue({ data: null, error: null })
+ enqueue({ data: { accounting_method: 'accrual' }, error: null })
+
+ mockCreateSupplierInvoiceRegistrationEntry.mockResolvedValue({ id: 'je-1' })
+ enqueue({ data: null, error: null })
+
+ const emitSpy = vi.spyOn(eventBus, 'emit')
+
+ const request = createMockRequest('/api/supplier-invoices', {
+ method: 'POST',
+ body: {
+ supplier_id: VALID_UUID,
+ supplier_invoice_number: 'LF-001',
+ invoice_date: '2024-06-01',
+ due_date: '2024-07-01',
+ items: [
+ { description: 'Material', quantity: 10, unit_price: 800, account_number: '4010', vat_rate: 0.25 },
+ ],
+ },
+ })
+ const response = await POST(request)
+ const { status } = await parseJsonResponse(response)
+
+ expect(status).toBe(200)
+ expect(emitSpy).toHaveBeenCalledWith(
+ expect.objectContaining({
+ type: 'supplier_invoice.registered',
+ payload: expect.objectContaining({ userId: 'user-1' }),
+ })
+ )
+ })
+
it('skips registration entry for cash method', async () => {
const supplier = makeSupplier({ id: VALID_UUID })
const createdInvoice = makeSupplierInvoice({ id: 'si-1' })
diff --git a/app/api/supplier-invoices/route.ts b/app/api/supplier-invoices/route.ts
index 478da92c..26474f26 100644
--- a/app/api/supplier-invoices/route.ts
+++ b/app/api/supplier-invoices/route.ts
@@ -1,5 +1,6 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
+import { eventBus } from '@/lib/events'
import { createSupplierInvoiceRegistrationEntry } from '@/lib/bookkeeping/supplier-invoice-entries'
import { ensureInitialized } from '@/lib/init'
import { validateBody } from '@/lib/api/validate'
@@ -185,6 +186,15 @@ export async function POST(request: Request) {
}
}
+ try {
+ await eventBus.emit({
+ type: 'supplier_invoice.registered',
+ payload: { supplierInvoice: invoice as SupplierInvoice, userId: user.id },
+ })
+ } catch {
+ // Non-blocking — event emission failure should not affect the response
+ }
+
return NextResponse.json({
data: {
...invoice,
diff --git a/components/transactions/CategoryExpandedDialog.tsx b/components/transactions/CategoryExpandedDialog.tsx
index 70ce1312..2d6c2e1c 100644
--- a/components/transactions/CategoryExpandedDialog.tsx
+++ b/components/transactions/CategoryExpandedDialog.tsx
@@ -96,11 +96,14 @@ export default function CategoryExpandedDialog({
key={cat.value}
variant="outline"
size="sm"
- className="justify-start text-xs"
+ className="justify-start text-xs h-auto py-1.5"
onClick={() => handleSelectCategory(cat.value)}
disabled={isProcessing}
>
- {cat.label}
+
+ {cat.label}
+ {cat.account && {cat.account}}
+
))}
@@ -113,11 +116,14 @@ export default function CategoryExpandedDialog({
key={cat.value}
variant="outline"
size="sm"
- className="justify-start text-xs"
+ className="justify-start text-xs h-auto py-1.5"
onClick={() => handleSelectCategory(cat.value)}
disabled={isProcessing}
>
- {cat.label}
+
+ {cat.label}
+ {cat.account && {cat.account}}
+
))}
diff --git a/components/transactions/JournalEntryPreview.tsx b/components/transactions/JournalEntryPreview.tsx
index f98b2029..247d7175 100644
--- a/components/transactions/JournalEntryPreview.tsx
+++ b/components/transactions/JournalEntryPreview.tsx
@@ -5,7 +5,7 @@ import { formatCurrency } from '@/lib/utils'
import { formatAccountWithName } from '@/lib/bookkeeping/client-account-names'
import { getVatRate, extractVatAmount, extractNetAmount } from '@/lib/bookkeeping/vat-entries'
import { getCategoryAccountMapping } from '@/lib/bookkeeping/category-mapping'
-import type { TransactionCategory, VatTreatment } from '@/types'
+import type { TransactionCategory, VatTreatment, EntityType } from '@/types'
interface PreviewLine {
side: 'debet' | 'kredit'
@@ -19,6 +19,7 @@ interface JournalEntryPreviewProps {
category?: TransactionCategory
vatTreatment?: VatTreatment | 'none'
accountOverride?: string
+ entityType?: EntityType
/** For template-based bookings — overrides category mapping */
templateDebitAccount?: string
templateCreditAccount?: string
@@ -31,6 +32,7 @@ export default function JournalEntryPreview({
category,
vatTreatment,
accountOverride,
+ entityType = 'enskild_firma',
templateDebitAccount,
templateCreditAccount,
templateVatRate,
@@ -57,7 +59,7 @@ export default function JournalEntryPreview({
if (!category) return result
const resolvedVat = vatTreatment === 'none' ? undefined : vatTreatment
- const mapping = getCategoryAccountMapping(category, amount, category !== 'private', 'enskild_firma', resolvedVat)
+ const mapping = getCategoryAccountMapping(category, amount, category !== 'private', entityType, resolvedVat)
const debitAccount = accountOverride && amount < 0 ? accountOverride : mapping.debitAccount
const creditAccount = accountOverride && amount > 0 ? accountOverride : mapping.creditAccount
@@ -91,7 +93,7 @@ export default function JournalEntryPreview({
}
return result
- }, [amount, category, vatTreatment, accountOverride, templateDebitAccount, templateCreditAccount, templateVatRate])
+ }, [amount, category, vatTreatment, accountOverride, entityType, templateDebitAccount, templateCreditAccount, templateVatRate])
if (lines.length === 0) return null
diff --git a/components/transactions/QuickReviewDialog.tsx b/components/transactions/QuickReviewDialog.tsx
index 24e9056b..a0d9f753 100644
--- a/components/transactions/QuickReviewDialog.tsx
+++ b/components/transactions/QuickReviewDialog.tsx
@@ -15,7 +15,7 @@ import type { UploadedFile } from '@/components/bookkeeping/DocumentUploadZone'
import VatTreatmentSelect from './VatTreatmentSelect'
import { VAT_TREATMENT_OPTIONS } from './transaction-types'
import type { TransactionWithInvoice } from './transaction-types'
-import type { TransactionCategory, VatTreatment, BASAccount } from '@/types'
+import type { TransactionCategory, VatTreatment, BASAccount, EntityType } from '@/types'
interface QuickReviewDialogProps {
open: boolean
@@ -25,6 +25,7 @@ interface QuickReviewDialogProps {
categoryLabel: string
defaultAccount: string
defaultVat: VatTreatment | 'none'
+ entityType?: EntityType
onConfirm: (
id: string,
category: TransactionCategory,
@@ -41,6 +42,7 @@ export default function QuickReviewDialog({
categoryLabel,
defaultAccount,
defaultVat,
+ entityType,
onConfirm,
}: QuickReviewDialogProps) {
const { toast } = useToast()
@@ -184,6 +186,7 @@ export default function QuickReviewDialog({
category={category}
vatTreatment={isLiabilityAccount ? 'none' : vatTreatment}
accountOverride={accountOverride}
+ entityType={entityType}
/>
{/* Account */}
@@ -220,7 +223,7 @@ export default function QuickReviewDialog({
className="text-xs text-primary hover:underline"
onClick={() => setShowVatDropdown(true)}
>
- Andra
+ Ändra
)}
diff --git a/components/transactions/SwipeCategorizationView.tsx b/components/transactions/SwipeCategorizationView.tsx
index cd43970a..ea2538fc 100644
--- a/components/transactions/SwipeCategorizationView.tsx
+++ b/components/transactions/SwipeCategorizationView.tsx
@@ -18,7 +18,7 @@ import type { UploadedFile } from '@/components/bookkeeping/DocumentUploadZone'
import { X, ArrowLeft, ArrowRight, Building, AlertTriangle, Check, FileText, Link2, Receipt as ReceiptIcon, SkipForward, Paperclip, ChevronDown, ChevronUp, MessageSquareText } from 'lucide-react'
import DescribeTransactionDialog from './DescribeTransactionDialog'
import { formatAccountWithName } from '@/lib/bookkeeping/client-account-names'
-import type { TransactionCategory, VatTreatment, BASAccount } from '@/types'
+import type { TransactionCategory, VatTreatment, BASAccount, EntityType } from '@/types'
import type { SuggestedCategory, SuggestedTemplate } from '@/lib/transactions/category-suggestions'
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
import type { TransactionWithInvoice, CategorizeHandler, MatchInvoiceHandler } from './transaction-types'
@@ -31,6 +31,7 @@ interface SwipeCategorizationViewProps {
onCategorize: CategorizeHandler
onMatchInvoice?: MatchInvoiceHandler
onClose: () => void
+ entityType?: EntityType
}
const expenseCategories = EXPENSE_CATEGORIES
@@ -43,6 +44,7 @@ export default function SwipeCategorizationView({
onCategorize,
onMatchInvoice,
onClose,
+ entityType,
}: SwipeCategorizationViewProps) {
const { toast } = useToast()
const [showAllCategories, setShowAllCategories] = useState(false)
@@ -337,7 +339,10 @@ export default function SwipeCategorizationView({
onClick={() => handleCategorySelect(cat.value)}
disabled={isProcessing}
>
- {cat.label}
+
+ {cat.label}
+ {cat.account && {cat.account}}
+
))}
@@ -408,6 +413,7 @@ export default function SwipeCategorizationView({
category={pendingCategory}
vatTreatment={isLiabilityAccount ? 'none' : vatTreatment}
accountOverride={accountOverride}
+ entityType={entityType}
/>
{/* Account override */}
@@ -444,7 +450,7 @@ export default function SwipeCategorizationView({
className="text-xs text-primary hover:underline"
onClick={() => setShowVatDropdown(true)}
>
- Andra
+ Ändra
)}
diff --git a/components/transactions/transaction-types.ts b/components/transactions/transaction-types.ts
index 1011e6e6..99b17f54 100644
--- a/components/transactions/transaction-types.ts
+++ b/components/transactions/transaction-types.ts
@@ -32,31 +32,32 @@ export type MatchInvoiceHandler = (
export interface CategoryOption {
value: TransactionCategory
label: string
+ account?: string
}
// Shared category arrays
export const EXPENSE_CATEGORIES: CategoryOption[] = [
- { value: 'expense_representation', label: 'Representation' },
- { value: 'expense_equipment', label: 'Utrustning' },
- { value: 'expense_software', label: 'Programvara' },
- { value: 'expense_consumables', label: 'Material' },
- { value: 'expense_travel', label: 'Resor' },
- { value: 'expense_office', label: 'Kontor' },
- { value: 'expense_vehicle', label: 'Bil & drivmedel' },
- { value: 'expense_telecom', label: 'Telefon & internet' },
- { value: 'expense_marketing', label: 'Marknadsföring' },
- { value: 'expense_professional_services', label: 'Konsulter' },
- { value: 'expense_education', label: 'Utbildning' },
- { value: 'expense_bank_fees', label: 'Bankavgift' },
- { value: 'expense_card_fees', label: 'Kortavgift' },
- { value: 'expense_currency_exchange', label: 'Valutaväxling' },
- { value: 'expense_other', label: 'Övrigt' },
+ { value: 'expense_representation', label: 'Representation', account: '6071' },
+ { value: 'expense_equipment', label: 'Utrustning', account: '5410' },
+ { value: 'expense_software', label: 'Programvara', account: '5420' },
+ { value: 'expense_consumables', label: 'Material', account: '5460' },
+ { value: 'expense_travel', label: 'Resor', account: '5800' },
+ { value: 'expense_office', label: 'Kontor', account: '6110' },
+ { value: 'expense_vehicle', label: 'Bil & drivmedel', account: '5611' },
+ { value: 'expense_telecom', label: 'Telefon & internet', account: '6200' },
+ { value: 'expense_marketing', label: 'Marknadsföring', account: '5910' },
+ { value: 'expense_professional_services', label: 'Konsulter', account: '6530' },
+ { value: 'expense_education', label: 'Utbildning', account: '6991' },
+ { value: 'expense_bank_fees', label: 'Bankavgift', account: '6570' },
+ { value: 'expense_card_fees', label: 'Kortavgift', account: '6570' },
+ { value: 'expense_currency_exchange', label: 'Valutaväxling', account: '7960' },
+ { value: 'expense_other', label: 'Övrigt', account: '6991' },
]
export const INCOME_CATEGORIES: CategoryOption[] = [
- { value: 'income_services', label: 'Tjänster' },
- { value: 'income_products', label: 'Produkter' },
- { value: 'income_other', label: 'Övrigt' },
+ { value: 'income_services', label: 'Tjänster', account: '3001' },
+ { value: 'income_products', label: 'Produkter', account: '3001' },
+ { value: 'income_other', label: 'Övrigt', account: '3900' },
]
export interface VatTreatmentOption {
diff --git a/lib/bookkeeping/__tests__/category-mapping.test.ts b/lib/bookkeeping/__tests__/category-mapping.test.ts
index 2309f63f..34fd29b5 100644
--- a/lib/bookkeeping/__tests__/category-mapping.test.ts
+++ b/lib/bookkeeping/__tests__/category-mapping.test.ts
@@ -7,7 +7,7 @@ import {
buildMappingResultFromCategory,
} from '../category-mapping'
import { makeTransaction } from '@/tests/helpers'
-import type { TransactionCategory } from '@/types'
+import type { TransactionCategory, VatTreatment } from '@/types'
describe('getCategoryAccountMapping', () => {
describe('income_products uses correct account', () => {
@@ -182,6 +182,7 @@ describe('getDefaultVatTreatmentForCategory', () => {
expect(getDefaultVatTreatmentForCategory('expense_bank_fees')).toBeNull()
expect(getDefaultVatTreatmentForCategory('expense_card_fees')).toBeNull()
expect(getDefaultVatTreatmentForCategory('expense_currency_exchange')).toBeNull()
+ expect(getDefaultVatTreatmentForCategory('expense_representation')).toBeNull()
})
it('returns null for private transactions', () => {
@@ -192,3 +193,80 @@ describe('getDefaultVatTreatmentForCategory', () => {
expect(getDefaultVatTreatmentForCategory('uncategorized')).toBeNull()
})
})
+
+describe('representation VAT (ML 8:9 — illegal since 2017)', () => {
+ it('getDefaultVatTreatmentForCategory returns null for representation', () => {
+ expect(getDefaultVatTreatmentForCategory('expense_representation')).toBeNull()
+ })
+
+ it('getCategoryAccountMapping has vatTreatment: null for representation', () => {
+ const result = getCategoryAccountMapping('expense_representation', -500, true)
+ expect(result.vatTreatment).toBeNull()
+ expect(result.vatDebitAccount).toBeNull()
+ })
+
+ it('buildMappingResultFromCategory generates no VAT lines for representation', () => {
+ const tx = makeTransaction({ amount: -500 })
+ const result = buildMappingResultFromCategory('expense_representation', tx, true)
+ expect(result.vat_lines).toHaveLength(0)
+ })
+})
+
+describe('income account resolves by VAT treatment', () => {
+ const cases: [VatTreatment, string][] = [
+ ['standard_25', '3001'],
+ ['reduced_12', '3002'],
+ ['reduced_6', '3003'],
+ ['export', '3305'],
+ ['reverse_charge', '3308'],
+ ['exempt', '3004'],
+ ]
+
+ it.each(cases)('income_services with %s maps to %s', (vat, expectedAccount) => {
+ const result = getCategoryAccountMapping('income_services', 1000, true, 'enskild_firma', vat)
+ expect(result.creditAccount).toBe(expectedAccount)
+ })
+
+ it.each(cases)('income_products with %s maps to %s', (vat, expectedAccount) => {
+ const result = getCategoryAccountMapping('income_products', 1000, true, 'enskild_firma', vat)
+ expect(result.creditAccount).toBe(expectedAccount)
+ })
+
+ it('income_other always returns 3900 regardless of VAT treatment', () => {
+ for (const vat of ['standard_25', 'reduced_12', 'reduced_6', 'export', 'reverse_charge', 'exempt'] as VatTreatment[]) {
+ const result = getCategoryAccountMapping('income_other', 1000, true, 'enskild_firma', vat)
+ expect(result.creditAccount).toBe('3900')
+ }
+ })
+
+ it('defaults to 3001 when no vatTreatment provided', () => {
+ const result = getCategoryAccountMapping('income_services', 1000, true)
+ expect(result.creditAccount).toBe('3001')
+ })
+})
+
+describe('private transaction accounts by entity type and direction', () => {
+ it('EF withdrawal (amount < 0) uses 2013', () => {
+ const result = getCategoryAccountMapping('private', -500, false, 'enskild_firma')
+ expect(result.debitAccount).toBe('2013')
+ expect(result.creditAccount).toBe('1930')
+ })
+
+ it('EF deposit (amount > 0) uses 2018', () => {
+ const result = getCategoryAccountMapping('private', 500, false, 'enskild_firma')
+ expect(result.debitAccount).toBe('1930')
+ expect(result.creditAccount).toBe('2018')
+ })
+
+ it('AB uses 2893 for both withdrawal and deposit', () => {
+ const withdrawal = getCategoryAccountMapping('private', -500, false, 'aktiebolag')
+ expect(withdrawal.debitAccount).toBe('2893')
+
+ const deposit = getCategoryAccountMapping('private', 500, false, 'aktiebolag')
+ expect(deposit.creditAccount).toBe('2893')
+ })
+
+ it('getDefaultAccountForCategory still returns 2013 for EF (default/withdrawal account)', () => {
+ expect(getDefaultAccountForCategory('private', 'enskild_firma')).toBe('2013')
+ })
+})
diff --git a/lib/bookkeeping/__tests__/supplier-invoice-entries.test.ts b/lib/bookkeeping/__tests__/supplier-invoice-entries.test.ts
new file mode 100644
index 00000000..0e98501d
--- /dev/null
+++ b/lib/bookkeeping/__tests__/supplier-invoice-entries.test.ts
@@ -0,0 +1,907 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import type { SupplierInvoiceItem, CreateJournalEntryLineInput, CreateJournalEntryInput } from '@/types'
+import { makeSupplierInvoice } from '@/tests/helpers'
+
+// Mock engine
+vi.mock('../engine', () => ({
+ findFiscalPeriod: vi.fn().mockResolvedValue('period-1'),
+ createJournalEntry: vi.fn().mockImplementation(
+ async (_supabase: unknown, _userId: string, input: CreateJournalEntryInput) => ({
+ id: 'entry-1',
+ ...input,
+ lines: input.lines,
+ })
+ ),
+}))
+
+// Mock currency-utils with real logic
+vi.mock('../currency-utils', () => ({
+ resolveSekAmount: vi.fn().mockImplementation(
+ (amount: number, amountSek: number | null, currency: string | null, exchangeRate: number | null) => {
+ if (!currency || currency === 'SEK') return amount
+ if (amountSek != null) return Math.round(amountSek * 100) / 100
+ if (exchangeRate != null && exchangeRate > 0) return Math.round(amount * exchangeRate * 100) / 100
+ return amount
+ }
+ ),
+ buildCurrencyMetadata: vi.fn().mockImplementation(
+ (currency: string | null, amountInCurrency: number | null | undefined, exchangeRate: number | null) => {
+ if (!currency || currency === 'SEK') return {}
+ return {
+ ...(currency ? { currency } : {}),
+ ...(amountInCurrency != null ? { amount_in_currency: amountInCurrency } : {}),
+ ...(exchangeRate != null && exchangeRate > 0 ? { exchange_rate: exchangeRate } : {}),
+ }
+ }
+ ),
+}))
+
+// Mock vat-entries with real reverse charge logic
+vi.mock('../vat-entries', () => ({
+ generateReverseChargeLines: vi.fn().mockImplementation(
+ (baseAmount: number, vatRate: number = 0.25) => {
+ const vatAmount = Math.round(baseAmount * vatRate * 100) / 100
+ let outputAccount: string
+ switch (vatRate) {
+ case 0.12: outputAccount = '2624'; break
+ case 0.06: outputAccount = '2634'; break
+ default: outputAccount = '2614'; break
+ }
+ return [
+ { account_number: '2645', debit_amount: vatAmount, credit_amount: 0, line_description: `Fiktiv ingående moms ${vatRate * 100}% (omvänd skattskyldighet)` },
+ { account_number: outputAccount, debit_amount: 0, credit_amount: vatAmount, line_description: `Fiktiv utgående moms ${vatRate * 100}% (omvänd skattskyldighet)` },
+ ]
+ }
+ ),
+}))
+
+const { createJournalEntry, findFiscalPeriod } = await import('../engine')
+const mockedCreateEntry = vi.mocked(createJournalEntry)
+const mockedFindFiscalPeriod = vi.mocked(findFiscalPeriod)
+
+const {
+ createSupplierInvoiceRegistrationEntry,
+ createSupplierInvoicePaymentEntry,
+ createSupplierInvoiceCashEntry,
+ createSupplierCreditNoteEntry,
+} = await import('../supplier-invoice-entries')
+
+function makeItem(overrides: Partial = {}): SupplierInvoiceItem {
+ return {
+ id: 'si-item-1',
+ supplier_invoice_id: 'si-1',
+ sort_order: 0,
+ description: 'Consulting services',
+ quantity: 1,
+ unit: 'st',
+ unit_price: 8000,
+ line_total: 8000,
+ account_number: '6200',
+ vat_code: null,
+ vat_rate: 0.25,
+ vat_amount: 2000,
+ created_at: '2024-06-01T00:00:00Z',
+ ...overrides,
+ }
+}
+
+function findByAccount(lines: CreateJournalEntryLineInput[], account: string) {
+ return lines.filter((l) => l.account_number === account)
+}
+
+/** Balance check helper */
+function assertBalanced(input: CreateJournalEntryInput) {
+ const totalDebit = input.lines.reduce((sum, l) => sum + l.debit_amount, 0)
+ const totalCredit = input.lines.reduce((sum, l) => sum + l.credit_amount, 0)
+ expect(Math.round(totalDebit * 100)).toBe(Math.round(totalCredit * 100))
+ expect(totalDebit).toBeGreaterThan(0)
+}
+
+// ============================================================
+// createSupplierInvoiceRegistrationEntry
+// ============================================================
+
+describe('createSupplierInvoiceRegistrationEntry', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockedFindFiscalPeriod.mockResolvedValue('period-1')
+ })
+
+ it('returns null when no fiscal period found', async () => {
+ mockedFindFiscalPeriod.mockResolvedValue(null)
+ const invoice = makeSupplierInvoice()
+ const items = [makeItem()]
+
+ const result = await createSupplierInvoiceRegistrationEntry(
+ null as never, 'user-1', invoice, items, 'swedish_business'
+ )
+
+ expect(result).toBeNull()
+ expect(mockedCreateEntry).not.toHaveBeenCalled()
+ })
+
+ it('creates domestic entry with VAT (D expense + D 2641 + C 2440)', async () => {
+ const invoice = makeSupplierInvoice({
+ subtotal: 8000,
+ vat_amount: 2000,
+ total: 10000,
+ })
+ const items = [makeItem({ line_total: 8000, account_number: '6200', vat_rate: 0.25 })]
+
+ await createSupplierInvoiceRegistrationEntry(
+ null as never, 'user-1', invoice, items, 'swedish_business'
+ )
+
+ expect(mockedCreateEntry).toHaveBeenCalledOnce()
+ const input = mockedCreateEntry.mock.calls[0][2]
+
+ const debit6200 = findByAccount(input.lines, '6200')
+ expect(debit6200).toHaveLength(1)
+ expect(debit6200[0].debit_amount).toBe(8000)
+
+ const debit2641 = findByAccount(input.lines, '2641')
+ expect(debit2641).toHaveLength(1)
+ expect(debit2641[0].debit_amount).toBe(2000) // 8000 * 0.25
+
+ const credit2440 = findByAccount(input.lines, '2440')
+ expect(credit2440).toHaveLength(1)
+ expect(credit2440[0].credit_amount).toBe(10000) // 8000 + 2000
+
+ assertBalanced(input)
+ })
+
+ it('creates domestic entry with zero VAT (no 2641 line)', async () => {
+ const invoice = makeSupplierInvoice({
+ subtotal: 5000,
+ vat_amount: 0,
+ total: 5000,
+ })
+ const items = [makeItem({ line_total: 5000, account_number: '5410', vat_rate: 0 })]
+
+ await createSupplierInvoiceRegistrationEntry(
+ null as never, 'user-1', invoice, items, 'swedish_business'
+ )
+
+ const input = mockedCreateEntry.mock.calls[0][2]
+
+ const debit5410 = findByAccount(input.lines, '5410')
+ expect(debit5410).toHaveLength(1)
+ expect(debit5410[0].debit_amount).toBe(5000)
+
+ const credit2440 = findByAccount(input.lines, '2440')
+ expect(credit2440[0].credit_amount).toBe(5000)
+
+ expect(findByAccount(input.lines, '2641')).toHaveLength(0)
+
+ assertBalanced(input)
+ })
+
+ it('creates EU reverse charge entry at 25%', async () => {
+ const invoice = makeSupplierInvoice({
+ subtotal: 10000,
+ vat_amount: 0,
+ total: 10000,
+ reverse_charge: true,
+ })
+ const items = [makeItem({ line_total: 10000, account_number: '6540', vat_rate: 0.25 })]
+
+ await createSupplierInvoiceRegistrationEntry(
+ null as never, 'user-1', invoice, items, 'eu_business'
+ )
+
+ const input = mockedCreateEntry.mock.calls[0][2]
+
+ const debit6540 = findByAccount(input.lines, '6540')
+ expect(debit6540[0].debit_amount).toBe(10000)
+
+ const debit2645 = findByAccount(input.lines, '2645')
+ expect(debit2645).toHaveLength(1)
+ expect(debit2645[0].debit_amount).toBe(2500) // 10000 * 0.25
+
+ const credit2614 = findByAccount(input.lines, '2614')
+ expect(credit2614).toHaveLength(1)
+ expect(credit2614[0].credit_amount).toBe(2500)
+
+ const credit2440 = findByAccount(input.lines, '2440')
+ // 2440 = totalDebits - totalCredits = (10000 + 2500) - 2500 = 10000
+ // The fiktiv moms (D 2645 / C 2614) are offsetting; 2440 only reflects actual supplier debt
+ expect(credit2440[0].credit_amount).toBe(10000)
+
+ assertBalanced(input)
+ })
+
+ it('creates EU reverse charge entry at reduced 12%', async () => {
+ const invoice = makeSupplierInvoice({
+ subtotal: 5000,
+ vat_amount: 0,
+ total: 5000,
+ reverse_charge: true,
+ })
+ const items = [makeItem({ line_total: 5000, account_number: '6540', vat_rate: 0.12 })]
+
+ await createSupplierInvoiceRegistrationEntry(
+ null as never, 'user-1', invoice, items, 'eu_business'
+ )
+
+ const input = mockedCreateEntry.mock.calls[0][2]
+
+ const debit2645 = findByAccount(input.lines, '2645')
+ expect(debit2645[0].debit_amount).toBe(600) // 5000 * 0.12
+
+ const credit2624 = findByAccount(input.lines, '2624')
+ expect(credit2624).toHaveLength(1)
+ expect(credit2624[0].credit_amount).toBe(600)
+
+ assertBalanced(input)
+ })
+
+ it('handles multi-item with different accounts', async () => {
+ const invoice = makeSupplierInvoice({
+ subtotal: 8000,
+ vat_amount: 2000,
+ total: 10000,
+ })
+ const items = [
+ makeItem({ id: 'item-1', line_total: 3000, account_number: '5410', vat_rate: 0.25 }),
+ makeItem({ id: 'item-2', line_total: 5000, account_number: '6200', vat_rate: 0.25 }),
+ ]
+
+ await createSupplierInvoiceRegistrationEntry(
+ null as never, 'user-1', invoice, items, 'swedish_business'
+ )
+
+ const input = mockedCreateEntry.mock.calls[0][2]
+
+ const debit5410 = findByAccount(input.lines, '5410')
+ expect(debit5410[0].debit_amount).toBe(3000)
+
+ const debit6200 = findByAccount(input.lines, '6200')
+ expect(debit6200[0].debit_amount).toBe(5000)
+
+ const debit2641 = findByAccount(input.lines, '2641')
+ expect(debit2641[0].debit_amount).toBe(2000) // (3000 + 5000) * 0.25
+
+ assertBalanced(input)
+ })
+
+ it('aggregates multi-item with same account', async () => {
+ const invoice = makeSupplierInvoice({
+ subtotal: 5000,
+ vat_amount: 1250,
+ total: 6250,
+ })
+ const items = [
+ makeItem({ id: 'item-1', line_total: 3000, account_number: '6200', vat_rate: 0.25 }),
+ makeItem({ id: 'item-2', line_total: 2000, account_number: '6200', vat_rate: 0.25 }),
+ ]
+
+ await createSupplierInvoiceRegistrationEntry(
+ null as never, 'user-1', invoice, items, 'swedish_business'
+ )
+
+ const input = mockedCreateEntry.mock.calls[0][2]
+
+ const lines6200 = findByAccount(input.lines, '6200')
+ expect(lines6200).toHaveLength(1)
+ expect(lines6200[0].debit_amount).toBe(5000) // 3000 + 2000
+
+ assertBalanced(input)
+ })
+
+ it('creates per-rate 2641 lines for mixed-rate domestic invoice', async () => {
+ const invoice = makeSupplierInvoice({
+ subtotal: 18000,
+ vat_amount: 3280,
+ total: 21280,
+ })
+ const items = [
+ makeItem({ id: 'item-1', account_number: '4010', line_total: 10000, vat_rate: 0.25 }),
+ makeItem({ id: 'item-2', account_number: '5410', line_total: 5000, vat_rate: 0.12 }),
+ makeItem({ id: 'item-3', account_number: '6200', line_total: 3000, vat_rate: 0.06 }),
+ ]
+
+ await createSupplierInvoiceRegistrationEntry(
+ null as never, 'user-1', invoice, items, 'swedish_business'
+ )
+
+ const input = mockedCreateEntry.mock.calls[0][2]
+ const vat2641 = findByAccount(input.lines, '2641')
+ expect(vat2641).toHaveLength(3)
+
+ // 25%: 10000 * 0.25 = 2500
+ expect(vat2641.find((l) => l.line_description.includes('25%'))?.debit_amount).toBe(2500)
+ // 12%: 5000 * 0.12 = 600
+ expect(vat2641.find((l) => l.line_description.includes('12%'))?.debit_amount).toBe(600)
+ // 6%: 3000 * 0.06 = 180
+ expect(vat2641.find((l) => l.line_description.includes('6%'))?.debit_amount).toBe(180)
+
+ assertBalanced(input)
+ })
+
+ it('adds foreign currency metadata on 2440 line', async () => {
+ const invoice = makeSupplierInvoice({
+ currency: 'EUR',
+ exchange_rate: 11.50,
+ subtotal: 800,
+ vat_amount: 0,
+ total: 800,
+ })
+ const items = [makeItem({ line_total: 800, account_number: '6200', vat_rate: 0 })]
+
+ await createSupplierInvoiceRegistrationEntry(
+ null as never, 'user-1', invoice, items, 'swedish_business'
+ )
+
+ const input = mockedCreateEntry.mock.calls[0][2]
+ const credit2440 = findByAccount(input.lines, '2440')[0]
+ expect(credit2440.currency).toBe('EUR')
+ expect(credit2440.amount_in_currency).toBe(800)
+ expect(credit2440.exchange_rate).toBe(11.50)
+ })
+
+ it('sets source_type to supplier_invoice_registered', async () => {
+ const invoice = makeSupplierInvoice({ id: 'si-xyz' })
+ const items = [makeItem()]
+
+ await createSupplierInvoiceRegistrationEntry(
+ null as never, 'user-1', invoice, items, 'swedish_business'
+ )
+
+ const input = mockedCreateEntry.mock.calls[0][2]
+ expect(input.source_type).toBe('supplier_invoice_registered')
+ expect(input.source_id).toBe('si-xyz')
+ })
+
+ it('description includes invoice number and arrival number', async () => {
+ const invoice = makeSupplierInvoice({
+ supplier_invoice_number: 'LF-999',
+ arrival_number: 42,
+ })
+ const items = [makeItem()]
+
+ await createSupplierInvoiceRegistrationEntry(
+ null as never, 'user-1', invoice, items, 'swedish_business'
+ )
+
+ const input = mockedCreateEntry.mock.calls[0][2]
+ expect(input.description).toContain('LF-999')
+ expect(input.description).toContain('42')
+ })
+
+ it('handles non-EU reverse charge (services)', async () => {
+ const invoice = makeSupplierInvoice({
+ subtotal: 5000,
+ vat_amount: 0,
+ total: 5000,
+ reverse_charge: true,
+ })
+ const items = [makeItem({ line_total: 5000, vat_rate: 0.25, account_number: '6540' })]
+
+ await createSupplierInvoiceRegistrationEntry(
+ null as never, 'user-1', invoice, items, 'non_eu_business'
+ )
+
+ const input = mockedCreateEntry.mock.calls[0][2]
+
+ expect(findByAccount(input.lines, '2645')).toHaveLength(1)
+ expect(findByAccount(input.lines, '2614')).toHaveLength(1)
+ expect(findByAccount(input.lines, '2641')).toHaveLength(0)
+
+ assertBalanced(input)
+ })
+
+ it('creates per-rate 2645/26x4 pairs for mixed-rate reverse charge', async () => {
+ const invoice = makeSupplierInvoice({
+ subtotal: 15000,
+ vat_amount: 0,
+ total: 15000,
+ reverse_charge: true,
+ })
+ const items = [
+ makeItem({ line_total: 10000, vat_rate: 0.25, account_number: '6540' }),
+ makeItem({ id: 'item-2', line_total: 5000, vat_rate: 0.12, account_number: '5410' }),
+ ]
+
+ await createSupplierInvoiceRegistrationEntry(
+ null as never, 'user-1', invoice, items, 'eu_business'
+ )
+
+ const input = mockedCreateEntry.mock.calls[0][2]
+
+ const vat2645 = findByAccount(input.lines, '2645')
+ expect(vat2645).toHaveLength(2)
+
+ // 25%: 2614
+ expect(findByAccount(input.lines, '2614')[0].credit_amount).toBe(2500)
+ // 12%: 2624
+ expect(findByAccount(input.lines, '2624')[0].credit_amount).toBe(600)
+
+ assertBalanced(input)
+ })
+})
+
+// ============================================================
+// createSupplierInvoicePaymentEntry
+// ============================================================
+
+describe('createSupplierInvoicePaymentEntry', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockedFindFiscalPeriod.mockResolvedValue('period-1')
+ })
+
+ it('returns null when no fiscal period found', async () => {
+ mockedFindFiscalPeriod.mockResolvedValue(null)
+ const invoice = makeSupplierInvoice()
+
+ const result = await createSupplierInvoicePaymentEntry(
+ null as never, 'user-1', invoice, 10000, '2024-07-01'
+ )
+
+ expect(result).toBeNull()
+ expect(mockedCreateEntry).not.toHaveBeenCalled()
+ })
+
+ it('creates standard SEK payment (2 lines)', async () => {
+ const invoice = makeSupplierInvoice({ total: 10000 })
+
+ await createSupplierInvoicePaymentEntry(
+ null as never, 'user-1', invoice, 10000, '2024-07-01'
+ )
+
+ const input = mockedCreateEntry.mock.calls[0][2]
+ expect(input.lines).toHaveLength(2)
+
+ const debit2440 = findByAccount(input.lines, '2440')[0]
+ expect(debit2440.debit_amount).toBe(10000)
+
+ const credit1930 = findByAccount(input.lines, '1930')[0]
+ expect(credit1930.credit_amount).toBe(10000)
+
+ assertBalanced(input)
+ })
+
+ it('creates entry with FX gain (credit 3960)', async () => {
+ const invoice = makeSupplierInvoice({ total: 11500, currency: 'EUR' })
+
+ // paymentAmount = original SEK amount, exchangeRateDifference > 0 = gain
+ await createSupplierInvoicePaymentEntry(
+ null as never, 'user-1', invoice, 11500, '2024-07-15', 500
+ )
+
+ const input = mockedCreateEntry.mock.calls[0][2]
+ expect(input.lines).toHaveLength(3)
+
+ const debit2440 = findByAccount(input.lines, '2440')[0]
+ expect(debit2440.debit_amount).toBe(11500)
+
+ const credit1930 = findByAccount(input.lines, '1930')[0]
+ expect(credit1930.credit_amount).toBe(11000) // 11500 - 500
+
+ const credit3960 = findByAccount(input.lines, '3960')[0]
+ expect(credit3960.credit_amount).toBe(500)
+
+ assertBalanced(input)
+ })
+
+ it('creates entry with FX loss (debit 7960)', async () => {
+ const invoice = makeSupplierInvoice({ total: 11500, currency: 'EUR' })
+
+ // exchangeRateDifference < 0 = loss
+ await createSupplierInvoicePaymentEntry(
+ null as never, 'user-1', invoice, 11500, '2024-07-15', -300
+ )
+
+ const input = mockedCreateEntry.mock.calls[0][2]
+ expect(input.lines).toHaveLength(3)
+
+ const debit2440 = findByAccount(input.lines, '2440')[0]
+ expect(debit2440.debit_amount).toBe(11500)
+
+ const credit1930 = findByAccount(input.lines, '1930')[0]
+ expect(credit1930.credit_amount).toBe(11800) // 11500 - (-300)
+
+ const debit7960 = findByAccount(input.lines, '7960')[0]
+ expect(debit7960.debit_amount).toBe(300)
+
+ assertBalanced(input)
+ })
+
+ it('exchangeRateDifference=0 creates standard 2-line entry', async () => {
+ const invoice = makeSupplierInvoice()
+
+ await createSupplierInvoicePaymentEntry(
+ null as never, 'user-1', invoice, 10000, '2024-07-01', 0
+ )
+
+ const input = mockedCreateEntry.mock.calls[0][2]
+ expect(input.lines).toHaveLength(2)
+
+ expect(findByAccount(input.lines, '3960')).toHaveLength(0)
+ expect(findByAccount(input.lines, '7960')).toHaveLength(0)
+
+ assertBalanced(input)
+ })
+
+ it('rounds amounts to 2 decimal places', async () => {
+ const invoice = makeSupplierInvoice()
+
+ await createSupplierInvoicePaymentEntry(
+ null as never, 'user-1', invoice, 10000.555, '2024-07-01'
+ )
+
+ const input = mockedCreateEntry.mock.calls[0][2]
+ for (const line of input.lines) {
+ if (line.debit_amount > 0) {
+ expect(line.debit_amount).toBe(Math.round(10000.555 * 100) / 100)
+ }
+ if (line.credit_amount > 0) {
+ expect(line.credit_amount).toBe(Math.round(10000.555 * 100) / 100)
+ }
+ }
+ })
+
+ it('sets source_type to supplier_invoice_paid', async () => {
+ const invoice = makeSupplierInvoice({ id: 'si-pay-1' })
+
+ await createSupplierInvoicePaymentEntry(
+ null as never, 'user-1', invoice, 10000, '2024-07-01'
+ )
+
+ const input = mockedCreateEntry.mock.calls[0][2]
+ expect(input.source_type).toBe('supplier_invoice_paid')
+ expect(input.source_id).toBe('si-pay-1')
+ })
+
+ it('uses paymentDate not invoice_date as entry_date', async () => {
+ const invoice = makeSupplierInvoice({ invoice_date: '2024-06-01' })
+
+ await createSupplierInvoicePaymentEntry(
+ null as never, 'user-1', invoice, 10000, '2024-08-15'
+ )
+
+ const input = mockedCreateEntry.mock.calls[0][2]
+ expect(input.entry_date).toBe('2024-08-15')
+ })
+})
+
+// ============================================================
+// createSupplierInvoiceCashEntry
+// ============================================================
+
+describe('createSupplierInvoiceCashEntry', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockedFindFiscalPeriod.mockResolvedValue('period-1')
+ })
+
+ it('returns null when no fiscal period found', async () => {
+ mockedFindFiscalPeriod.mockResolvedValue(null)
+ const invoice = makeSupplierInvoice()
+ const items = [makeItem()]
+
+ const result = await createSupplierInvoiceCashEntry(
+ null as never, 'user-1', invoice, items, '2024-07-01', 'swedish_business'
+ )
+
+ expect(result).toBeNull()
+ expect(mockedCreateEntry).not.toHaveBeenCalled()
+ })
+
+ it('domestic with VAT — credits 1930 (not 2440)', async () => {
+ const invoice = makeSupplierInvoice({
+ subtotal: 8000,
+ vat_amount: 2000,
+ total: 10000,
+ })
+ const items = [makeItem({ line_total: 8000, account_number: '6200', vat_rate: 0.25 })]
+
+ await createSupplierInvoiceCashEntry(
+ null as never, 'user-1', invoice, items, '2024-07-01', 'swedish_business'
+ )
+
+ const input = mockedCreateEntry.mock.calls[0][2]
+
+ expect(findByAccount(input.lines, '6200')[0].debit_amount).toBe(8000)
+ expect(findByAccount(input.lines, '2641')[0].debit_amount).toBe(2000)
+
+ const credit1930 = findByAccount(input.lines, '1930')
+ expect(credit1930).toHaveLength(1)
+ expect(credit1930[0].credit_amount).toBe(10000)
+
+ assertBalanced(input)
+ })
+
+ it('domestic zero VAT', async () => {
+ const invoice = makeSupplierInvoice({
+ subtotal: 5000,
+ vat_amount: 0,
+ total: 5000,
+ })
+ const items = [makeItem({ line_total: 5000, account_number: '5410', vat_rate: 0 })]
+
+ await createSupplierInvoiceCashEntry(
+ null as never, 'user-1', invoice, items, '2024-07-01', 'swedish_business'
+ )
+
+ const input = mockedCreateEntry.mock.calls[0][2]
+
+ expect(findByAccount(input.lines, '5410')[0].debit_amount).toBe(5000)
+ expect(findByAccount(input.lines, '1930')[0].credit_amount).toBe(5000)
+ expect(findByAccount(input.lines, '2641')).toHaveLength(0)
+
+ assertBalanced(input)
+ })
+
+ it('EU reverse charge — credits 1930', async () => {
+ const invoice = makeSupplierInvoice({
+ subtotal: 10000,
+ vat_amount: 0,
+ total: 10000,
+ reverse_charge: true,
+ })
+ const items = [makeItem({ line_total: 10000, account_number: '6540', vat_rate: 0.25 })]
+
+ await createSupplierInvoiceCashEntry(
+ null as never, 'user-1', invoice, items, '2024-07-01', 'eu_business'
+ )
+
+ const input = mockedCreateEntry.mock.calls[0][2]
+
+ expect(findByAccount(input.lines, '2645')[0].debit_amount).toBe(2500)
+ expect(findByAccount(input.lines, '2614')[0].credit_amount).toBe(2500)
+
+ const credit1930 = findByAccount(input.lines, '1930')
+ expect(credit1930).toHaveLength(1)
+ // 1930 = totalDebits - totalCredits = (10000 + 2500) - 2500 = 10000
+ // Fiktiv moms entries are offsetting; bank payment equals actual invoice amount
+ expect(credit1930[0].credit_amount).toBe(10000)
+
+ assertBalanced(input)
+ })
+
+ it('has no 2440 line', async () => {
+ const invoice = makeSupplierInvoice()
+ const items = [makeItem()]
+
+ await createSupplierInvoiceCashEntry(
+ null as never, 'user-1', invoice, items, '2024-07-01', 'swedish_business'
+ )
+
+ const input = mockedCreateEntry.mock.calls[0][2]
+ expect(findByAccount(input.lines, '2440')).toHaveLength(0)
+ })
+
+ it('creates per-rate 2641 lines for mixed-rate domestic cash entry', async () => {
+ const invoice = makeSupplierInvoice({ vat_amount: 2680, total: 15680 })
+ const items = [
+ makeItem({ line_total: 10000, vat_rate: 0.25 }),
+ makeItem({ id: 'item-2', line_total: 3000, vat_rate: 0.06, account_number: '5410' }),
+ ]
+
+ await createSupplierInvoiceCashEntry(
+ null as never, 'user-1', invoice, items, '2024-06-01', 'swedish_business'
+ )
+
+ const input = mockedCreateEntry.mock.calls[0][2]
+ const vat2641 = findByAccount(input.lines, '2641')
+ expect(vat2641).toHaveLength(2)
+ expect(vat2641.find((l) => l.line_description.includes('25%'))?.debit_amount).toBe(2500)
+ expect(vat2641.find((l) => l.line_description.includes('6%'))?.debit_amount).toBe(180)
+
+ assertBalanced(input)
+ })
+
+ it('sets source_type to supplier_invoice_cash_payment', async () => {
+ const invoice = makeSupplierInvoice({ id: 'si-cash-1' })
+ const items = [makeItem()]
+
+ await createSupplierInvoiceCashEntry(
+ null as never, 'user-1', invoice, items, '2024-07-01', 'swedish_business'
+ )
+
+ const input = mockedCreateEntry.mock.calls[0][2]
+ expect(input.source_type).toBe('supplier_invoice_cash_payment')
+ expect(input.source_id).toBe('si-cash-1')
+ })
+
+ it('description contains "kontantmetoden"', async () => {
+ const invoice = makeSupplierInvoice()
+ const items = [makeItem()]
+
+ await createSupplierInvoiceCashEntry(
+ null as never, 'user-1', invoice, items, '2024-07-01', 'swedish_business'
+ )
+
+ const input = mockedCreateEntry.mock.calls[0][2]
+ expect(input.description).toContain('kontantmetoden')
+ })
+})
+
+// ============================================================
+// createSupplierCreditNoteEntry
+// ============================================================
+
+describe('createSupplierCreditNoteEntry', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockedFindFiscalPeriod.mockResolvedValue('period-1')
+ })
+
+ it('returns null when no fiscal period found', async () => {
+ mockedFindFiscalPeriod.mockResolvedValue(null)
+ const creditNote = makeSupplierInvoice({ is_credit_note: true })
+ const items = [makeItem()]
+
+ const result = await createSupplierCreditNoteEntry(
+ null as never, 'user-1', creditNote, items, 'swedish_business'
+ )
+
+ expect(result).toBeNull()
+ expect(mockedCreateEntry).not.toHaveBeenCalled()
+ })
+
+ it('domestic: D 2440, C expense, C 2641', async () => {
+ const creditNote = makeSupplierInvoice({
+ is_credit_note: true,
+ subtotal: -8000,
+ vat_amount: -2000,
+ total: -10000,
+ })
+ const items = [makeItem({ line_total: -8000, account_number: '6200', vat_rate: 0.25 })]
+
+ await createSupplierCreditNoteEntry(
+ null as never, 'user-1', creditNote, items, 'swedish_business'
+ )
+
+ const input = mockedCreateEntry.mock.calls[0][2]
+
+ const debit2440 = findByAccount(input.lines, '2440')[0]
+ expect(debit2440.debit_amount).toBe(10000) // abs
+ expect(debit2440.credit_amount).toBe(0)
+
+ const credit6200 = findByAccount(input.lines, '6200')[0]
+ expect(credit6200.credit_amount).toBe(8000) // abs
+ expect(credit6200.debit_amount).toBe(0)
+
+ const credit2641 = findByAccount(input.lines, '2641')[0]
+ expect(credit2641.credit_amount).toBe(2000) // abs(8000) * 0.25
+ expect(credit2641.debit_amount).toBe(0)
+
+ assertBalanced(input)
+ })
+
+ it('domestic zero VAT', async () => {
+ const creditNote = makeSupplierInvoice({
+ is_credit_note: true,
+ subtotal: -5000,
+ vat_amount: 0,
+ total: -5000,
+ })
+ const items = [makeItem({ line_total: -5000, account_number: '6200', vat_rate: 0 })]
+
+ await createSupplierCreditNoteEntry(
+ null as never, 'user-1', creditNote, items, 'swedish_business'
+ )
+
+ const input = mockedCreateEntry.mock.calls[0][2]
+
+ expect(findByAccount(input.lines, '2440')[0].debit_amount).toBe(5000)
+ expect(findByAccount(input.lines, '6200')[0].credit_amount).toBe(5000)
+ expect(findByAccount(input.lines, '2641')).toHaveLength(0)
+
+ assertBalanced(input)
+ })
+
+ it('EU reverse charge reversal (C 2645, D 2614)', async () => {
+ const creditNote = makeSupplierInvoice({
+ is_credit_note: true,
+ subtotal: -10000,
+ vat_amount: 0,
+ total: -10000,
+ reverse_charge: true,
+ })
+ const items = [makeItem({ line_total: -10000, account_number: '6540', vat_rate: 0.25 })]
+
+ await createSupplierCreditNoteEntry(
+ null as never, 'user-1', creditNote, items, 'eu_business'
+ )
+
+ const input = mockedCreateEntry.mock.calls[0][2]
+
+ // Reversed fiktiv moms
+ const credit2645 = findByAccount(input.lines, '2645')[0]
+ expect(credit2645.credit_amount).toBe(2500) // abs(10000) * 0.25
+ expect(credit2645.debit_amount).toBe(0)
+
+ const debit2614 = findByAccount(input.lines, '2614')[0]
+ expect(debit2614.debit_amount).toBe(2500)
+ expect(debit2614.credit_amount).toBe(0)
+
+ const credit6540 = findByAccount(input.lines, '6540')[0]
+ expect(credit6540.credit_amount).toBe(10000)
+
+ const debit2440 = findByAccount(input.lines, '2440')[0]
+ expect(debit2440.debit_amount).toBe(10000) // totalCredits - totalDebits = (2500 + 10000) - 2500
+
+ assertBalanced(input)
+ })
+
+ it('uses Math.abs for all amounts (negative inputs produce positive lines)', async () => {
+ const creditNote = makeSupplierInvoice({
+ is_credit_note: true,
+ total: -7500,
+ vat_amount: 0,
+ })
+ const items = [makeItem({ line_total: -7500, account_number: '6200', vat_rate: 0 })]
+
+ await createSupplierCreditNoteEntry(
+ null as never, 'user-1', creditNote, items, 'swedish_business'
+ )
+
+ const input = mockedCreateEntry.mock.calls[0][2]
+ for (const line of input.lines) {
+ expect(line.debit_amount).toBeGreaterThanOrEqual(0)
+ expect(line.credit_amount).toBeGreaterThanOrEqual(0)
+ }
+ })
+
+ it('2440 line is first (unshift)', async () => {
+ const creditNote = makeSupplierInvoice({
+ is_credit_note: true,
+ total: -10000,
+ vat_amount: -2000,
+ })
+ const items = [makeItem({ line_total: -8000, account_number: '6200', vat_rate: 0.25 })]
+
+ await createSupplierCreditNoteEntry(
+ null as never, 'user-1', creditNote, items, 'swedish_business'
+ )
+
+ const input = mockedCreateEntry.mock.calls[0][2]
+ expect(input.lines[0].account_number).toBe('2440')
+ })
+
+ it('sets source_type to supplier_credit_note', async () => {
+ const creditNote = makeSupplierInvoice({ id: 'si-cn-1', is_credit_note: true })
+ const items = [makeItem()]
+
+ await createSupplierCreditNoteEntry(
+ null as never, 'user-1', creditNote, items, 'swedish_business'
+ )
+
+ const input = mockedCreateEntry.mock.calls[0][2]
+ expect(input.source_type).toBe('supplier_credit_note')
+ expect(input.source_id).toBe('si-cn-1')
+ })
+
+ it('reverses mixed-rate reverse charge with correct per-rate accounts', async () => {
+ const creditNote = makeSupplierInvoice({
+ is_credit_note: true,
+ vat_amount: 0,
+ total: -15000,
+ reverse_charge: true,
+ })
+ const items = [
+ makeItem({ line_total: -10000, vat_rate: 0.25, account_number: '6540' }),
+ makeItem({ id: 'item-2', line_total: -5000, vat_rate: 0.12, account_number: '5410' }),
+ ]
+
+ await createSupplierCreditNoteEntry(
+ null as never, 'user-1', creditNote, items, 'eu_business'
+ )
+
+ const input = mockedCreateEntry.mock.calls[0][2]
+
+ // 2645 credit lines: 2 (one per rate)
+ const vat2645 = findByAccount(input.lines, '2645')
+ expect(vat2645).toHaveLength(2)
+
+ // 2614 debit (25%): abs(10000) * 0.25 = 2500
+ expect(findByAccount(input.lines, '2614')[0].debit_amount).toBe(2500)
+ // 2624 debit (12%): abs(5000) * 0.12 = 600
+ expect(findByAccount(input.lines, '2624')[0].debit_amount).toBe(600)
+
+ assertBalanced(input)
+ })
+})
diff --git a/lib/bookkeeping/__tests__/transaction-entries.test.ts b/lib/bookkeeping/__tests__/transaction-entries.test.ts
new file mode 100644
index 00000000..70bad08d
--- /dev/null
+++ b/lib/bookkeeping/__tests__/transaction-entries.test.ts
@@ -0,0 +1,524 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { makeTransaction } from '@/tests/helpers'
+import type { CreateJournalEntryInput, MappingResult, VatJournalLine } from '@/types'
+
+// Mock engine
+vi.mock('../engine', () => ({
+ findFiscalPeriod: vi.fn().mockResolvedValue('period-1'),
+ createJournalEntry: vi.fn().mockImplementation(
+ async (_supabase: unknown, _userId: string, input: CreateJournalEntryInput) => ({
+ id: 'entry-1',
+ ...input,
+ lines: input.lines,
+ })
+ ),
+}))
+
+// Mock currency-utils with real logic
+vi.mock('../currency-utils', () => ({
+ resolveSekAmount: vi.fn().mockImplementation(
+ (amount: number, amountSek: number | null, currency: string | null, exchangeRate: number | null) => {
+ if (!currency || currency === 'SEK') return amount
+ if (amountSek != null) return Math.round(amountSek * 100) / 100
+ if (exchangeRate != null && exchangeRate > 0) return Math.round(amount * exchangeRate * 100) / 100
+ return amount
+ }
+ ),
+ buildCurrencyMetadata: vi.fn().mockImplementation(
+ (currency: string | null, amountInCurrency: number | null | undefined, exchangeRate: number | null) => {
+ if (!currency || currency === 'SEK') return {}
+ return {
+ ...(currency ? { currency } : {}),
+ ...(amountInCurrency != null ? { amount_in_currency: amountInCurrency } : {}),
+ ...(exchangeRate != null && exchangeRate > 0 ? { exchange_rate: exchangeRate } : {}),
+ }
+ }
+ ),
+}))
+
+// Mock vat-entries with real logic
+vi.mock('../vat-entries', () => ({
+ generateInputVatLine: vi.fn().mockImplementation(
+ (totalAmount: number, vatRate: number = 0.25) => {
+ if (vatRate === 0) return null
+ const vatAmount = Math.round((totalAmount * vatRate) / (1 + vatRate) * 100) / 100
+ return {
+ account_number: '2641',
+ debit_amount: vatAmount,
+ credit_amount: 0,
+ line_description: `Ingående moms ${vatRate * 100}%`,
+ }
+ }
+ ),
+ generateReverseChargeLines: vi.fn().mockImplementation(
+ (baseAmount: number, vatRate: number = 0.25) => {
+ const vatAmount = Math.round(baseAmount * vatRate * 100) / 100
+ let outputAccount: string
+ switch (vatRate) {
+ case 0.12: outputAccount = '2624'; break
+ case 0.06: outputAccount = '2634'; break
+ default: outputAccount = '2614'; break
+ }
+ return [
+ { account_number: '2645', debit_amount: vatAmount, credit_amount: 0, line_description: `Fiktiv ingående moms` },
+ { account_number: outputAccount, debit_amount: 0, credit_amount: vatAmount, line_description: `Fiktiv utgående moms` },
+ ]
+ }
+ ),
+ extractNetAmount: vi.fn().mockImplementation(
+ (totalAmount: number, vatRate: number) => {
+ if (vatRate === 0) return totalAmount
+ return Math.round((totalAmount / (1 + vatRate)) * 100) / 100
+ }
+ ),
+ extractVatAmount: vi.fn().mockImplementation(
+ (totalAmount: number, vatRate: number) => {
+ if (vatRate === 0) return 0
+ return Math.round((totalAmount - totalAmount / (1 + vatRate)) * 100) / 100
+ }
+ ),
+}))
+
+const { createJournalEntry, findFiscalPeriod } = await import('../engine')
+const mockedCreateEntry = vi.mocked(createJournalEntry)
+const mockedFindFiscalPeriod = vi.mocked(findFiscalPeriod)
+
+const { createTransactionJournalEntry, buildDomesticExpenseLines } = await import('../transaction-entries')
+
+function makeMappingResult(overrides: Partial = {}): MappingResult {
+ return {
+ rule: null,
+ debit_account: '5410',
+ credit_account: '1930',
+ risk_level: 'low',
+ confidence: 0.95,
+ requires_review: false,
+ default_private: false,
+ vat_lines: [],
+ description: 'Test mapping',
+ ...overrides,
+ }
+}
+
+/** Balance check helper */
+function assertBalanced(input: CreateJournalEntryInput) {
+ const totalDebit = input.lines.reduce((sum, l) => sum + l.debit_amount, 0)
+ const totalCredit = input.lines.reduce((sum, l) => sum + l.credit_amount, 0)
+ expect(totalDebit).toBeCloseTo(totalCredit, 2)
+ expect(totalDebit).toBeGreaterThan(0)
+}
+
+describe('createTransactionJournalEntry', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockedFindFiscalPeriod.mockResolvedValue('period-1')
+ })
+
+ // --- Validation ---
+
+ it('throws when debit_account is missing', async () => {
+ const tx = makeTransaction()
+ const mapping = makeMappingResult({ debit_account: '' })
+
+ await expect(
+ createTransactionJournalEntry(null as never, 'user-1', tx, mapping)
+ ).rejects.toThrow('Invalid mapping result')
+ })
+
+ it('throws when credit_account is missing', async () => {
+ const tx = makeTransaction()
+ const mapping = makeMappingResult({ credit_account: '' })
+
+ await expect(
+ createTransactionJournalEntry(null as never, 'user-1', tx, mapping)
+ ).rejects.toThrow('Invalid mapping result')
+ })
+
+ // --- Fiscal period ---
+
+ it('returns null when no fiscal period found', async () => {
+ mockedFindFiscalPeriod.mockResolvedValue(null)
+ const tx = makeTransaction()
+ const mapping = makeMappingResult()
+
+ const result = await createTransactionJournalEntry(null as never, 'user-1', tx, mapping)
+
+ expect(result).toBeNull()
+ expect(mockedCreateEntry).not.toHaveBeenCalled()
+ })
+
+ // --- Private expense ---
+
+ it('creates private expense entry for EF (2013)', async () => {
+ const tx = makeTransaction({ amount: -500, description: 'Lunch privat' })
+ const mapping = makeMappingResult({
+ debit_account: '2013',
+ credit_account: '1930',
+ default_private: true,
+ })
+
+ await createTransactionJournalEntry(null as never, 'user-1', tx, mapping)
+
+ expect(mockedCreateEntry).toHaveBeenCalledOnce()
+ const input = mockedCreateEntry.mock.calls[0][2]
+
+ expect(input.lines).toHaveLength(2)
+
+ const debit2013 = input.lines.find(l => l.account_number === '2013')
+ expect(debit2013?.debit_amount).toBe(500)
+ expect(debit2013?.credit_amount).toBe(0)
+ expect(debit2013?.line_description).toMatch(/^Privat:/)
+
+ const credit1930 = input.lines.find(l => l.account_number === '1930')
+ expect(credit1930?.credit_amount).toBe(500)
+ expect(credit1930?.debit_amount).toBe(0)
+
+ assertBalanced(input)
+ })
+
+ it('creates private expense entry for AB (2893)', async () => {
+ const tx = makeTransaction({ amount: -1200, description: 'Privat uttag' })
+ const mapping = makeMappingResult({
+ debit_account: '2893',
+ credit_account: '1930',
+ default_private: true,
+ })
+
+ await createTransactionJournalEntry(null as never, 'user-1', tx, mapping)
+
+ const input = mockedCreateEntry.mock.calls[0][2]
+ expect(input.lines).toHaveLength(2)
+
+ const debit2893 = input.lines.find(l => l.account_number === '2893')
+ expect(debit2893?.debit_amount).toBe(1200)
+
+ const credit1930 = input.lines.find(l => l.account_number === '1930')
+ expect(credit1930?.credit_amount).toBe(1200)
+
+ assertBalanced(input)
+ })
+
+ // --- Business expense ---
+
+ it('creates business expense without VAT (2 lines)', async () => {
+ const tx = makeTransaction({ amount: -299, description: 'Office supplies' })
+ const mapping = makeMappingResult({
+ debit_account: '5410',
+ credit_account: '1930',
+ vat_lines: [],
+ })
+
+ await createTransactionJournalEntry(null as never, 'user-1', tx, mapping)
+
+ const input = mockedCreateEntry.mock.calls[0][2]
+ expect(input.lines).toHaveLength(2)
+
+ const debit5410 = input.lines.find(l => l.account_number === '5410')
+ expect(debit5410?.debit_amount).toBe(299)
+
+ const credit1930 = input.lines.find(l => l.account_number === '1930')
+ expect(credit1930?.credit_amount).toBe(299)
+
+ assertBalanced(input)
+ })
+
+ it('creates business expense with 25% input VAT (3 lines)', async () => {
+ const tx = makeTransaction({ amount: -1250, description: 'Software license' })
+ const vatLines: VatJournalLine[] = [
+ { account_number: '2641', debit_amount: 250, credit_amount: 0, description: 'Ingående moms 25%' },
+ ]
+ const mapping = makeMappingResult({
+ debit_account: '5410',
+ credit_account: '1930',
+ vat_lines: vatLines,
+ })
+
+ await createTransactionJournalEntry(null as never, 'user-1', tx, mapping)
+
+ const input = mockedCreateEntry.mock.calls[0][2]
+ expect(input.lines).toHaveLength(3)
+
+ const debit2641 = input.lines.find(l => l.account_number === '2641')
+ expect(debit2641?.debit_amount).toBe(250)
+
+ const debit5410 = input.lines.find(l => l.account_number === '5410')
+ expect(debit5410?.debit_amount).toBe(1000) // 1250 - 250 VAT
+
+ const credit1930 = input.lines.find(l => l.account_number === '1930')
+ expect(credit1930?.credit_amount).toBe(1250)
+
+ assertBalanced(input)
+ })
+
+ it('handles VAT rounding precision on expense', async () => {
+ const tx = makeTransaction({ amount: -997.50, description: 'Expense with rounding' })
+ const vatLines: VatJournalLine[] = [
+ { account_number: '2641', debit_amount: 199.50, credit_amount: 0, description: 'Ingående moms 25%' },
+ ]
+ const mapping = makeMappingResult({
+ debit_account: '5410',
+ credit_account: '1930',
+ vat_lines: vatLines,
+ })
+
+ await createTransactionJournalEntry(null as never, 'user-1', tx, mapping)
+
+ const input = mockedCreateEntry.mock.calls[0][2]
+ const debit5410 = input.lines.find(l => l.account_number === '5410')
+ // net = Math.round((997.50 - 199.50) * 100) / 100 = 798
+ expect(debit5410?.debit_amount).toBe(Math.round((997.50 - 199.50) * 100) / 100)
+
+ assertBalanced(input)
+ })
+
+ it('creates expense with EU reverse charge (2645/2614)', async () => {
+ const tx = makeTransaction({ amount: -5000, description: 'EU SaaS service' })
+ const vatLines: VatJournalLine[] = [
+ { account_number: '2645', debit_amount: 1250, credit_amount: 0, description: 'Fiktiv ingående moms' },
+ { account_number: '2614', debit_amount: 0, credit_amount: 1250, description: 'Fiktiv utgående moms' },
+ ]
+ const mapping = makeMappingResult({
+ debit_account: '5410',
+ credit_account: '1930',
+ vat_lines: vatLines,
+ })
+
+ await createTransactionJournalEntry(null as never, 'user-1', tx, mapping)
+
+ const input = mockedCreateEntry.mock.calls[0][2]
+
+ const debit2645 = input.lines.find(l => l.account_number === '2645')
+ expect(debit2645?.debit_amount).toBe(1250)
+
+ const credit2614 = input.lines.find(l => l.account_number === '2614')
+ expect(credit2614?.credit_amount).toBe(1250)
+
+ // Expense: For reverse charge, no 2641 line means netAmount = absAmount - 0 = 5000
+ const debit5410 = input.lines.find(l => l.account_number === '5410')
+ expect(debit5410?.debit_amount).toBe(5000)
+
+ const credit1930 = input.lines.find(l => l.account_number === '1930')
+ expect(credit1930?.credit_amount).toBe(5000)
+
+ assertBalanced(input)
+ })
+
+ // --- Income ---
+
+ it('creates income entry without VAT (2 lines)', async () => {
+ const tx = makeTransaction({ amount: 8000, description: 'Export revenue' })
+ const mapping = makeMappingResult({
+ debit_account: '1930',
+ credit_account: '3001',
+ vat_lines: [],
+ })
+
+ await createTransactionJournalEntry(null as never, 'user-1', tx, mapping)
+
+ const input = mockedCreateEntry.mock.calls[0][2]
+ expect(input.lines).toHaveLength(2)
+
+ const debit1930 = input.lines.find(l => l.account_number === '1930')
+ expect(debit1930?.debit_amount).toBe(8000)
+
+ const credit3001 = input.lines.find(l => l.account_number === '3001')
+ expect(credit3001?.credit_amount).toBe(8000)
+
+ assertBalanced(input)
+ })
+
+ it('creates income entry with output VAT', async () => {
+ const tx = makeTransaction({ amount: 12500, description: 'Sales income' })
+ const vatLines: VatJournalLine[] = [
+ { account_number: '2611', debit_amount: 0, credit_amount: 2500, description: 'Utgående moms 25%' },
+ ]
+ const mapping = makeMappingResult({
+ debit_account: '1930',
+ credit_account: '3001',
+ vat_lines: vatLines,
+ })
+
+ await createTransactionJournalEntry(null as never, 'user-1', tx, mapping)
+
+ const input = mockedCreateEntry.mock.calls[0][2]
+
+ const debit1930 = input.lines.find(l => l.account_number === '1930')
+ expect(debit1930?.debit_amount).toBe(12500)
+
+ const credit3001 = input.lines.find(l => l.account_number === '3001')
+ expect(credit3001?.credit_amount).toBe(10000) // 12500 - 2500 VAT
+
+ const credit2611 = input.lines.find(l => l.account_number === '2611')
+ expect(credit2611?.credit_amount).toBe(2500)
+
+ assertBalanced(input)
+ })
+
+ it('handles VAT rounding precision on income', async () => {
+ const tx = makeTransaction({ amount: 333.33, description: 'Small sale' })
+ const vatLines: VatJournalLine[] = [
+ { account_number: '2611', debit_amount: 0, credit_amount: 66.67, description: 'Utgående moms 25%' },
+ ]
+ const mapping = makeMappingResult({
+ debit_account: '1930',
+ credit_account: '3001',
+ vat_lines: vatLines,
+ })
+
+ await createTransactionJournalEntry(null as never, 'user-1', tx, mapping)
+
+ const input = mockedCreateEntry.mock.calls[0][2]
+ const credit3001 = input.lines.find(l => l.account_number === '3001')
+ // net = Math.round((333.33 - 66.67) * 100) / 100 = 266.66
+ expect(credit3001?.credit_amount).toBe(Math.round((333.33 - 66.67) * 100) / 100)
+
+ assertBalanced(input)
+ })
+
+ // --- Foreign currency ---
+
+ it('adds currency metadata to 1930 line for EUR expense', async () => {
+ const tx = makeTransaction({
+ amount: -100,
+ currency: 'EUR',
+ amount_sek: null,
+ exchange_rate: 11.50,
+ description: 'EUR purchase',
+ })
+ const mapping = makeMappingResult({
+ debit_account: '5410',
+ credit_account: '1930',
+ vat_lines: [],
+ })
+
+ await createTransactionJournalEntry(null as never, 'user-1', tx, mapping)
+
+ const input = mockedCreateEntry.mock.calls[0][2]
+ const credit1930 = input.lines.find(l => l.account_number === '1930')
+
+ expect(credit1930?.currency).toBe('EUR')
+ expect(credit1930?.amount_in_currency).toBe(100)
+ expect(credit1930?.exchange_rate).toBe(11.50)
+
+ // All amounts in SEK
+ expect(credit1930?.credit_amount).toBe(1150) // 100 * 11.50
+ const debit5410 = input.lines.find(l => l.account_number === '5410')
+ expect(debit5410?.debit_amount).toBe(1150)
+
+ assertBalanced(input)
+ })
+
+ it('SEK transaction has no currency metadata', async () => {
+ const tx = makeTransaction({ amount: -500, currency: 'SEK' })
+ const mapping = makeMappingResult()
+
+ await createTransactionJournalEntry(null as never, 'user-1', tx, mapping)
+
+ const input = mockedCreateEntry.mock.calls[0][2]
+ for (const line of input.lines) {
+ expect(line.currency).toBeUndefined()
+ expect(line.amount_in_currency).toBeUndefined()
+ expect(line.exchange_rate).toBeUndefined()
+ }
+ })
+
+ // --- Metadata ---
+
+ it('sets source_type and source_id correctly', async () => {
+ const tx = makeTransaction({ id: 'tx-abc-123', amount: -100 })
+ const mapping = makeMappingResult()
+
+ await createTransactionJournalEntry(null as never, 'user-1', tx, mapping)
+
+ const input = mockedCreateEntry.mock.calls[0][2]
+ expect(input.source_type).toBe('bank_transaction')
+ expect(input.source_id).toBe('tx-abc-123')
+ })
+
+ it('uses transaction.date as entry_date', async () => {
+ const tx = makeTransaction({ date: '2024-09-15', amount: -100 })
+ const mapping = makeMappingResult()
+
+ await createTransactionJournalEntry(null as never, 'user-1', tx, mapping)
+
+ const input = mockedCreateEntry.mock.calls[0][2]
+ expect(input.entry_date).toBe('2024-09-15')
+ })
+})
+
+describe('buildDomesticExpenseLines', () => {
+ it('25% VAT: 3 lines (expense net + 2641 + 1930)', () => {
+ const lines = buildDomesticExpenseLines(1250, '5410', 'Office supplies', 0.25)
+
+ expect(lines).toHaveLength(3)
+
+ const expense = lines.find(l => l.account_number === '5410')
+ expect(expense?.debit_amount).toBe(1000) // 1250 / 1.25
+
+ const vat = lines.find(l => l.account_number === '2641')
+ expect(vat?.debit_amount).toBe(250) // 1250 - 1000
+
+ const bank = lines.find(l => l.account_number === '1930')
+ expect(bank?.credit_amount).toBe(1250)
+
+ const totalDebit = lines.reduce((sum, l) => sum + l.debit_amount, 0)
+ const totalCredit = lines.reduce((sum, l) => sum + l.credit_amount, 0)
+ expect(totalDebit).toBeCloseTo(totalCredit, 2)
+ })
+
+ it('12% VAT: correct amounts', () => {
+ const lines = buildDomesticExpenseLines(1120, '5400', 'Food supplies', 0.12)
+
+ expect(lines).toHaveLength(3)
+
+ const expense = lines.find(l => l.account_number === '5400')
+ expect(expense?.debit_amount).toBe(1000) // 1120 / 1.12
+
+ const vat = lines.find(l => l.account_number === '2641')
+ expect(vat?.debit_amount).toBe(120) // 1120 - 1000
+
+ const bank = lines.find(l => l.account_number === '1930')
+ expect(bank?.credit_amount).toBe(1120)
+
+ const totalDebit = lines.reduce((sum, l) => sum + l.debit_amount, 0)
+ const totalCredit = lines.reduce((sum, l) => sum + l.credit_amount, 0)
+ expect(totalDebit).toBeCloseTo(totalCredit, 2)
+ })
+
+ it('vatRate=0: 2 lines, no 2641', () => {
+ const lines = buildDomesticExpenseLines(500, '5410', 'No VAT expense', 0)
+
+ expect(lines).toHaveLength(2)
+
+ const expense = lines.find(l => l.account_number === '5410')
+ expect(expense?.debit_amount).toBe(500)
+
+ const bank = lines.find(l => l.account_number === '1930')
+ expect(bank?.credit_amount).toBe(500)
+
+ const vatLine = lines.find(l => l.account_number === '2641')
+ expect(vatLine).toBeUndefined()
+
+ const totalDebit = lines.reduce((sum, l) => sum + l.debit_amount, 0)
+ const totalCredit = lines.reduce((sum, l) => sum + l.credit_amount, 0)
+ expect(totalDebit).toBe(totalCredit)
+ })
+
+ it('negative amount uses Math.abs', () => {
+ const lines = buildDomesticExpenseLines(-750, '5410', 'Negative test', 0)
+
+ expect(lines).toHaveLength(2)
+
+ const expense = lines.find(l => l.account_number === '5410')
+ expect(expense?.debit_amount).toBe(750)
+
+ const bank = lines.find(l => l.account_number === '1930')
+ expect(bank?.credit_amount).toBe(750)
+
+ // All amounts positive
+ for (const line of lines) {
+ expect(line.debit_amount).toBeGreaterThanOrEqual(0)
+ expect(line.credit_amount).toBeGreaterThanOrEqual(0)
+ }
+ })
+})
diff --git a/lib/bookkeeping/category-mapping.ts b/lib/bookkeeping/category-mapping.ts
index 8a09e629..d293299f 100644
--- a/lib/bookkeeping/category-mapping.ts
+++ b/lib/bookkeeping/category-mapping.ts
@@ -72,6 +72,30 @@ function getExpenseAccount(category: string, entityType: EntityType = 'enskild_f
return EXPENSE_ACCOUNTS[category] || '6991'
}
+/**
+ * Get the income account for a category, resolving by VAT treatment.
+ * BAS mandates revenue account segregation by VAT rate:
+ * 3001=25%, 3002=12%, 3003=6%, 3305=Export, 3308=EU services, 3004=Exempt.
+ */
+function getIncomeAccount(category: string, vatTreatment?: VatTreatment): string {
+ // income_other always maps to 3900 regardless of VAT treatment
+ if (category === 'income_other') return '3900'
+
+ if (vatTreatment) {
+ switch (vatTreatment) {
+ case 'standard_25': return '3001'
+ case 'reduced_12': return '3002'
+ case 'reduced_6': return '3003'
+ case 'export': return '3305'
+ case 'reverse_charge': return '3308'
+ case 'exempt': return '3004'
+ }
+ }
+
+ // No vatTreatment provided — fall back to static mapping
+ return INCOME_ACCOUNTS[category] || '3900'
+}
+
/**
* Get account mapping for a transaction category
*
@@ -86,8 +110,15 @@ export function getCategoryAccountMapping(
vatTreatment?: VatTreatment
): CategoryAccountMapping {
// Private/owner transactions use entity-specific accounts
+ // EF: 2013 for withdrawals (uttag), 2018 for deposits (insättningar)
+ // AB: 2893 for both directions
if (!isBusiness) {
- const privateAccount = PRIVATE_ACCOUNTS[entityType] || PRIVATE_ACCOUNTS.enskild_firma
+ let privateAccount: string
+ if (entityType === 'enskild_firma') {
+ privateAccount = amount < 0 ? '2013' : '2018'
+ } else {
+ privateAccount = PRIVATE_ACCOUNTS[entityType] || PRIVATE_ACCOUNTS.enskild_firma
+ }
return {
debitAccount: amount < 0 ? privateAccount : BANK_ACCOUNT,
creditAccount: amount < 0 ? BANK_ACCOUNT : privateAccount,
@@ -101,8 +132,9 @@ export function getCategoryAccountMapping(
if (category.startsWith('expense_')) {
const expenseAccount = getExpenseAccount(category, entityType)
- // Bank fees, card fees, and currency exchange are VAT-exempt in Sweden
- const vatExemptCategories = ['expense_bank_fees', 'expense_card_fees', 'expense_currency_exchange']
+ // Bank fees, card fees, currency exchange, and representation are VAT-exempt in Sweden
+ // Representation has zero input VAT deduction since 2017-01-01 (ML 8:9)
+ const vatExemptCategories = ['expense_bank_fees', 'expense_card_fees', 'expense_currency_exchange', 'expense_representation']
const isVatExempt = vatExemptCategories.includes(category)
// Use provided vatTreatment, or default based on category
@@ -119,7 +151,7 @@ export function getCategoryAccountMapping(
// Check if it's an income category
if (category.startsWith('income_')) {
- const incomeAccount = INCOME_ACCOUNTS[category] || '3900'
+ const incomeAccount = getIncomeAccount(category, vatTreatment)
// Use provided vatTreatment, or default to standard_25
const resolvedVat = vatTreatment ?? 'standard_25'
@@ -313,7 +345,8 @@ export function getDefaultVatTreatmentForCategory(
return null
}
- const vatExemptCategories = ['expense_bank_fees', 'expense_card_fees', 'expense_currency_exchange']
+ // Representation has zero input VAT deduction since 2017-01-01 (ML 8:9)
+ const vatExemptCategories = ['expense_bank_fees', 'expense_card_fees', 'expense_currency_exchange', 'expense_representation']
if (vatExemptCategories.includes(category)) {
return null
}
diff --git a/lib/bookkeeping/client-account-names.ts b/lib/bookkeeping/client-account-names.ts
index 30f24df4..3bdb19d5 100644
--- a/lib/bookkeeping/client-account-names.ts
+++ b/lib/bookkeeping/client-account-names.ts
@@ -11,6 +11,7 @@ const ACCOUNT_NAMES: Record = {
// Equity & Liabilities (2xxx)
'2013': 'Ovriga egna uttag',
+ '2018': 'Egna insattningar',
'2440': 'Leverantorsskulder',
'2611': 'Utg. moms 25%',
'2621': 'Utg. moms 12%',
@@ -24,6 +25,7 @@ const ACCOUNT_NAMES: Record = {
'3001': 'Forsaljning 25%',
'3002': 'Forsaljning 12%',
'3003': 'Forsaljning 6%',
+ '3004': 'Momsfri forsaljning',
'3305': 'Exportforsaljning',
'3308': 'EU-tjanster',
'3900': 'Ovriga rorelseintakter',
diff --git a/lib/bookkeeping/supplier-invoice-entries.ts b/lib/bookkeeping/supplier-invoice-entries.ts
index 4a0226ca..d655a11f 100644
--- a/lib/bookkeeping/supplier-invoice-entries.ts
+++ b/lib/bookkeeping/supplier-invoice-entries.ts
@@ -18,14 +18,17 @@ const log = createLogger('supplier-invoice-entries')
*
* Swedish domestic (25% VAT):
* Debit 5xxx/6xxx (per item's account_number) [item line_total]
- * Debit 2641 Ingående moms [total VAT]
+ * Debit 2641 Ingående moms (per rate) [VAT per rate group]
* Credit 2440 Leverantörsskulder [total incl VAT]
*
- * EU reverse charge (supplier_type = 'eu_business'):
+ * EU/non-EU reverse charge (services):
* Debit 5xxx/6xxx (per item) [total]
- * Debit 2645 Beräknad ingående moms [fiktiv VAT]
- * Credit 2614 Utgående moms omvänd [fiktiv VAT]
+ * Debit 2645 Beräknad ingående moms (per rate) [fiktiv VAT per rate]
+ * Credit 26x4 Utgående moms omvänd (per rate) [fiktiv VAT per rate]
* Credit 2440 Leverantörsskulder [total]
+ *
+ * Note: Goods imports via Tullverket (customs) use a different accounting path
+ * (2615/2645) and are not handled here — only services use reverse charge.
*/
export async function createSupplierInvoiceRegistrationEntry(
supabase: SupabaseClient,
@@ -64,29 +67,40 @@ export async function createSupplierInvoiceRegistrationEntry(
}
lines.push(...debitLines)
- if (supplierType === 'eu_business' && invoice.reverse_charge) {
- // EU reverse charge: fiktiv moms entries (computed on SEK subtotal)
- const vatRate = getDefaultVatRate(invoice.vat_treatment)
- const subtotalSek = resolveSekAmount(invoice.subtotal, invoice.subtotal_sek, invoice.currency, invoice.exchange_rate)
- const reverseChargeLines = generateReverseChargeLines(subtotalSek, vatRate)
- lines.push(...reverseChargeLines)
+ const isReverseCharge = (supplierType === 'eu_business' || supplierType === 'non_eu_business') && invoice.reverse_charge
+
+ if (isReverseCharge) {
+ // EU/non-EU reverse charge: fiktiv moms entries per rate group
+ const vatByRate = groupVatByRate(items, invoice.currency, invoice.exchange_rate)
+ for (const [rate, amount] of vatByRate) {
+ if (rate > 0 && amount > 0) {
+ const rcLines = generateReverseChargeLines(amount / rate, rate)
+ lines.push(...rcLines)
+ }
+ }
} else if (invoice.vat_amount > 0) {
- // Domestic: Debit ingående moms (in SEK)
- const vatSek = resolveSekAmount(invoice.vat_amount, invoice.vat_amount_sek, invoice.currency, invoice.exchange_rate)
- lines.push({
- account_number: '2641',
- debit_amount: Math.round(vatSek * 100) / 100,
- credit_amount: 0,
- line_description: `Ingående moms ${desc}`,
- })
+ // Domestic: Debit ingående moms per rate group
+ const vatByRate = groupVatByRate(items, invoice.currency, invoice.exchange_rate)
+ for (const [rate, amount] of vatByRate) {
+ if (amount > 0) {
+ lines.push({
+ account_number: '2641',
+ debit_amount: Math.round(amount * 100) / 100,
+ credit_amount: 0,
+ line_description: `Ingående moms ${Math.round(rate * 100)}% ${desc}`,
+ })
+ }
+ }
}
- // Credit: Leverantörsskulder — balance guarantee: credit = sum of all debit lines
+ // Credit: Leverantörsskulder — balance guarantee: ensures sum(debits) === sum(credits)
+ // For reverse charge, intermediate credits (2614/2624/2634) already exist, so we subtract them
const totalDebits = lines.reduce((sum, l) => sum + l.debit_amount, 0)
+ const totalCredits = lines.reduce((sum, l) => sum + l.credit_amount, 0)
lines.push({
account_number: '2440',
debit_amount: 0,
- credit_amount: Math.round(totalDebits * 100) / 100,
+ credit_amount: Math.round((totalDebits - totalCredits) * 100) / 100,
line_description: desc,
...buildCurrencyMetadata(invoice.currency, isForeign ? invoice.total : undefined, invoice.exchange_rate),
})
@@ -242,29 +256,40 @@ export async function createSupplierInvoiceCashEntry(
})
}
- if (supplierType === 'eu_business' && invoice.reverse_charge) {
- // EU reverse charge: fiktiv moms entries (computed on SEK subtotal)
- const vatRate = getDefaultVatRate(invoice.vat_treatment)
- const subtotalSek = resolveSekAmount(invoice.subtotal, invoice.subtotal_sek, invoice.currency, invoice.exchange_rate)
- const reverseChargeLines = generateReverseChargeLines(subtotalSek, vatRate)
- lines.push(...reverseChargeLines)
+ const isReverseCharge = (supplierType === 'eu_business' || supplierType === 'non_eu_business') && invoice.reverse_charge
+
+ if (isReverseCharge) {
+ // EU/non-EU reverse charge: fiktiv moms entries per rate group
+ const vatByRate = groupVatByRate(items, invoice.currency, invoice.exchange_rate)
+ for (const [rate, amount] of vatByRate) {
+ if (rate > 0 && amount > 0) {
+ const rcLines = generateReverseChargeLines(amount / rate, rate)
+ lines.push(...rcLines)
+ }
+ }
} else if (invoice.vat_amount > 0) {
- // Domestic: Debit ingående moms (in SEK)
- const vatSek = resolveSekAmount(invoice.vat_amount, invoice.vat_amount_sek, invoice.currency, invoice.exchange_rate)
- lines.push({
- account_number: '2641',
- debit_amount: Math.round(vatSek * 100) / 100,
- credit_amount: 0,
- line_description: `Ingående moms ${desc}`,
- })
+ // Domestic: Debit ingående moms per rate group
+ const vatByRate = groupVatByRate(items, invoice.currency, invoice.exchange_rate)
+ for (const [rate, amount] of vatByRate) {
+ if (amount > 0) {
+ lines.push({
+ account_number: '2641',
+ debit_amount: Math.round(amount * 100) / 100,
+ credit_amount: 0,
+ line_description: `Ingående moms ${Math.round(rate * 100)}% ${desc}`,
+ })
+ }
+ }
}
- // Credit: Företagskonto — balance guarantee: credit = sum of all debit lines
+ // Credit: Företagskonto — balance guarantee: ensures sum(debits) === sum(credits)
+ // For reverse charge, intermediate credits (2614/2624/2634) already exist, so we subtract them
const totalDebits = lines.reduce((sum, l) => sum + l.debit_amount, 0)
+ const totalCredits = lines.reduce((sum, l) => sum + l.credit_amount, 0)
lines.push({
account_number: '1930',
debit_amount: 0,
- credit_amount: Math.round(totalDebits * 100) / 100,
+ credit_amount: Math.round((totalDebits - totalCredits) * 100) / 100,
line_description: desc,
})
@@ -321,34 +346,46 @@ export async function createSupplierCreditNoteEntry(
})
}
- if (supplierType === 'eu_business' && creditNote.reverse_charge) {
- // Reverse the fiktiv moms (swap debit/credit from registration)
- const vatRate = getDefaultVatRate(creditNote.vat_treatment)
- const absSubtotalSek = Math.abs(resolveSekAmount(creditNote.subtotal, creditNote.subtotal_sek, creditNote.currency, creditNote.exchange_rate))
- const vatAmount = Math.round(absSubtotalSek * vatRate * 100) / 100
- creditLines.push({
- account_number: '2645',
- debit_amount: 0,
- credit_amount: vatAmount,
- line_description: `Omvänd fiktiv ingående moms ${desc}`,
- })
- // 2614 is a debit (reversal of the output VAT credit)
- lines.push({
- account_number: '2614',
- debit_amount: vatAmount,
- credit_amount: 0,
- line_description: `Omvänd fiktiv utgående moms ${desc}`,
- })
+ const isReverseCharge = (supplierType === 'eu_business' || supplierType === 'non_eu_business') && creditNote.reverse_charge
+
+ if (isReverseCharge) {
+ // Reverse the fiktiv moms per rate group (swap debit/credit from registration)
+ const vatByRate = groupVatByRate(items, creditNote.currency, creditNote.exchange_rate, true)
+ for (const [rate, amount] of vatByRate) {
+ if (rate > 0 && amount > 0) {
+ // Determine the output account for this rate
+ let outputAccount: string
+ switch (rate) {
+ case 0.12: outputAccount = '2624'; break
+ case 0.06: outputAccount = '2634'; break
+ default: outputAccount = '2614'; break
+ }
+ creditLines.push({
+ account_number: '2645',
+ debit_amount: 0,
+ credit_amount: amount,
+ line_description: `Omvänd fiktiv ingående moms ${Math.round(rate * 100)}% ${desc}`,
+ })
+ lines.push({
+ account_number: outputAccount,
+ debit_amount: amount,
+ credit_amount: 0,
+ line_description: `Omvänd fiktiv utgående moms ${Math.round(rate * 100)}% ${desc}`,
+ })
+ }
+ }
} else {
- const absVat = Math.abs(resolveSekAmount(creditNote.vat_amount, creditNote.vat_amount_sek, creditNote.currency, creditNote.exchange_rate))
- if (absVat > 0) {
- // Credit: Ingående moms (reverse)
- creditLines.push({
- account_number: '2641',
- debit_amount: 0,
- credit_amount: Math.round(absVat * 100) / 100,
- line_description: `Ingående moms ${desc}`,
- })
+ // Domestic: Credit ingående moms per rate group (reverse)
+ const vatByRate = groupVatByRate(items, creditNote.currency, creditNote.exchange_rate, true)
+ for (const [rate, amount] of vatByRate) {
+ if (amount > 0) {
+ creditLines.push({
+ account_number: '2641',
+ debit_amount: 0,
+ credit_amount: amount,
+ line_description: `Ingående moms ${Math.round(rate * 100)}% ${desc}`,
+ })
+ }
}
}
@@ -377,13 +414,22 @@ export async function createSupplierCreditNoteEntry(
}
/**
- * Get default VAT rate from treatment string
+ * Group items by VAT rate and sum the VAT amount per rate.
+ * Returns a Map for generating per-rate journal lines.
*/
-function getDefaultVatRate(vatTreatment: string): number {
- switch (vatTreatment) {
- case 'standard_25': return 0.25
- case 'reduced_12': return 0.12
- case 'reduced_6': return 0.06
- default: return 0.25
+function groupVatByRate(
+ items: SupplierInvoiceItem[],
+ currency: string,
+ exchangeRate: number | null,
+ useAbsoluteValues = false
+): Map {
+ const vatByRate = new Map()
+ for (const item of items) {
+ const rate = item.vat_rate ?? 0.25
+ let itemSek = resolveSekAmount(item.line_total, null, currency, exchangeRate)
+ if (useAbsoluteValues) itemSek = Math.abs(itemSek)
+ const itemVat = Math.round(itemSek * rate * 100) / 100
+ vatByRate.set(rate, (vatByRate.get(rate) || 0) + itemVat)
}
+ return vatByRate
}
diff --git a/lib/events/types.ts b/lib/events/types.ts
index 89f4e699..7ffe2308 100644
--- a/lib/events/types.ts
+++ b/lib/events/types.ts
@@ -56,6 +56,11 @@ export type CoreEvent =
privateTotal: number;
userId: string;
}}
+ // Supplier Invoice Lifecycle
+ | { type: 'supplier_invoice.registered'; payload: { supplierInvoice: SupplierInvoice; userId: string } }
+ | { type: 'supplier_invoice.approved'; payload: { supplierInvoice: SupplierInvoice; userId: string } }
+ | { type: 'supplier_invoice.paid'; payload: { supplierInvoice: SupplierInvoice; paymentAmount: number; userId: string } }
+ | { type: 'supplier_invoice.credited'; payload: { supplierInvoice: SupplierInvoice; creditNote: SupplierInvoice; userId: string } }
// Supplier Invoice Inbox
| { type: 'supplier_invoice.received'; payload: { inboxItem: InvoiceInboxItem; userId: string } }
| { type: 'supplier_invoice.extracted'; payload: { inboxItem: InvoiceInboxItem; confidence: number; userId: string } }
diff --git a/lib/tax/expense-warnings.ts b/lib/tax/expense-warnings.ts
index 7a3883df..9b9c397c 100644
--- a/lib/tax/expense-warnings.ts
+++ b/lib/tax/expense-warnings.ts
@@ -53,9 +53,9 @@ const warningPatterns: {
pattern: /restaurang|lunch|middag|dinner|café|fika/i,
warning: {
category: 'Representation',
- warningLevel: 'info',
- message: 'Måltider kan vara avdragsgilla som representation med max 300 kr per person (exkl. moms)',
- legalBasis: 'IL 16 kap 2§',
+ warningLevel: 'warning',
+ message: 'Måltider kan vara avdragsgilla som representation med max 300 kr per person (exkl. moms). Momsen är inte avdragsgill sedan 2017.',
+ legalBasis: 'IL 16 kap 2§, ML 8:9',
},
},
{
diff --git a/supabase/migrations/20240101000046_supplier_invoice_overdue_cron.sql b/supabase/migrations/20240101000046_supplier_invoice_overdue_cron.sql
new file mode 100644
index 00000000..cd403650
--- /dev/null
+++ b/supabase/migrations/20240101000046_supplier_invoice_overdue_cron.sql
@@ -0,0 +1,30 @@
+-- Migration: supplier_invoice_overdue_cron
+-- Sets overdue status on supplier invoices past due_date via pg_cron
+
+-- Enable pg_cron extension
+CREATE EXTENSION IF NOT EXISTS pg_cron WITH SCHEMA pg_catalog;
+
+-- Grant usage to postgres role (required by Supabase)
+GRANT USAGE ON SCHEMA cron TO postgres;
+
+-- Function to update overdue supplier invoices
+CREATE OR REPLACE FUNCTION public.update_overdue_supplier_invoices()
+RETURNS void
+LANGUAGE plpgsql
+SECURITY DEFINER
+AS $$
+BEGIN
+ UPDATE supplier_invoices
+ SET status = 'overdue',
+ updated_at = NOW()
+ WHERE due_date < CURRENT_DATE
+ AND status IN ('registered', 'approved');
+END;
+$$;
+
+-- Schedule daily at 06:00 UTC (matches existing banking sync timing)
+SELECT cron.schedule(
+ 'update-overdue-supplier-invoices',
+ '0 6 * * *',
+ $$SELECT public.update_overdue_supplier_invoices()$$
+);