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()$$ +);