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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
f6a9889f0a
commit
a9bf24ce0b
@@ -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 <project>_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
|
||||
```
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
|
||||
@@ -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' }),
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
<div className="flex flex-col items-start">
|
||||
<span>{cat.label}</span>
|
||||
{cat.account && <span className="text-[10px] text-muted-foreground">{cat.account}</span>}
|
||||
</div>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
@@ -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}
|
||||
<div className="flex flex-col items-start">
|
||||
<span>{cat.label}</span>
|
||||
{cat.account && <span className="text-[10px] text-muted-foreground">{cat.account}</span>}
|
||||
</div>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
</button>
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -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}
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span>{cat.label}</span>
|
||||
{cat.account && <span className="text-xs text-muted-foreground">{cat.account}</span>}
|
||||
</div>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
@@ -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
|
||||
</button>
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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> = {}): 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)
|
||||
})
|
||||
})
|
||||
@@ -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> = {}): 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)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ const ACCOUNT_NAMES: Record<string, string> = {
|
||||
|
||||
// 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<string, string> = {
|
||||
'3001': 'Forsaljning 25%',
|
||||
'3002': 'Forsaljning 12%',
|
||||
'3003': 'Forsaljning 6%',
|
||||
'3004': 'Momsfri forsaljning',
|
||||
'3305': 'Exportforsaljning',
|
||||
'3308': 'EU-tjanster',
|
||||
'3900': 'Ovriga rorelseintakter',
|
||||
|
||||
@@ -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<rate, totalVatAmount> 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<number, number> {
|
||||
const vatByRate = new Map<number, number>()
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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 } }
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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()$$
|
||||
);
|
||||
Reference in New Issue
Block a user