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:
Jakob Wennberg
2026-03-03 17:08:26 +01:00
co-authored by Claude Opus 4.6
parent f6a9889f0a
commit a9bf24ce0b
23 changed files with 2293 additions and 113 deletions
+364
View File
@@ -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
```
+2
View File
@@ -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' })
+10
View File
@@ -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>
)}
+19 -18
View File
@@ -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)
}
})
})
+38 -5
View File
@@ -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
}
+2
View File
@@ -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',
+116 -70
View File
@@ -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
}
+5
View File
@@ -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 } }
+3 -3
View File
@@ -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()$$
);