Initial commit: Books accounting system with EventFlow CQRS

Backend (.NET 10):
- EventFlow CQRS/Event Sourcing with PostgreSQL
- GraphQL.NET API with mutations and queries
- Custom ReadModelSqlGenerator for snake_case PostgreSQL columns
- Hangfire for background job processing
- Integration tests with isolated test databases

Frontend (React/Vite):
- Initial project structure

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Nicolaj Hartmann 2026-01-18 02:52:30 +01:00
commit 66f6fa138d
126 changed files with 24741 additions and 0 deletions

204
.gitignore vendored Normal file
View file

@ -0,0 +1,204 @@
# ============================================
# .NET / C#
# ============================================
[Bb]in/
[Oo]bj/
[Oo]ut/
[Ll]og/
[Ll]ogs/
*.user
*.rsuser
*.suo
*.userosscache
*.sln.docstates
*.userprefs
*.pidb
*.booproj
*.svd
*.pdb
*.idb
*.ilk
*.meta
*.obj
*.pch
*.pdb
*.pgc
*.pgd
*.rsp
*.sbr
*.tlb
*.tli
*.tlh
*.tmp
*.vspscc
*.vssscc
.builds
*.DotSettings.user
# NuGet
*.nupkg
*.snupkg
**/[Pp]ackages/*
!**/[Pp]ackages/build/
.nuget/
project.lock.json
project.fragment.lock.json
artifacts/
# .NET Core
*.PublishPersistentProperties.json
# ============================================
# Node.js / Frontend
# ============================================
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
.pnpm-store/
.npm/
.yarn/
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/sdks
!.yarn/versions
# Vite
.vite/
dist/
dist-ssr/
*.local
# Build outputs
build/
.next/
.nuxt/
.output/
.cache/
# ============================================
# Docker
# ============================================
**/docker-compose.override.yml
**/.docker/
# ============================================
# Kubernetes
# ============================================
*.kubeconfig
kubeconfig
**/charts/*.tgz
**/*.decrypted.yaml
**/secrets.yaml
**/secrets.yml
!**/secrets.example.yaml
# Helm
**/Chart.lock
# ============================================
# IDEs and Editors
# ============================================
# JetBrains Rider / IntelliJ
.idea/
*.sln.iml
# Visual Studio Code
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
!.vscode/*.code-snippets
.history/
*.vsix
# Visual Studio
.vs/
*.VC.db
*.VC.VC.opendb
# ============================================
# OS Generated
# ============================================
# macOS
.DS_Store
.AppleDouble
.LSOverride
._*
.DocumentRevisions-V100
.fseventsd
.Spotlight-V100
.TemporaryItems
.Trashes
.VolumeIcon.icns
.com.apple.timemachine.donotpresent
.AppleDB
.AppleDesktop
Network Trash Folder
Temporary Items
.apdisk
# Windows
Thumbs.db
Thumbs.db:encryptable
ehthumbs.db
ehthumbs_vista.db
*.stackdump
[Dd]esktop.ini
$RECYCLE.BIN/
*.cab
*.msi
*.msix
*.msm
*.msp
*.lnk
# Linux
*~
.fuse_hidden*
.directory
.Trash-*
.nfs*
# ============================================
# Environment & Secrets
# ============================================
.env
.env.*
!.env.example
!.env.*.example
*.pem
*.key
*.p12
*.pfx
appsettings.Development.json
appsettings.Local.json
secrets.json
# ============================================
# Testing
# ============================================
coverage/
*.lcov
.nyc_output/
TestResults/
*.trx
# ============================================
# Misc
# ============================================
*.log
*.tmp
*.temp
*.swp
*.swo
*.bak
*.backup
.sass-cache/
*.css.map
*.js.map

78
README.md Normal file
View file

@ -0,0 +1,78 @@
# Books - Dansk Bogføringssystem
Et komplet bogføringssystem til danske virksomheder med support for regnskabsår, momsindberetning og SKAT-compliance.
## Projektstruktur
```
books/
├── frontend/ # React/TypeScript frontend
│ ├── src/
│ │ ├── components/
│ │ ├── pages/
│ │ ├── stores/
│ │ ├── hooks/
│ │ ├── lib/
│ │ └── types/
│ ├── package.json
│ └── vite.config.ts
├── backend/ # .NET 8 Web API
│ ├── Books.Api/
│ │ ├── Controllers/
│ │ ├── Models/
│ │ ├── Services/
│ │ └── Program.cs
│ ├── Books.slnx
│ └── BACKEND_REQUIREMENTS.md
└── README.md
```
## Teknologi Stack
### Frontend
- React 18 med TypeScript
- Ant Design komponenter
- Zustand state management
- Vite build tool
- Day.js til dato-håndtering
### Backend
- .NET 10 Web API
- C# 14
- Entity Framework Core
- PostgreSQL database
- JWT authentication
- SLNX solution format
## Kom i gang
### Frontend
```bash
cd frontend
npm install
npm run dev
```
### Backend
```bash
cd backend
dotnet restore
dotnet run --project Books.Api
```
## Features
- [x] Regnskabsår (Fiscal Years) - opret, luk, lås
- [x] Regnskabsperioder - månedlig, kvartalsvis, halvårlig, årlig
- [x] Kontoplan med danske standardkonti
- [x] Hurtig bogføring interface
- [ ] Årsafslutning med lukkeposter
- [ ] Dynamiske åbningsbalancer
- [ ] Momsindberetning
- [ ] SKAT integration
## Licens
Proprietary - Alle rettigheder forbeholdes.

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,122 @@
# Dinero Expense Accounts with Suggestions
# 104 accounts for AI-powered account suggestion
meta:
source: Dinero API
organizationId: 582288
accountType: expense
totalAccounts: 104
removedAccounts: 3220|5120|7140|7280
regionVariants:
varekøb: DK=2000|EU=2050|WORLD=2150
ydelseskøb: EU=2100|WORLD=2200
fragt: DK=2250|DK_VATFREE=2300|EU=2350
husleje: DK=5000|DK_VATFREE=5010
parkering: DK=6080|DK_VATFREE=6085
accounts[104]{number,name,category,vatCode,region,vatRubric,suggestions}:
2000,Vareforbrug,Variable omkostninger,I25,DK,,varer|indkøb|lager|goods|inventory|purchase|varekøb
2050,EU-erhvervelser varer (rubrik A-varer),Variable omkostninger,IEUV,EU,EU_BOX_A_GOODS,eu|europa|germany|france|italien|spain|netherlands|eu-erhvervelse
2100,EU-erhvervelser ydelser (rubrik A-ydelser),Variable omkostninger,IEUY,EU,EU_BOX_A_SERVICES,eu|europa|ydelse|service|eu-ydelse
2150,Varekøb verden,Variable omkostninger,IVV,WORLD,,usa|uk|kina|china|verden|import|world
2200,Ydelseskøb verden,Variable omkostninger,IVY,WORLD,,usa|uk|kina|verden|service|ydelse|world
2250,Fragt med moms,Variable omkostninger,I25,DK,,fragt|levering|shipping|delivery|postnord|gls|dao|bring|ups|dhl
2300,Fragt uden moms,Variable omkostninger,,DK,,fragt|shipping|delivery|momsfri
2350,Fragt - EU,Variable omkostninger,IEUV,EU,EU_BOX_A_GOODS,fragt|eu|shipping|dhl|ups|fedex
2400,Valutakursdifferencer import,Variable omkostninger,,DK,,valuta|kurs|currency|exchange
2450,Varelagerregulering,Variable omkostninger,,DK,,lager|regulering|inventory
2800,Fremmed arbejde,Variable omkostninger,I25,DK,,underleverandør|freelance|contractor|subcontractor|fremmed arbejde
3000,AM-indkomst,Lønomkostninger,,DK,,løn|salary|wage|payroll|am-indkomst
3020,Arbejdsgiver ATP,Lønomkostninger,,DK,,atp|arbejdsgiver
3040,Medarbejder ATP,Lønomkostninger,,DK,,atp|medarbejder
3060,Sygepenge mv.,Lønomkostninger,,DK,,sygepenge|sickness|sygedag
3070,Personalegoder herunder fri telefon,Lønomkostninger,,DK,,personalegode|fri telefon|benefit|goder
3080,B-honorar,Lønomkostninger,,DK,,b-honorar|honorar|fee
3090,Barsel,Lønomkostninger,,DK,,barsel|maternity|fødsel
3100,Feriepenge og SH,Lønomkostninger,,DK,,ferie|feriepenge|sh|vacation|holiday
3120,Pension,Lønomkostninger,,DK,,pension|pensionsbidrag|retirement
3140,Diæter/rejsegodtgørelse,Lønomkostninger,,DK,,diæt|rejsegodtgørelse|per diem|allowance
3160,Kørsel i egen bil (kilometergodtgørelse),Lønomkostninger,,DK,,kørsel|kilometer|km|mileage|km-godtgørelse|bil
3180,AER/AES/ATP-finansieringsbidrag,Lønomkostninger,,DK,,aer|aes|atp|finansieringsbidrag
3200,Arbejdstøj,Lønomkostninger,I25,DK,,arbejdstøj|uniform|workwear|tøj
3240,Mad under kursus/møder mv. fuldt fradrag,Lønomkostninger,I25,DK,,mad|kursus|møde|food|catering|frokost
3260,Gaver til personalet fuldt fradrag,Lønomkostninger,,DK,,gave|personale|gift|employee
3280,Uddannelsesudgifter,Lønomkostninger,I25,DK,,kursus|uddannelse|course|training|efteruddannelse
3300,Diverse vedr. ansatte med moms,Lønomkostninger,I25,DK,,diverse|personale|ansatte|moms
3320,Diverse vedr. ansatte uden moms,Lønomkostninger,,DK,,diverse|personale|ansatte|momsfri
3340,Regulering feriepenge,Lønomkostninger,,DK,,ferie|regulering|feriepenge
3355,Frokostordning til ansatte,Lønomkostninger,,DK,,frokost|frokostordning|lunch|kantine
4000,Annoncer og reklame,Salgsomkostninger,I25,DK,,annonce|reklame|marketing|ads|google|facebook|linkedin|meta|advertising
4020,Udsmykning i forbindelse med arrangementer/events,Salgsomkostninger,I25,DK,,udsmykning|event|arrangement|decoration
4040,Hotel personale fuldt fradrag,Salgsomkostninger,I25,DK,,hotel|overnatning|accommodation|booking.com|hotels.com|personale
4060,Hotel forretningsforbindelser delvis fradrag,Salgsomkostninger,I25,DK,,hotel|overnatning|kunde|client|forretningsforbindelse
4080,Konferencer,Salgsomkostninger,,DK,,konference|conference|seminar|event
4100,Messer,Salgsomkostninger,I25,DK,,messe|fair|exhibition|udstilling
4120,Restaurant personale fuldt fradrag,Salgsomkostninger,REP,DK,,restaurant|frokost|middag|lunch|dinner|café|personale
4140,Restaurant forretningsforbindelser delvis fradrag,Salgsomkostninger,REP,DK,,restaurant|repræsentation|kunde|client|forretningsforbindelse
4160,Mad i virksomheden til forretningsforbindelser delvis fradrag,Salgsomkostninger,I25,DK,,mad|virksomhed|forretningsforbindelse|catering
4180,Repræsentation gaver og blomster delvis fradrag,Salgsomkostninger,,DK,,gave|blomster|gift|flowers|repræsentation
4200,Anden fradragsberettiget repræsentation med moms,Salgsomkostninger,I25,DK,,repræsentation|representation
4220,Repræsentation diverse,Salgsomkostninger,,DK,,repræsentation|diverse
4240,Øvrige personaleomkostninger,Salgsomkostninger,I25,DK,,personale|personnel|staff
4260,Ej fradragsberettiget andel,Salgsomkostninger,,DK,,ikke-fradrag|non-deductible
4280,Rejseomkostninger,Salgsomkostninger,,DK,,rejse|travel|tur|trip
5000,Husleje,Lokaleomkostninger,I25,DK,,husleje|leje|rent|kontor|lokale
5010,Husleje uden moms,Lokaleomkostninger,,DK,,husleje|leje|rent|momsfri
5025,El,Lokaleomkostninger,I25,DK,,el|strøm|electricity|power|ørsted|norlys|ewii|andel
5030,Vand,Lokaleomkostninger,I25,DK,,vand|water|vandværk
5035,Varme,Lokaleomkostninger,I25,DK,,varme|gas|fjernvarme|heating|naturgas
5040,Elafgift,Lokaleomkostninger,,DK,,elafgift|afgift|tax
5045,Naturgas- og bygasafgift,Lokaleomkostninger,,DK,,gas|afgift|naturgas
5050,Vandafgift,Lokaleomkostninger,,DK,,vand|afgift|vandafgift
5060,Rengøring og affaldshåndtering,Lokaleomkostninger,I25,DK,,rengøring|cleaning|affald|waste|renovation
5080,Reparation og vedligeholdelse,Lokaleomkostninger,I25,DK,,reparation|vedligeholdelse|maintenance|repair
5100,Ejendomsskat,Lokaleomkostninger,,DK,,ejendomsskat|property tax|skat
5140,Mødelokaler,Lokaleomkostninger,I25,DK,,mødelokale|meeting room|konference
5160,Dekoration,Lokaleomkostninger,I25,DK,,dekoration|decoration|indretning
6000,Billeje (gulplade),Kørsel og rejser,I25,DK,,billeje|car rental|leje|hertz|avis|europcar|sixt|enterprise
6020,Brændstof (gulplade),Kørsel og rejser,I25,DK,,benzin|diesel|fuel|brændstof|ok|shell|q8|circle k|ingo
6040,Vedligeholdelse af bil (gulplade),Kørsel og rejser,I25,DK,,bil|vedligeholdelse|car|service|værksted|dæk
6060,Vægtafgift og forsikringer,Kørsel og rejser,,DK,,vægtafgift|forsikring|car insurance|bilforsikring
6080,Parkering (gulplade),Kørsel og rejser,I25,DK,,parkering|parking|p-afgift|easypark|parkman|apcoa
6085,Parkering uden moms,Kørsel og rejser,,DK,,parkering|parking|momsfri
6100,Broafgift,Kørsel og rejser,I25,DK,,bro|bridge|storebælt|øresund|toll|afgift
6120,Taxa,Kørsel og rejser,,DK,,taxa|taxi|uber|bolt|dantaxi|viggo|4x27
6140,Tog,Kørsel og rejser,,DK,,tog|dsb|train|rejsekort|billet
6160,Fly,Kørsel og rejser,,DK,,fly|flight|sas|norwegian|ryanair|lufthansa|flybillet
6180,Bus,Kørsel og rejser,,DK,,bus|movia|arriva|flixbus
6200,Færge,Kørsel og rejser,I25,DK,,færge|ferry|molslinjen|scandlines|bornholmslinjen
6400,Diverse transportomkostninger uden moms,Kørsel og rejser,,DK,,transport|diverse|transportation
7005,Revision og regnskabsmæssig assistance,Administrationsomkostninger,I25,DK,,revisor|revision|audit|regnskab|pwc|deloitte|ey|kpmg|bdo
7010,Advokat,Administrationsomkostninger,I25,DK,,advokat|lawyer|legal|juridisk|attorney
7020,Bogføringsassistance,Administrationsomkostninger,I25,DK,,bogføring|bookkeeping|regnskab|bogholder
7040,Konsulentbistand,Administrationsomkostninger,I25,DK,,konsulent|consultant|rådgivning|advisory|rådgiver
7060,Kontingenter inkl. moms,Administrationsomkostninger,I25,DK,,kontingent|medlemskab|membership|forening
7080,Kontingenter ekskl. moms,Administrationsomkostninger,,DK,,kontingent|medlemskab|momsfri
7100,Aviser,Administrationsomkostninger,,DK,,avis|newspaper|berlingske|politiken|jyllandsposten|børsen|dagblad
7120,Faglitteratur,Administrationsomkostninger,I25,DK,,bog|book|litteratur|amazon|saxo|tales|faglitteratur
7160,Erhvervsforsikringer,Administrationsomkostninger,,DK,,forsikring|insurance|tryg|topdanmark|if|codan|gjensidige|alm brand
7180,Fragt og kørsel,Administrationsomkostninger,I25,DK,,fragt|kørsel|shipping|transport
7200,Kontorartikler og tryksager,Administrationsomkostninger,I25,DK,,kontor|office|papir|printer|lyreco|staples|kontorartikler
7220,Porto og gebyrer,Administrationsomkostninger,,DK,,porto|gebyr|postage|fee|postnord
7240,Telefoni,Administrationsomkostninger,I25,DK,,telefon|mobil|phone|mobile|telia|telenor|3|telmore|cbs|lebara
7260,Beskatning af fri telefoni,Administrationsomkostninger,,DK,,telefon|beskatning|fri telefon
7300,Internet og webhotel,Administrationsomkostninger,I25,DK,,internet|hosting|domain|web|simply|one.com|cloudflare|aws|azure
7320,Køb af software,Administrationsomkostninger,I25,DK,,software|app|abonnement|subscription|saas|adobe|microsoft|slack|notion|google workspace|dropbox
7360,Offentlige bøder og gebyrer,Administrationsomkostninger,,DK,,bøde|gebyr|fine|offentlig
7380,Registrerede kassedifferencer,Administrationsomkostninger,,DK,,kasse|difference|cash
7400,Betalingsløsning,Administrationsomkostninger,I25,DK,,betaling|payment|stripe|mobilepay|nets|clearhaus|paypal
7420,Indløsere,Administrationsomkostninger,,DK,,indløser|acquirer|nets|clearhaus
7440,Licens,Administrationsomkostninger,,DK,,licens|license|rettighed
7460,Diverse inkl. moms,Administrationsomkostninger,I25,DK,,diverse|moms|miscellaneous
7480,Diverse ekskl. moms,Administrationsomkostninger,,DK,,diverse|momsfri|miscellaneous
7500,Generalforsamling bestyrelsesmøder ude i byen fuld fradrag,Administrationsomkostninger,REP,DK,,generalforsamling|bestyrelse|møde|board|meeting
7520,Generalforsamling i virksomhedens lokaler,Administrationsomkostninger,,DK,,generalforsamling|møde|lokale
7540,Bestyrelsesmøder i virksomhedens lokaler,Administrationsomkostninger,I25,DK,,bestyrelse|board|møde|lokale
7560,Bestyrelsesmøder ude i byen,Administrationsomkostninger,REP,DK,,bestyrelse|board|møde|restaurant
8040,Småanskaffelser (straksafskrivning),Afskrivninger,I25,DK,,computer|laptop|møbler|furniture|udstyr|equipment|apple|dell|lenovo|småanskaffelse
8050,Småanskaffelser med omvendt betalingspligt,Afskrivninger,OBPK,DK,,småanskaffelse|omvendt betalingspligt|reverse charge
9200,Bankrenter,Renteudgifter,,DK,,rente|bank|interest|bankrenter
9210,Leverandører mv.,Renteudgifter,,DK,,rente|leverandør|supplier|interest
9220,Ikke-fradragsberettigede renter,Renteudgifter,,DK,,rente|ikke-fradrag|non-deductible

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,199 @@
const fs = require('fs');
// Load accounts
const accounts = JSON.parse(fs.readFileSync('accounts.json', 'utf8'));
// Accounts to remove
const removeNumbers = [3220, 5120, 7140, 7280];
// Suggestions mapping by account number
const suggestionsMap = {
// Variable omkostninger
2000: ["varer", "indkøb", "lager", "goods", "inventory", "purchase", "varekøb"],
2050: ["eu", "europa", "germany", "france", "italien", "spain", "netherlands", "eu-erhvervelse"],
2100: ["eu", "europa", "ydelse", "service", "eu-ydelse"],
2150: ["usa", "uk", "kina", "china", "verden", "import", "world"],
2200: ["usa", "uk", "kina", "verden", "service", "ydelse", "world"],
2250: ["fragt", "levering", "shipping", "delivery", "postnord", "gls", "dao", "bring", "ups", "dhl"],
2300: ["fragt", "shipping", "delivery", "momsfri"],
2350: ["fragt", "eu", "shipping", "dhl", "ups", "fedex"],
2400: ["valuta", "kurs", "currency", "exchange"],
2450: ["lager", "regulering", "inventory"],
2800: ["underleverandør", "freelance", "contractor", "subcontractor", "fremmed arbejde"],
// Lønomkostninger
3000: ["løn", "salary", "wage", "payroll", "am-indkomst"],
3020: ["atp", "arbejdsgiver"],
3040: ["atp", "medarbejder"],
3060: ["sygepenge", "sickness", "sygedag"],
3070: ["personalegode", "fri telefon", "benefit", "goder"],
3080: ["b-honorar", "honorar", "fee"],
3090: ["barsel", "maternity", "fødsel"],
3100: ["ferie", "feriepenge", "sh", "vacation", "holiday"],
3120: ["pension", "pensionsbidrag", "retirement"],
3140: ["diæt", "rejsegodtgørelse", "per diem", "allowance"],
3160: ["kørsel", "kilometer", "km", "mileage", "km-godtgørelse", "bil"],
3180: ["aer", "aes", "atp", "finansieringsbidrag"],
3200: ["arbejdstøj", "uniform", "workwear", "tøj"],
3240: ["mad", "kursus", "møde", "food", "catering", "frokost"],
3260: ["gave", "personale", "gift", "employee"],
3280: ["kursus", "uddannelse", "course", "training", "efteruddannelse"],
3300: ["diverse", "personale", "ansatte", "moms"],
3320: ["diverse", "personale", "ansatte", "momsfri"],
3340: ["ferie", "regulering", "feriepenge"],
3355: ["frokost", "frokostordning", "lunch", "kantine"],
// Salgsomkostninger
4000: ["annonce", "reklame", "marketing", "ads", "google", "facebook", "linkedin", "meta", "advertising"],
4020: ["udsmykning", "event", "arrangement", "decoration"],
4040: ["hotel", "overnatning", "accommodation", "booking.com", "hotels.com", "personale"],
4060: ["hotel", "overnatning", "kunde", "client", "forretningsforbindelse"],
4080: ["konference", "conference", "seminar", "event"],
4100: ["messe", "fair", "exhibition", "udstilling"],
4120: ["restaurant", "frokost", "middag", "lunch", "dinner", "café", "personale"],
4140: ["restaurant", "repræsentation", "kunde", "client", "forretningsforbindelse"],
4160: ["mad", "virksomhed", "forretningsforbindelse", "catering"],
4180: ["gave", "blomster", "gift", "flowers", "repræsentation"],
4200: ["repræsentation", "representation"],
4220: ["repræsentation", "diverse"],
4240: ["personale", "personnel", "staff"],
4260: ["ikke-fradrag", "non-deductible"],
4280: ["rejse", "travel", "tur", "trip"],
// Lokaleomkostninger
5000: ["husleje", "leje", "rent", "kontor", "lokale"],
5010: ["husleje", "leje", "rent", "momsfri"],
5025: ["el", "strøm", "electricity", "power", "ørsted", "norlys", "ewii", "andel"],
5030: ["vand", "water", "vandværk"],
5035: ["varme", "gas", "fjernvarme", "heating", "naturgas"],
5040: ["elafgift", "afgift", "tax"],
5045: ["gas", "afgift", "naturgas"],
5050: ["vand", "afgift", "vandafgift"],
5060: ["rengøring", "cleaning", "affald", "waste", "renovation"],
5080: ["reparation", "vedligeholdelse", "maintenance", "repair"],
5100: ["ejendomsskat", "property tax", "skat"],
5140: ["mødelokale", "meeting room", "konference"],
5160: ["dekoration", "decoration", "indretning"],
// Kørsel og rejser
6000: ["billeje", "car rental", "leje", "hertz", "avis", "europcar", "sixt", "enterprise"],
6020: ["benzin", "diesel", "fuel", "brændstof", "ok", "shell", "q8", "circle k", "ingo"],
6040: ["bil", "vedligeholdelse", "car", "service", "værksted", "dæk"],
6060: ["vægtafgift", "forsikring", "car insurance", "bilforsikring"],
6080: ["parkering", "parking", "p-afgift", "easypark", "parkman", "apcoa"],
6085: ["parkering", "parking", "momsfri"],
6100: ["bro", "bridge", "storebælt", "øresund", "toll", "afgift"],
6120: ["taxa", "taxi", "uber", "bolt", "dantaxi", "viggo", "4x27"],
6140: ["tog", "dsb", "train", "rejsekort", "billet"],
6160: ["fly", "flight", "sas", "norwegian", "ryanair", "lufthansa", "flybillet"],
6180: ["bus", "movia", "arriva", "flixbus"],
6200: ["færge", "ferry", "molslinjen", "scandlines", "bornholmslinjen"],
6400: ["transport", "diverse", "transportation"],
// Administrationsomkostninger
7005: ["revisor", "revision", "audit", "regnskab", "pwc", "deloitte", "ey", "kpmg", "bdo"],
7010: ["advokat", "lawyer", "legal", "juridisk", "attorney"],
7020: ["bogføring", "bookkeeping", "regnskab", "bogholder"],
7040: ["konsulent", "consultant", "rådgivning", "advisory", "rådgiver"],
7060: ["kontingent", "medlemskab", "membership", "forening"],
7080: ["kontingent", "medlemskab", "momsfri"],
7100: ["avis", "newspaper", "berlingske", "politiken", "jyllandsposten", "børsen", "dagblad"],
7120: ["bog", "book", "litteratur", "amazon", "saxo", "tales", "faglitteratur"],
7160: ["forsikring", "insurance", "tryg", "topdanmark", "if", "codan", "gjensidige", "alm brand"],
7180: ["fragt", "kørsel", "shipping", "transport"],
7200: ["kontor", "office", "papir", "printer", "lyreco", "staples", "kontorartikler"],
7220: ["porto", "gebyr", "postage", "fee", "postnord"],
7240: ["telefon", "mobil", "phone", "mobile", "telia", "telenor", "3", "telmore", "cbs", "lebara"],
7260: ["telefon", "beskatning", "fri telefon"],
7300: ["internet", "hosting", "domain", "web", "simply", "one.com", "cloudflare", "aws", "azure"],
7320: ["software", "app", "abonnement", "subscription", "saas", "adobe", "microsoft", "slack", "notion", "google workspace", "dropbox"],
7360: ["bøde", "gebyr", "fine", "offentlig"],
7380: ["kasse", "difference", "cash"],
7400: ["betaling", "payment", "stripe", "mobilepay", "nets", "clearhaus", "paypal"],
7420: ["indløser", "acquirer", "nets", "clearhaus"],
7440: ["licens", "license", "rettighed"],
7460: ["diverse", "moms", "miscellaneous"],
7480: ["diverse", "momsfri", "miscellaneous"],
7500: ["generalforsamling", "bestyrelse", "møde", "board", "meeting"],
7520: ["generalforsamling", "møde", "lokale"],
7540: ["bestyrelse", "board", "møde", "lokale"],
7560: ["bestyrelse", "board", "møde", "restaurant"],
// Afskrivninger
8040: ["computer", "laptop", "møbler", "furniture", "udstyr", "equipment", "apple", "dell", "lenovo", "småanskaffelse"],
8050: ["småanskaffelse", "omvendt betalingspligt", "reverse charge"],
// Renteudgifter
9200: ["rente", "bank", "interest", "bankrenter"],
9210: ["rente", "leverandør", "supplier", "interest"],
9220: ["rente", "ikke-fradrag", "non-deductible"]
};
// VAT rubric mapping for EU accounts
const vatRubricMap = {
2050: "EU_BOX_A_GOODS",
2100: "EU_BOX_A_SERVICES",
2350: "EU_BOX_A_GOODS"
};
// Region variants mapping
const regionVariants = {
2000: { category: "varekøb", EU: 2050, WORLD: 2150 },
2050: { category: "varekøb", DK: 2000, WORLD: 2150 },
2150: { category: "varekøb", DK: 2000, EU: 2050 },
2100: { category: "ydelseskøb", WORLD: 2200 },
2200: { category: "ydelseskøb", EU: 2100 },
2250: { category: "fragt", DK_VATFREE: 2300, EU: 2350 },
2300: { category: "fragt", DK: 2250, EU: 2350 },
2350: { category: "fragt", DK: 2250, DK_VATFREE: 2300 },
5000: { category: "husleje", DK_VATFREE: 5010 },
5010: { category: "husleje", DK: 5000 },
6080: { category: "parkering", DK_VATFREE: 6085 },
6085: { category: "parkering", DK: 6080 }
};
// Process accounts
const enrichedAccounts = accounts
.filter(acc => !removeNumbers.includes(acc.number))
.map(acc => {
// Determine region
let region = "DK";
if (acc.defaultVatTypeRegion === "EU") {
region = "EU";
} else if (acc.defaultVatTypeRegion === "World") {
region = "WORLD";
}
// Build enriched account
const enriched = {
id: acc.id,
number: acc.number,
name: acc.name,
categoryName: acc.categoryName,
defaultVatCode: acc.defaultVatCode,
region: region,
suggestions: suggestionsMap[acc.number] || []
};
// Add vatRubric for EU accounts
if (vatRubricMap[acc.number]) {
enriched.vatRubric = vatRubricMap[acc.number];
}
// Add related accounts for region variants
if (regionVariants[acc.number]) {
enriched.regionVariants = regionVariants[acc.number];
}
return enriched;
});
// Save output
fs.writeFileSync(
'accounts-with-suggestions.json',
JSON.stringify(enrichedAccounts, null, 2),
'utf8'
);
console.log(`Processed ${enrichedAccounts.length} accounts (removed ${removeNumbers.length})`);
console.log('Saved to accounts-with-suggestions.json');

View file

@ -0,0 +1,45 @@
{
"vendor": {
"name": "EWII A/S",
"address": "Kokbjerg 30, 6000 Kolding",
"country": "DK",
"vatNumber": "DK28297947"
},
"invoice": {
"type": "reminder",
"number": null,
"date": "2024-06-06",
"dueDate": "2024-06-17",
"currency": "DKK",
"totalAmount": 3361.04,
"vatAmount": null,
"netAmount": null
},
"customer": {
"name": "Nicolaj Helmer Hartmann",
"address": "Mejsevej 2, 8370 Hadsten",
"customerNumber": "400143507"
},
"lineItems": [
{
"description": "El restance - Rykker 2",
"amount": 3361.04
}
],
"suggestedAccount": {
"number": 5025,
"name": "El",
"vatCode": "I25",
"region": "DK",
"confidence": 0.95,
"reasoning": "EWII er el-leverandør, betalingspåmindelse vedrører el-forbrug"
},
"alternativeAccounts": [
{
"number": 5040,
"name": "Elafgift",
"confidence": 0.3,
"reasoning": "Kan indeholde elafgift, men primært el-forbrug"
}
]
}

View file

@ -0,0 +1,50 @@
{
"vendor": {
"name": "Microsoft Ireland Operations Ltd",
"address": "One Microsoft Place, South County Business Park, Leopardstown, Dublin 18, D18 P521, Ireland",
"country": "IE",
"vatNumber": "IE8256796U"
},
"invoice": {
"type": "invoice",
"number": "E0800QGJD1",
"date": "2024-01-03",
"dueDate": "2024-01-03",
"currency": "DKK",
"totalAmount": 69.90,
"vatAmount": 0.00,
"netAmount": 69.90,
"vatNote": "omvendt betalingspligt"
},
"customer": {
"name": "Softwarehuset",
"address": "Mejsevej 2, 8370 Hadsten",
"vatNumber": "DK38059726"
},
"lineItems": [
{
"description": "Office 365 E1 - Månedligt abonnementsgebyr",
"period": "23-12-2023 - 22-01-2024",
"quantity": 1,
"unitPrice": 69.90,
"amount": 69.90
}
],
"suggestedAccount": {
"number": 2100,
"name": "EU-erhvervelser ydelser (rubrik A-ydelser)",
"vatCode": "IEUY",
"region": "EU",
"vatRubric": "EU_BOX_A_SERVICES",
"confidence": 0.97,
"reasoning": "Software/cloud-service fra EU (Irland) med omvendt betalingspligt - skal bogføres som EU-ydelseskøb"
},
"alternativeAccounts": [
{
"number": 7320,
"name": "Køb af software",
"confidence": 0.4,
"reasoning": "Ville være korrekt hvis leverandør var dansk, men EU-leverandør kræver EU-konto pga. momsindberetning"
}
]
}

View file

@ -0,0 +1,65 @@
{
"vendor": {
"name": "Waoo (Fibia P/S)",
"address": "Energivej 33, 4690 Haslev",
"country": "DK",
"vatNumber": "DK36058552"
},
"invoice": {
"type": "invoice",
"number": "08338955",
"date": "2025-08-07",
"dueDate": "2025-09-01",
"currency": "DKK",
"totalAmount": 1284.00,
"vatAmount": 256.80,
"netAmount": 1027.20
},
"customer": {
"name": "Nicolaj Hartmann",
"address": "Mejsevej 2, Hadbjerg, 8370 Hadsten",
"customerNumber": "1299453",
"contractNumber": "861418"
},
"lineItems": [
{
"description": "Waoo Fiber 1000",
"period": "01.09.2025 - 30.09.2025",
"amount": 389.00
},
{
"description": "Waoo Fiber 1000",
"period": "01.10.2025 - 31.10.2025",
"amount": 389.00
},
{
"description": "Waoo Fiber 1000",
"period": "01.11.2025 - 30.11.2025",
"amount": 389.00
},
{
"description": "Abonnement Fast IP adresse",
"period": "01.09.2025 - 30.09.2025",
"amount": 39.00
},
{
"description": "Abonnement Fast IP adresse",
"period": "01.10.2025 - 31.10.2025",
"amount": 39.00
},
{
"description": "Abonnement Fast IP adresse",
"period": "01.11.2025 - 30.11.2025",
"amount": 39.00
}
],
"suggestedAccount": {
"number": 7300,
"name": "Internet og webhotel",
"vatCode": "I25",
"region": "DK",
"confidence": 0.98,
"reasoning": "Fiber internet abonnement fra dansk udbyder med 25% moms"
},
"alternativeAccounts": []
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 497 KiB

View file

@ -0,0 +1,355 @@
# Backend Krav - Dansk Bogføringssystem
## Overblik
Dette dokument beskriver backend-kravene for det danske bogføringssystem. Frontend er implementeret i React/TypeScript med Ant Design og Zustand state management.
---
## 1. Regnskabsår (Fiscal Years)
### 1.1 Data Model
```typescript
interface FiscalYear {
id: string;
companyId: string;
name: string; // "2025" eller "2024/2025"
startDate: string; // ISO date "YYYY-MM-DD"
endDate: string; // ISO date "YYYY-MM-DD"
status: 'open' | 'closed' | 'locked';
openingBalancePosted: boolean;
closingDate?: string; // Når året blev lukket
closedBy?: string; // Bruger ID
createdAt: string;
updatedAt: string;
}
```
### 1.2 API Endpoints
| Endpoint | Metode | Beskrivelse |
|----------|--------|-------------|
| `/api/fiscal-years` | GET | Hent alle regnskabsår for aktiv virksomhed |
| `/api/fiscal-years` | POST | Opret nyt regnskabsår |
| `/api/fiscal-years/:id` | GET | Hent specifikt regnskabsår |
| `/api/fiscal-years/:id` | PATCH | Opdater regnskabsår |
| `/api/fiscal-years/:id/close` | POST | Luk regnskabsår (årsafslutning) |
| `/api/fiscal-years/:id/reopen` | POST | Genåbn lukket regnskabsår |
| `/api/fiscal-years/:id/lock` | POST | Lås regnskabsår permanent |
### 1.3 Forretningsregler
#### Oprettelse
- Nye regnskabsår må IKKE overlappe med eksisterende
- Regnskabsår der "rører" ved grænsen (slutter/starter samme dag) er TILLADT
- Understøt både kalenderår (jan-dec) og skæve regnskabsår (fx jul-jun)
- Valider at regnskabsåret er mellem 300-400 dage (advarsel, ikke fejl)
#### Årsafslutning (Year-End Closing)
**KRITISK**: Ved årsafslutning skal backend:
1. **Validere** at regnskabsåret kan lukkes:
- Ikke allerede låst
- Advar om åbne perioder (men tillad lukning)
- Advar om ikke-afstemte transaktioner
2. **Oprette lukkeposter** (closing entries):
- Luk alle indtægtskonti til resultatoverførselskonto
- Luk alle udgiftskonti til resultatoverførselskonto
- Gem som normale transaktioner med særlig markering (`isClosingEntry: true`)
3. **Generere åbningsbalancer** til næste regnskabsår:
- Beregn slutsaldo for alle balancekonti (aktiver, passiver, egenkapital)
- Nulstil resultatkonti (indtægter, udgifter, vareforbrug, personale, finansielle)
- Opret åbningsposteringer i næste regnskabsår (`isOpeningBalance: true`)
4. **Opdatere status**:
- Sæt regnskabsår status til 'closed' eller 'locked'
- Luk alle tilhørende regnskabsperioder
- Sæt `openingBalancePosted: true` på næste regnskabsår
#### Genåbning
- Kun 'closed' regnskabsår kan genåbnes (ikke 'locked')
- Ved genåbning skal åbningsbalancer i næste år opdateres automatisk
- Log hvem der genåbnede og hvornår
### 1.4 Dynamiske Åbningsbalancer
Frontend forventer at åbningsbalancer opdateres automatisk når der bogføres i et tidligere år (som Dinero):
```
Scenarie:
1. Regnskabsår 2024 er lukket, 2025 er åbent
2. Revisor finder fejl og bogfører korrektion i 2024
3. Backend skal automatisk genberegne åbningsbalancen for 2025
```
---
## 2. Regnskabsperioder (Accounting Periods)
### 2.1 Data Model
```typescript
interface AccountingPeriod {
id: string;
fiscalYearId: string;
companyId: string;
name: string; // "Januar 2025", "Q1 2025"
periodNumber: number; // 1-12 for månedlig
startDate: string;
endDate: string;
status: 'future' | 'open' | 'closed' | 'locked';
closedAt?: string;
closedBy?: string;
reopenedAt?: string;
reopenedBy?: string;
lockedAt?: string;
lockedBy?: string;
createdAt: string;
updatedAt: string;
}
type PeriodFrequency = 'monthly' | 'quarterly' | 'half-yearly' | 'yearly';
```
### 2.2 API Endpoints
| Endpoint | Metode | Beskrivelse |
|----------|--------|-------------|
| `/api/periods` | GET | Hent perioder (filtrer på fiscalYearId) |
| `/api/periods/:id` | PATCH | Opdater periode |
| `/api/periods/:id/close` | POST | Luk periode |
| `/api/periods/:id/reopen` | POST | Genåbn periode |
| `/api/periods/:id/lock` | POST | Lås periode |
### 2.3 Forretningsregler
- Perioder genereres automatisk ved oprettelse af regnskabsår
- Understøt: månedlig (12), kvartalsvis (4), halvårlig (2), årlig (1)
- Perioder kan kun lukkes i rækkefølge (periode 2 kræver periode 1 lukket)
- Låste perioder kan IKKE genåbnes
---
## 3. Transaktioner med Periode-Reference
### 3.1 Udvidet Transaction Model
```typescript
interface Transaction {
id: string;
companyId: string;
fiscalYearId: string; // PÅKRÆVET - hvilket regnskabsår
periodId: string; // PÅKRÆVET - hvilken periode
journalEntryNumber: number;
date: string;
description: string;
reference?: string;
lines: TransactionLine[];
isVoided: boolean;
isReconciled: boolean;
isClosingEntry?: boolean; // NY - markering for lukkepost
isOpeningBalance?: boolean; // NY - markering for åbningsbalance
createdAt: string;
updatedAt: string;
createdBy: string;
}
```
### 3.2 Forretningsregler for Bogføring
```typescript
interface PostingValidation {
allowed: boolean;
reason?: string;
reasonDanish?: string;
}
```
Backend skal validere ved bogføring:
1. Find periode for transaktionsdato
2. Tjek om perioden er åben
3. Tjek om regnskabsåret er åbent
4. Returner fejl med dansk besked hvis ikke tilladt
---
## 4. Kontoplan (Chart of Accounts)
### 4.1 Data Model
```typescript
interface Account {
id: string;
companyId: string;
accountNumber: string; // "1000", "3900"
name: string;
type: AccountType;
parentId?: string;
description?: string;
vatCodeId?: string;
isActive: boolean;
isSystemAccount: boolean; // Kan ikke slettes
balance: number; // Beregnet felt
createdAt: string;
updatedAt: string;
}
type AccountType =
| 'asset' // Aktiver
| 'liability' // Passiver
| 'equity' // Egenkapital
| 'revenue' // Indtægter
| 'cogs' // Vareforbrug
| 'expense' // Udgifter
| 'personnel' // Personaleomkostninger
| 'financial' // Finansielle poster
| 'extraordinary'; // Ekstraordinære poster
```
### 4.2 Systemkonti
Følgende konti skal oprettes automatisk:
- `3900` - Overført resultat (equity) - bruges til årsafslutning
- Åbningsbalancekonto for primo-posteringer
---
## 5. Moms (VAT)
### 5.1 Data Model
```typescript
interface VATCode {
id: string;
companyId: string;
code: string; // "S25", "K25"
name: string;
rate: number; // 0.25 for 25%
type: 'sales' | 'purchase' | 'eu_sales' | 'eu_purchase' | 'reverse_charge';
accountId: string; // Momskonto
isActive: boolean;
}
interface VATPeriod {
id: string;
companyId: string;
startDate: string;
endDate: string;
dueDate: string; // Indberetningsfrist
status: 'open' | 'submitted' | 'paid';
salesVAT: number; // Beregnet
purchaseVAT: number; // Beregnet
netVAT: number; // salesVAT - purchaseVAT
submittedAt?: string;
paidAt?: string;
}
```
### 5.2 SKAT Integration (Fremtidig)
- Momsindberetning til SKAT via NemVirksomhed API
- Kvartalsvise eller månedlige momsperioder baseret på virksomhedsstørrelse
---
## 6. Virksomhed (Company)
### 6.1 Data Model
```typescript
interface Company {
id: string;
name: string;
cvr: string; // Dansk CVR-nummer
address?: string;
postalCode?: string;
city?: string;
country: string; // Default "DK"
fiscalYearStart: number; // Måned 1-12 (default 1 for januar)
currency: string; // Default "DKK"
vatRegistered: boolean;
vatPeriodFrequency: 'monthly' | 'quarterly' | 'half-yearly';
createdAt: string;
updatedAt: string;
}
```
---
## 7. Fejlhåndtering
Alle fejlbeskeder skal være tilgængelige på både engelsk og dansk:
```typescript
interface APIError {
code: string;
message: string;
messageDanish: string;
details?: Record<string, unknown>;
}
```
### Eksempler på fejlkoder
| Kode | Engelsk | Dansk |
|------|---------|-------|
| `PERIOD_LOCKED` | Period is locked | Perioden er låst |
| `FISCAL_YEAR_LOCKED` | Fiscal year is locked | Regnskabsåret er låst |
| `OVERLAP_EXISTS` | Overlaps with existing fiscal year | Overlapper med eksisterende regnskabsår |
| `UNBALANCED_ENTRY` | Debit and credit must be equal | Debet og kredit skal være ens |
| `NO_COMPANY_SELECTED` | No company selected | Ingen virksomhed valgt |
---
## 8. Prioriteret Implementeringsrækkefølge
### Fase 1: Grundlæggende (MVP)
1. Company CRUD
2. Account CRUD med standard kontoplan
3. Transaction CRUD med validering
4. FiscalYear CRUD
### Fase 2: Periode-management
1. AccountingPeriod generering og CRUD
2. Periode-validering ved bogføring
3. Periode lukning/åbning
### Fase 3: Årsafslutning
1. Closing entries generering og bogføring
2. Opening balance beregning og bogføring
3. Dynamisk åbningsbalance-opdatering
### Fase 4: Moms
1. VATCode CRUD
2. VATPeriod generering
3. Momsberegning og -rapportering
---
## 9. Tekniske Krav
### Authentication
- JWT-baseret authentication
- Multi-tenant support (bruger kan have adgang til flere virksomheder)
### Database
- PostgreSQL anbefales
- Soft delete på alle entiteter
- Audit trail (createdBy, updatedBy, deletedBy)
### API Format
- RESTful JSON API
- GraphQL som alternativ (valgfrit)
- Pagination på liste-endpoints
- Filtering og sorting support
### Valuta
- Alle beløb i øre/cents (integers) for at undgå floating point problemer
- Frontend konverterer til/fra DKK ved visning
---
*Sidst opdateret: 17. januar 2026*

View file

@ -0,0 +1,37 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>14</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<!-- Test Framework -->
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
<!-- Integration Testing -->
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.0" />
<PackageReference Include="FluentAssertions" Version="8.3.0" />
<!-- Hangfire In-Memory for Tests -->
<PackageReference Include="Hangfire.InMemory" Version="1.0.0" />
<!-- PostgreSQL for Test Database -->
<PackageReference Include="Npgsql" Version="10.0.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Books.Api\Books.Api.csproj" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,260 @@
using Books.Api.Domain.Companies;
using Books.Api.EventFlow.Repositories;
using Books.Api.Tests.Helpers;
using Books.Api.Tests.Infrastructure;
using FluentAssertions;
using Microsoft.Extensions.DependencyInjection;
namespace Books.Api.Tests.GraphQL;
/// <summary>
/// Integration tests for Company GraphQL operations.
/// Each test class runs with its own isolated database.
/// </summary>
[Trait("Category", "Integration")]
public class CompanyGraphQLTests(TestWebApplicationFactory factory)
: IntegrationTestBase(factory)
{
[Fact]
public async Task Query_Companies_ReturnsEmptyList_WhenNoCompaniesExist()
{
// Arrange
var graphqlClient = new GraphQLTestClient(Client);
// Act
var response = await graphqlClient.QueryAsync<CompaniesResponse>("""
query {
companies {
id
name
}
}
""");
// Assert
response.EnsureNoErrors();
response.Data.Should().NotBeNull();
response.Data!.Companies.Should().BeEmpty();
}
[Fact]
public async Task Mutation_CreateCompany_CreatesCompanySuccessfully()
{
// Arrange
var graphqlClient = new GraphQLTestClient(Client);
// Act
var response = await graphqlClient.MutateAsync<CreateCompanyResponse>("""
mutation CreateCompany($input: CreateCompanyInput!) {
createCompany(input: $input) {
id
name
cvr
country
currency
fiscalYearStartMonth
}
}
""",
new
{
input = new
{
name = "Test Virksomhed A/S",
cvr = "12345678",
country = "DK",
currency = "DKK",
fiscalYearStartMonth = 1
}
});
// Assert
response.EnsureNoErrors();
response.Data.Should().NotBeNull();
response.Data!.CreateCompany.Should().NotBeNull();
response.Data.CreateCompany!.Name.Should().Be("Test Virksomhed A/S");
response.Data.CreateCompany.Cvr.Should().Be("12345678");
response.Data.CreateCompany.Country.Should().Be("DK");
response.Data.CreateCompany.Currency.Should().Be("DKK");
response.Data.CreateCompany.FiscalYearStartMonth.Should().Be(1);
}
[Fact]
public async Task Query_Company_ReturnsCompany_AfterCreation()
{
// Arrange
var graphqlClient = new GraphQLTestClient(Client);
// Create a company first
var createResponse = await graphqlClient.MutateAsync<CreateCompanyResponse>("""
mutation CreateCompany($input: CreateCompanyInput!) {
createCompany(input: $input) {
id
name
}
}
""",
new
{
input = new
{
name = "Query Test Virksomhed",
cvr = "87654321"
}
});
createResponse.EnsureNoErrors();
var companyId = createResponse.Data!.CreateCompany!.Id;
// Act - Query the company by ID (with eventual consistency)
var company = await Eventually.GetAsync(async () =>
{
var response = await graphqlClient.QueryAsync<CompanyResponse>("""
query GetCompany($id: ID!) {
company(id: $id) {
id
name
cvr
}
}
""",
new { id = companyId });
return response.Data?.Company;
});
// Assert
company.Should().NotBeNull();
company!.Name.Should().Be("Query Test Virksomhed");
company.Cvr.Should().Be("87654321");
}
[Fact]
public async Task Query_Companies_ReturnsAllCompanies_AfterMultipleCreations()
{
// Arrange
var graphqlClient = new GraphQLTestClient(Client);
// Create multiple companies with unique names for this test
var company1Name = $"ListTest-Virksomhed-{Guid.NewGuid():N}";
var company2Name = $"ListTest-Virksomhed-{Guid.NewGuid():N}";
await graphqlClient.MutateAsync<CreateCompanyResponse>($$"""
mutation { createCompany(input: { name: "{{company1Name}}" }) { id } }
""");
await graphqlClient.MutateAsync<CreateCompanyResponse>($$"""
mutation { createCompany(input: { name: "{{company2Name}}" }) { id } }
""");
// Act - Wait for eventual consistency (at least 2 companies with our prefix)
var companies = await Eventually.GetAsync(
async () =>
{
var response = await graphqlClient.QueryAsync<CompaniesResponse>("""
query {
companies {
id
name
}
}
""");
var allCompanies = response.Data?.Companies ?? [];
var ourCompanies = allCompanies.Where(c => c.Name.StartsWith("ListTest-")).ToList();
return ourCompanies.Count >= 2 ? ourCompanies : null;
});
// Assert - verify our specific companies are present
companies.Should().Contain(c => c.Name == company1Name);
companies.Should().Contain(c => c.Name == company2Name);
}
[Fact]
public async Task Mutation_UpdateCompany_UpdatesCompanySuccessfully()
{
// Arrange
var graphqlClient = new GraphQLTestClient(Client);
// Create a company first
var createResponse = await graphqlClient.MutateAsync<CreateCompanyResponse>("""
mutation { createCompany(input: { name: "Original Name" }) { id } }
""");
createResponse.EnsureNoErrors();
var companyId = createResponse.Data!.CreateCompany!.Id;
// Wait for the company to be created
await Eventually.GetAsync(async () =>
{
var repository = GetService<ICompanyRepository>();
return await repository.GetByIds([CompanyId.With(companyId)]);
});
// Act - Update the company
var updateResponse = await graphqlClient.MutateAsync<UpdateCompanyResponse>("""
mutation UpdateCompany($id: ID!, $input: UpdateCompanyInput!) {
updateCompany(id: $id, input: $input) {
id
name
cvr
}
}
""",
new
{
id = companyId,
input = new
{
name = "Updated Name",
cvr = "11111111"
}
});
// Assert
updateResponse.EnsureNoErrors();
// Wait for eventual consistency and verify update
var updatedCompany = await Eventually.GetAsync(async () =>
{
var repository = GetService<ICompanyRepository>();
var companies = await repository.GetByIds([CompanyId.With(companyId)]);
var company = companies.First();
return company?.Name == "Updated Name" ? company : null;
});
updatedCompany.Should().NotBeNull();
updatedCompany!.Name.Should().Be("Updated Name");
updatedCompany.Cvr.Should().Be("11111111");
}
// Response DTOs for deserialization
private class CompaniesResponse
{
public List<CompanyDto> Companies { get; set; } = [];
}
private class CompanyResponse
{
public CompanyDto? Company { get; set; }
}
private class CreateCompanyResponse
{
public CompanyDto? CreateCompany { get; set; }
}
private class UpdateCompanyResponse
{
public CompanyDto? UpdateCompany { get; set; }
}
private class CompanyDto
{
public string Id { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public string? Cvr { get; set; }
public string? Country { get; set; }
public string? Currency { get; set; }
public int? FiscalYearStartMonth { get; set; }
}
}

View file

@ -0,0 +1,89 @@
namespace Books.Api.Tests.Helpers;
/// <summary>
/// Helper for testing eventually consistent systems.
/// Polls a condition until it's met or times out.
/// </summary>
public static class Eventually
{
/// <summary>
/// Polls a condition until it returns true or times out.
/// </summary>
public static async Task AssertAsync(
Func<Task<bool>> condition,
TimeSpan? timeout = null,
TimeSpan? pollInterval = null,
string? failMessage = null)
{
timeout ??= TimeSpan.FromSeconds(5);
pollInterval ??= TimeSpan.FromMilliseconds(50);
var start = DateTime.UtcNow;
while (DateTime.UtcNow - start < timeout)
{
if (await condition())
return;
await Task.Delay(pollInterval.Value);
}
throw new TimeoutException(
failMessage ?? $"Condition was not met within {timeout}.");
}
/// <summary>
/// Polls until a non-null result is returned or times out.
/// </summary>
public static async Task<T> GetAsync<T>(
Func<Task<T?>> getter,
TimeSpan? timeout = null,
TimeSpan? pollInterval = null,
string? failMessage = null) where T : class
{
timeout ??= TimeSpan.FromSeconds(10);
pollInterval ??= TimeSpan.FromMilliseconds(100);
var start = DateTime.UtcNow;
while (DateTime.UtcNow - start < timeout)
{
var result = await getter();
if (result != null)
return result;
await Task.Delay(pollInterval.Value);
}
throw new TimeoutException(
failMessage ?? $"Expected non-null result was not returned within {timeout}.");
}
/// <summary>
/// Polls until a collection has the expected count or times out.
/// </summary>
public static async Task<IReadOnlyList<T>> GetListAsync<T>(
Func<Task<IReadOnlyList<T>>> getter,
int expectedCount,
TimeSpan? timeout = null,
TimeSpan? pollInterval = null,
string? failMessage = null)
{
timeout ??= TimeSpan.FromSeconds(10);
pollInterval ??= TimeSpan.FromMilliseconds(100);
var start = DateTime.UtcNow;
while (DateTime.UtcNow - start < timeout)
{
var result = await getter();
if (result.Count >= expectedCount)
return result;
await Task.Delay(pollInterval.Value);
}
throw new TimeoutException(
failMessage ?? $"Expected {expectedCount} items but condition was not met within {timeout}.");
}
}

View file

@ -0,0 +1,125 @@
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Books.Api.Tests.Helpers;
/// <summary>
/// Helper client for executing GraphQL queries and mutations in tests.
/// </summary>
public class GraphQLTestClient(HttpClient httpClient)
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
/// <summary>
/// Executes a GraphQL query and returns the typed result.
/// </summary>
public async Task<GraphQLResponse<T>> QueryAsync<T>(
string query,
object? variables = null,
CancellationToken cancellationToken = default)
{
return await ExecuteAsync<T>(query, variables, cancellationToken);
}
/// <summary>
/// Executes a GraphQL mutation and returns the typed result.
/// </summary>
public async Task<GraphQLResponse<T>> MutateAsync<T>(
string mutation,
object? variables = null,
CancellationToken cancellationToken = default)
{
return await ExecuteAsync<T>(mutation, variables, cancellationToken);
}
/// <summary>
/// Executes a GraphQL operation and returns the raw JSON response.
/// </summary>
public async Task<string> ExecuteRawAsync(
string query,
object? variables = null,
CancellationToken cancellationToken = default)
{
var request = new GraphQLRequest(query, variables);
var json = JsonSerializer.Serialize(request, JsonOptions);
var content = new StringContent(json, Encoding.UTF8, "application/json");
var response = await httpClient.PostAsync("/graphql", content, cancellationToken);
return await response.Content.ReadAsStringAsync(cancellationToken);
}
private async Task<GraphQLResponse<T>> ExecuteAsync<T>(
string query,
object? variables,
CancellationToken cancellationToken)
{
var request = new GraphQLRequest(query, variables);
var json = JsonSerializer.Serialize(request, JsonOptions);
var content = new StringContent(json, Encoding.UTF8, "application/json");
var response = await httpClient.PostAsync("/graphql", content, cancellationToken);
var responseJson = await response.Content.ReadAsStringAsync(cancellationToken);
var result = JsonSerializer.Deserialize<GraphQLResponse<T>>(responseJson, JsonOptions);
return result ?? throw new InvalidOperationException("Failed to deserialize GraphQL response");
}
}
public record GraphQLRequest(
[property: JsonPropertyName("query")] string Query,
[property: JsonPropertyName("variables")] object? Variables = null);
public class GraphQLResponse<T>
{
[JsonPropertyName("data")]
public T? Data { get; set; }
[JsonPropertyName("errors")]
public List<GraphQLError>? Errors { get; set; }
public bool HasErrors => Errors?.Count > 0;
public void EnsureNoErrors()
{
if (HasErrors)
{
var errorDetails = Errors!.Select(e =>
{
var msg = e.Message;
if (e.Extensions != null)
{
foreach (var ext in e.Extensions)
{
msg += $"{Environment.NewLine} [{ext.Key}]: {ext.Value}";
}
}
return msg;
});
var messages = string.Join(Environment.NewLine, errorDetails);
throw new GraphQLException($"GraphQL errors occurred:{Environment.NewLine}{messages}", Errors!);
}
}
}
public class GraphQLError
{
[JsonPropertyName("message")]
public string Message { get; set; } = string.Empty;
[JsonPropertyName("path")]
public List<object>? Path { get; set; }
[JsonPropertyName("extensions")]
public Dictionary<string, object>? Extensions { get; set; }
}
public class GraphQLException(string message, List<GraphQLError> errors) : Exception(message)
{
public List<GraphQLError> Errors { get; } = errors;
}

View file

@ -0,0 +1,38 @@
using EventFlow;
using EventFlow.Aggregates;
using Microsoft.Extensions.DependencyInjection;
namespace Books.Api.Tests.Infrastructure;
/// <summary>
/// Base class for integration tests with isolated database per test class.
/// Uses IClassFixture pattern - each test class gets its own database.
/// </summary>
public abstract class IntegrationTestBase
: IClassFixture<TestWebApplicationFactory>, IDisposable
{
protected readonly TestWebApplicationFactory Factory;
protected readonly HttpClient Client;
private readonly IServiceScope _scope;
protected IntegrationTestBase(TestWebApplicationFactory factory)
{
Factory = factory;
Client = factory.CreateGraphQLClient();
_scope = factory.Services.CreateScope();
}
protected IServiceProvider Services => _scope.ServiceProvider;
protected ICommandBus CommandBus => Services.GetRequiredService<ICommandBus>();
protected IAggregateStore AggregateStore => Services.GetRequiredService<IAggregateStore>();
protected T GetService<T>() where T : notnull
=> Services.GetRequiredService<T>();
public void Dispose()
{
_scope?.Dispose();
GC.SuppressFinalize(this);
}
}

View file

@ -0,0 +1,121 @@
using Npgsql;
namespace Books.Api.Tests.Infrastructure;
/// <summary>
/// Creates an isolated PostgreSQL database for each test run.
/// The database is automatically dropped when disposed.
/// </summary>
public class TestDatabase : IDisposable
{
public const string DefaultConnectionString =
"Host=localhost;Port=5432;Username=postgres;Password=postgres;Database=postgres;Include Error Detail=true";
private readonly string _masterConnectionString;
private bool _disposed;
public string DatabaseName { get; }
public string ConnectionString
{
get
{
var builder = new NpgsqlConnectionStringBuilder(_masterConnectionString)
{
Database = DatabaseName
};
return builder.ToString();
}
}
public TestDatabase(string? masterConnectionString = null)
{
_masterConnectionString = masterConnectionString ?? GetConnectionStringFromEnvironment();
DatabaseName = $"books_test_{Guid.NewGuid():N}";
CreateDatabase();
}
private static string GetConnectionStringFromEnvironment()
{
return Environment.GetEnvironmentVariable("TEST_CONNECTION_STRING")
?? DefaultConnectionString;
}
private void CreateDatabase()
{
try
{
using var connection = new NpgsqlConnection(_masterConnectionString);
connection.Open();
using var command = connection.CreateCommand();
command.CommandText = $"CREATE DATABASE \"{DatabaseName}\"";
command.ExecuteNonQuery();
Console.WriteLine($"[TestDatabase] Created test database: {DatabaseName}");
}
catch (Exception e)
{
throw new InvalidOperationException(
$"Failed to create test database '{DatabaseName}' using connection string: {_masterConnectionString}",
e);
}
}
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
protected virtual void Dispose(bool disposing)
{
if (_disposed) return;
if (disposing)
{
try
{
// Step 1: Terminate all active connections to prevent "database in use" errors
using (var connection = new NpgsqlConnection(_masterConnectionString))
{
connection.Open();
using var terminateCommand = connection.CreateCommand();
terminateCommand.CommandText = $@"
SELECT pg_terminate_backend(pg_stat_activity.pid)
FROM pg_stat_activity
WHERE pg_stat_activity.datname = '{DatabaseName}'
AND pid <> pg_backend_pid();
";
terminateCommand.ExecuteNonQuery();
}
// Step 2: Drop the database
using (var connection = new NpgsqlConnection(_masterConnectionString))
{
connection.Open();
using var dropCommand = connection.CreateCommand();
dropCommand.CommandText = $"DROP DATABASE IF EXISTS \"{DatabaseName}\"";
dropCommand.ExecuteNonQuery();
}
Console.WriteLine($"[TestDatabase] Dropped test database: {DatabaseName}");
}
catch (Exception e)
{
// Log but don't throw - we don't want cleanup failures to mask test failures
Console.WriteLine($"[TestDatabase] Warning: Failed to drop test database {DatabaseName}: {e.Message}");
}
}
_disposed = true;
}
~TestDatabase()
{
Dispose(false);
}
}

View file

@ -0,0 +1,146 @@
using Books.Api.EventFlow.Infrastructure;
using EventFlow.PostgreSql;
using EventFlow.PostgreSql.Connections;
using EventFlow.PostgreSql.EventStores;
using Hangfire;
using Hangfire.InMemory;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Hosting;
using Npgsql;
namespace Books.Api.Tests.Infrastructure;
/// <summary>
/// WebApplicationFactory configured for integration testing with:
/// - Isolated database per test class
/// - In-memory Hangfire (no real job processing)
/// - Full GraphQL endpoint support
/// </summary>
public class TestWebApplicationFactory : WebApplicationFactory<Program>
{
static TestWebApplicationFactory()
{
// Enable legacy timestamp behavior for Npgsql 6.0+
// Must be set before any Npgsql connections are created
AppContext.SetSwitch("Npgsql.EnableLegacyTimestampBehavior", true);
}
private readonly TestDatabase _testDatabase = new();
public string TestDatabaseName => _testDatabase.DatabaseName;
public string ConnectionString => _testDatabase.ConnectionString;
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
// Set environment to Test to avoid Development-only middleware (like Altair)
builder.UseEnvironment("Test");
builder.ConfigureAppConfiguration((context, config) =>
{
// Clear all default configuration sources
config.Sources.Clear();
// Add test configuration with isolated database
var testConfig = new Dictionary<string, string?>
{
["ConnectionStrings:Default"] = _testDatabase.ConnectionString,
["AllowedHosts"] = "*"
};
config.AddInMemoryCollection(testConfig);
Console.WriteLine($"[TestWebApplicationFactory] Using test database: {_testDatabase.DatabaseName}");
Console.WriteLine($"[TestWebApplicationFactory] Connection string: {_testDatabase.ConnectionString}");
});
builder.ConfigureServices(services =>
{
// Run database migrations on the test database BEFORE services are built
// This ensures the test database has the correct schema
MigrateTestDatabase();
// Replace the NpgsqlDataSource to use the test database
services.RemoveAll<NpgsqlDataSource>();
services.AddSingleton(new NpgsqlDataSourceBuilder(_testDatabase.ConnectionString).Build());
// CRITICAL: Replace EventFlow's PostgreSQL configuration with test connection string
// This ensures the event store uses the test database, not the original
services.RemoveAll<IPostgreSqlConfiguration>();
services.AddSingleton<IPostgreSqlConfiguration>(
PostgreSqlConfiguration.New.SetConnectionString(_testDatabase.ConnectionString));
// Replace only Hangfire storage (not EventFlow.Hangfire integration services)
// We keep EventFlow.Hangfire services like IQueueNameProvider, HangfireJobScheduler
var hangfireStorageDescriptors = services
.Where(d =>
d.ServiceType.FullName?.Contains("Hangfire.JobStorage") == true ||
d.ServiceType.FullName?.Contains("Hangfire.Server.BackgroundJobServer") == true ||
d.ImplementationType?.FullName?.Contains("Hangfire.PostgreSql") == true)
.ToList();
foreach (var descriptor in hangfireStorageDescriptors)
{
services.Remove(descriptor);
}
// Add Hangfire with in-memory storage
services.AddHangfire(config => config
.SetDataCompatibilityLevel(CompatibilityLevel.Version_180)
.UseSimpleAssemblyNameTypeSerializer()
.UseRecommendedSerializerSettings()
.UseInMemoryStorage());
services.AddHangfireServer(options =>
{
options.WorkerCount = 1;
options.Queues = ["default"];
});
});
}
protected override IHost CreateHost(IHostBuilder builder)
{
var host = base.CreateHost(builder);
// Run EventFlow PostgreSQL migrations after host is built
using var scope = host.Services.CreateScope();
var eventFlowMigrator = scope.ServiceProvider.GetRequiredService<IPostgreSqlDatabaseMigrator>();
EventFlowEventStoresPostgreSql.MigrateDatabaseAsync(eventFlowMigrator, CancellationToken.None).Wait();
return host;
}
private void MigrateTestDatabase()
{
// Run DbUp migrations for read model tables
DatabaseMigrator.Migrate(_testDatabase.ConnectionString);
}
protected override void Dispose(bool disposing)
{
if (disposing)
{
// Reset Hangfire's static LogProvider before disposing to prevent
// ObjectDisposedException when the next test class creates a new factory.
Hangfire.Logging.LogProvider.SetCurrentLogProvider(null);
_testDatabase.Dispose();
}
base.Dispose(disposing);
}
/// <summary>
/// Creates an HttpClient configured for GraphQL requests.
/// </summary>
public HttpClient CreateGraphQLClient()
{
var client = CreateClient();
client.BaseAddress = new Uri("http://localhost/graphql");
return client;
}
}

View file

@ -0,0 +1,10 @@
namespace Books.Api.Tests;
public class UnitTest1
{
[Fact]
public void Test1()
{
}
}

View file

@ -0,0 +1,5 @@
{
"$schema": "https://xunit.net/schema/current/xunit.runner.schema.json",
"parallelizeAssembly": false,
"parallelizeTestCollections": false
}

View file

@ -0,0 +1,46 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>14</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<AnalysisLevel>latest</AnalysisLevel>
</PropertyGroup>
<ItemGroup>
<!-- EventFlow (local source) -->
<ProjectReference Include="../../../EventFlow/Source/EventFlow/EventFlow.csproj" />
<ProjectReference Include="../../../EventFlow/Source/EventFlow.PostgreSql/EventFlow.PostgreSql.csproj" />
<ProjectReference Include="../../../EventFlow/Source/EventFlow.Hangfire/EventFlow.Hangfire.csproj" />
<!-- Database -->
<PackageReference Include="Npgsql" Version="10.0.1" />
<PackageReference Include="Dapper" Version="2.1.66" />
<PackageReference Include="dbup-postgresql" Version="5.0.40" />
<!-- Hangfire -->
<PackageReference Include="Hangfire.Core" Version="1.8.22" />
<PackageReference Include="Hangfire.AspNetCore" Version="1.8.22" />
<PackageReference Include="Hangfire.PostgreSql" Version="1.20.13" />
<!-- Serialization -->
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<!-- GraphQL -->
<PackageReference Include="GraphQL" Version="8.0.2" />
<PackageReference Include="GraphQL.Server.Transports.AspNetCore" Version="8.0.2" />
<PackageReference Include="GraphQL.Server.Ui.Altair" Version="8.0.2" />
<PackageReference Include="GraphQL.DataLoader" Version="8.0.2" />
<PackageReference Include="GraphQL.MicrosoftDI" Version="8.0.2" />
<!-- DI Decoration -->
<PackageReference Include="Scrutor" Version="5.0.2" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="Database\Migrations\*.sql" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,6 @@
@Books.Api_HostAddress = http://localhost:5142
GET {{Books.Api_HostAddress}}/weatherforecast/
Accept: application/json
###

View file

@ -0,0 +1,50 @@
using Books.Api.Domain.Companies;
using EventFlow.Commands;
namespace Books.Api.Commands.Companies;
public class CreateCompanyCommandHandler : CommandHandler<CompanyAggregate, CompanyId, CreateCompanyCommand>
{
public override Task ExecuteAsync(
CompanyAggregate aggregate,
CreateCompanyCommand command,
CancellationToken cancellationToken)
{
aggregate.Create(
command.Name,
command.Cvr,
command.Address,
command.PostalCode,
command.City,
command.Country,
command.FiscalYearStartMonth,
command.Currency,
command.VatRegistered,
command.VatPeriodFrequency);
return Task.CompletedTask;
}
}
public class UpdateCompanyCommandHandler : CommandHandler<CompanyAggregate, CompanyId, UpdateCompanyCommand>
{
public override Task ExecuteAsync(
CompanyAggregate aggregate,
UpdateCompanyCommand command,
CancellationToken cancellationToken)
{
aggregate.Update(
command.Name,
command.Cvr,
command.Address,
command.PostalCode,
command.City,
command.Country,
command.FiscalYearStartMonth,
command.Currency,
command.VatRegistered,
command.VatPeriodFrequency);
return Task.CompletedTask;
}
}

View file

@ -0,0 +1,56 @@
using Books.Api.Domain.Companies;
using EventFlow.Commands;
namespace Books.Api.Commands.Companies;
public class CreateCompanyCommand(
CompanyId aggregateId,
string name,
string? cvr,
string? address,
string? postalCode,
string? city,
string country,
int fiscalYearStartMonth,
string currency,
bool vatRegistered,
string? vatPeriodFrequency)
: Command<CompanyAggregate, CompanyId>(aggregateId)
{
public string Name { get; } = name;
public string? Cvr { get; } = cvr;
public string? Address { get; } = address;
public string? PostalCode { get; } = postalCode;
public string? City { get; } = city;
public string Country { get; } = country;
public int FiscalYearStartMonth { get; } = fiscalYearStartMonth;
public string Currency { get; } = currency;
public bool VatRegistered { get; } = vatRegistered;
public string? VatPeriodFrequency { get; } = vatPeriodFrequency;
}
public class UpdateCompanyCommand(
CompanyId aggregateId,
string name,
string? cvr,
string? address,
string? postalCode,
string? city,
string country,
int fiscalYearStartMonth,
string currency,
bool vatRegistered,
string? vatPeriodFrequency)
: Command<CompanyAggregate, CompanyId>(aggregateId)
{
public string Name { get; } = name;
public string? Cvr { get; } = cvr;
public string? Address { get; } = address;
public string? PostalCode { get; } = postalCode;
public string? City { get; } = city;
public string Country { get; } = country;
public int FiscalYearStartMonth { get; } = fiscalYearStartMonth;
public string Currency { get; } = currency;
public bool VatRegistered { get; } = vatRegistered;
public string? VatPeriodFrequency { get; } = vatPeriodFrequency;
}

View file

@ -0,0 +1,25 @@
using Microsoft.AspNetCore.Mvc;
namespace Books.Api.Controllers;
[ApiController]
[Route("[controller]")]
public class WeatherForecastController : ControllerBase
{
private static readonly string[] Summaries =
[
"Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
];
[HttpGet(Name = "GetWeatherForecast")]
public IEnumerable<WeatherForecast> Get()
{
return Enumerable.Range(1, 5).Select(index => new WeatherForecast
{
Date = DateOnly.FromDateTime(DateTime.Now.AddDays(index)),
TemperatureC = Random.Shared.Next(-20, 55),
Summary = Summaries[Random.Shared.Next(Summaries.Length)]
})
.ToArray();
}
}

View file

@ -0,0 +1,110 @@
-- 001_Initial.sql
-- Creates read model tables for Books API
-- NOTE: Column names use snake_case (PostgreSQL convention)
-- Custom ReadModelSqlGenerator converts C# PascalCase to snake_case
-- Company read models
CREATE TABLE IF NOT EXISTS company_read_models (
-- EventFlow standard columns
aggregate_id VARCHAR(255) PRIMARY KEY,
create_time TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_time TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_aggregate_sequence_number INT NOT NULL DEFAULT 1,
-- Business columns (snake_case)
name VARCHAR(255) NOT NULL,
cvr VARCHAR(8),
address VARCHAR(500),
postal_code VARCHAR(10),
city VARCHAR(100),
country VARCHAR(2) NOT NULL DEFAULT 'DK',
fiscal_year_start_month SMALLINT NOT NULL DEFAULT 1,
currency VARCHAR(3) NOT NULL DEFAULT 'DKK',
vat_registered BOOLEAN NOT NULL DEFAULT FALSE,
vat_period_frequency VARCHAR(20)
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_company_cvr ON company_read_models(cvr) WHERE cvr IS NOT NULL;
-- Fiscal year read models
CREATE TABLE IF NOT EXISTS fiscal_year_read_models (
aggregate_id VARCHAR(255) PRIMARY KEY,
create_time TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_time TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_aggregate_sequence_number INT NOT NULL DEFAULT 1,
company_id VARCHAR(255) NOT NULL,
name VARCHAR(50) NOT NULL,
start_date DATE NOT NULL,
end_date DATE NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'open',
opening_balance_posted BOOLEAN NOT NULL DEFAULT FALSE,
closing_date TIMESTAMPTZ,
closed_by VARCHAR(255),
CONSTRAINT fk_fiscal_year_company
FOREIGN KEY (company_id) REFERENCES company_read_models(aggregate_id) ON DELETE CASCADE,
CONSTRAINT chk_fiscal_year_status
CHECK (status IN ('open', 'closed', 'locked'))
);
CREATE INDEX IF NOT EXISTS idx_fiscal_year_company ON fiscal_year_read_models(company_id);
CREATE INDEX IF NOT EXISTS idx_fiscal_year_dates ON fiscal_year_read_models(start_date, end_date);
-- Accounting period read models
CREATE TABLE IF NOT EXISTS accounting_period_read_models (
aggregate_id VARCHAR(255) PRIMARY KEY,
create_time TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_time TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_aggregate_sequence_number INT NOT NULL DEFAULT 1,
fiscal_year_id VARCHAR(255) NOT NULL,
company_id VARCHAR(255) NOT NULL,
name VARCHAR(50) NOT NULL,
period_number SMALLINT NOT NULL,
start_date DATE NOT NULL,
end_date DATE NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'future',
closed_at TIMESTAMPTZ,
closed_by VARCHAR(255),
reopened_at TIMESTAMPTZ,
reopened_by VARCHAR(255),
locked_at TIMESTAMPTZ,
locked_by VARCHAR(255),
CONSTRAINT fk_period_fiscal_year
FOREIGN KEY (fiscal_year_id) REFERENCES fiscal_year_read_models(aggregate_id) ON DELETE CASCADE,
CONSTRAINT chk_period_status
CHECK (status IN ('future', 'open', 'closed', 'locked'))
);
CREATE INDEX IF NOT EXISTS idx_period_fiscal_year ON accounting_period_read_models(fiscal_year_id);
CREATE INDEX IF NOT EXISTS idx_period_company ON accounting_period_read_models(company_id);
-- Account read models (chart of accounts)
CREATE TABLE IF NOT EXISTS account_read_models (
aggregate_id VARCHAR(255) PRIMARY KEY,
create_time TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_time TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_aggregate_sequence_number INT NOT NULL DEFAULT 1,
company_id VARCHAR(255) NOT NULL,
account_number VARCHAR(10) NOT NULL,
name VARCHAR(255) NOT NULL,
account_type VARCHAR(20) NOT NULL,
parent_id VARCHAR(255),
description TEXT,
vat_code_id VARCHAR(255),
is_active BOOLEAN NOT NULL DEFAULT TRUE,
is_system_account BOOLEAN NOT NULL DEFAULT FALSE,
CONSTRAINT fk_account_company
FOREIGN KEY (company_id) REFERENCES company_read_models(aggregate_id) ON DELETE CASCADE,
CONSTRAINT chk_account_type
CHECK (account_type IN ('asset', 'liability', 'equity', 'revenue', 'cogs', 'expense', 'personnel', 'financial', 'extraordinary'))
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_account_number
ON account_read_models(company_id, account_number);
CREATE INDEX IF NOT EXISTS idx_account_company ON account_read_models(company_id);
CREATE INDEX IF NOT EXISTS idx_account_type ON account_read_models(account_type);

View file

@ -0,0 +1,81 @@
using Books.Api.Domain.Companies.Events;
using EventFlow.Aggregates;
namespace Books.Api.Domain.Companies;
public class CompanyAggregate(CompanyId id) : AggregateRoot<CompanyAggregate, CompanyId>(id)
{
private bool _isCreated;
public void Apply(CompanyCreatedEvent e) => _isCreated = true;
public void Apply(CompanyUpdatedEvent e) { }
public void Create(
string name,
string? cvr,
string? address,
string? postalCode,
string? city,
string country,
int fiscalYearStartMonth,
string currency,
bool vatRegistered,
string? vatPeriodFrequency)
{
if (_isCreated)
throw new DomainException("Company already exists");
if (string.IsNullOrWhiteSpace(name))
throw new DomainException("Company name is required");
if (fiscalYearStartMonth < 1 || fiscalYearStartMonth > 12)
throw new DomainException("Fiscal year start month must be between 1 and 12");
Emit(new CompanyCreatedEvent(
name.Trim(),
cvr?.Trim(),
address?.Trim(),
postalCode?.Trim(),
city?.Trim(),
country,
fiscalYearStartMonth,
currency,
vatRegistered,
vatPeriodFrequency));
}
public void Update(
string name,
string? cvr,
string? address,
string? postalCode,
string? city,
string country,
int fiscalYearStartMonth,
string currency,
bool vatRegistered,
string? vatPeriodFrequency)
{
if (!_isCreated)
throw new DomainException("Company does not exist");
if (string.IsNullOrWhiteSpace(name))
throw new DomainException("Company name is required");
if (fiscalYearStartMonth < 1 || fiscalYearStartMonth > 12)
throw new DomainException("Fiscal year start month must be between 1 and 12");
Emit(new CompanyUpdatedEvent(
name.Trim(),
cvr?.Trim(),
address?.Trim(),
postalCode?.Trim(),
city?.Trim(),
country,
fiscalYearStartMonth,
currency,
vatRegistered,
vatPeriodFrequency));
}
}

View file

@ -0,0 +1,8 @@
using EventFlow.Core;
namespace Books.Api.Domain.Companies;
public class CompanyId : Identity<CompanyId>
{
public CompanyId(string value) : base(value) { }
}

View file

@ -0,0 +1,27 @@
using EventFlow.Aggregates;
namespace Books.Api.Domain.Companies.Events;
public class CompanyCreatedEvent(
string name,
string? cvr,
string? address,
string? postalCode,
string? city,
string country,
int fiscalYearStartMonth,
string currency,
bool vatRegistered,
string? vatPeriodFrequency) : AggregateEvent<CompanyAggregate, CompanyId>
{
public string Name { get; } = name;
public string? Cvr { get; } = cvr;
public string? Address { get; } = address;
public string? PostalCode { get; } = postalCode;
public string? City { get; } = city;
public string Country { get; } = country;
public int FiscalYearStartMonth { get; } = fiscalYearStartMonth;
public string Currency { get; } = currency;
public bool VatRegistered { get; } = vatRegistered;
public string? VatPeriodFrequency { get; } = vatPeriodFrequency;
}

View file

@ -0,0 +1,27 @@
using EventFlow.Aggregates;
namespace Books.Api.Domain.Companies.Events;
public class CompanyUpdatedEvent(
string name,
string? cvr,
string? address,
string? postalCode,
string? city,
string country,
int fiscalYearStartMonth,
string currency,
bool vatRegistered,
string? vatPeriodFrequency) : AggregateEvent<CompanyAggregate, CompanyId>
{
public string Name { get; } = name;
public string? Cvr { get; } = cvr;
public string? Address { get; } = address;
public string? PostalCode { get; } = postalCode;
public string? City { get; } = city;
public string Country { get; } = country;
public int FiscalYearStartMonth { get; } = fiscalYearStartMonth;
public string Currency { get; } = currency;
public bool VatRegistered { get; } = vatRegistered;
public string? VatPeriodFrequency { get; } = vatPeriodFrequency;
}

View file

@ -0,0 +1,20 @@
namespace Books.Api.Domain;
public class DomainException : Exception
{
public string Code { get; }
public string? MessageDanish { get; }
public DomainException(string message)
: base(message)
{
Code = "DOMAIN_ERROR";
}
public DomainException(string code, string message, string messageDanish)
: base(message)
{
Code = code;
MessageDanish = messageDanish;
}
}

View file

@ -0,0 +1,31 @@
using System.Reflection;
namespace Books.Api.EventFlow.Customs;
public static class Casing
{
public static string ToSnakeCase(this string input)
{
if (string.IsNullOrEmpty(input))
return input;
var stringBuilder = new System.Text.StringBuilder();
for (var i = 0; i < input.Length; i++)
{
var c = input[i];
if (char.IsUpper(c))
{
if (i > 0)
{
stringBuilder.Append('_');
}
stringBuilder.Append(char.ToLower(c));
}
else
{
stringBuilder.Append(c);
}
}
return stringBuilder.ToString();
}
}

View file

@ -0,0 +1,221 @@
// The MIT License (MIT)
//
// Copyright (c) 2015-2025 Rasmus Mikkelsen
// https://github.com/eventflow/EventFlow
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of
// this software and associated documentation files (the "Software"), to deal in
// the Software without restriction, including without limitation the rights to
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
// the Software, and to permit persons to whom the Software is furnished to do so,
// subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
using System.Collections.Concurrent;
using System.ComponentModel.DataAnnotations.Schema;
using System.Reflection;
using EventFlow.Extensions;
using EventFlow.ReadStores;
using EventFlow.Sql.ReadModels;
using EventFlow.Sql.ReadModels.Attributes;
namespace Books.Api.EventFlow.Customs;
/// <summary>
/// Custom SQL generator for PostgreSQL that uses snake_case column names
/// while keeping PascalCase property names for Dapper parameter binding.
/// </summary>
public class ReadModelSqlGenerator : IReadModelSqlGenerator
{
private static readonly ConcurrentDictionary<Type, string> TableNames = new();
private static readonly ConcurrentDictionary<Type, IReadOnlyCollection<PropertyInfo>> PropertyInfos = new();
private static readonly ConcurrentDictionary<Type, string> IdentityColumns = new();
private static readonly ConcurrentDictionary<Type, string> IdentityProperties = new();
private static readonly ConcurrentDictionary<Type, string> VersionColumns = new();
private readonly ConcurrentDictionary<Type, string?> _insertSqls = new();
private readonly ConcurrentDictionary<Type, string> _purgeSqls = new();
private readonly ConcurrentDictionary<Type, string> _deleteSqls = new();
private readonly ConcurrentDictionary<Type, string> _selectSqls = new();
private readonly ConcurrentDictionary<Type, string> _updateSqls = new();
public string? CreateInsertSql<TReadModel>() where TReadModel : IReadModel
{
var readModelType = typeof(TReadModel);
if (_insertSqls.TryGetValue(readModelType, out var sql))
{
return sql;
}
var properties = GetPropertyInfos(readModelType);
// Column names in snake_case
var columnList = string.Join(", ", properties.Select(p => p.Name.ToSnakeCase()));
// Parameter names use property names (PascalCase) for Dapper binding
var parameterList = string.Join(", ", properties.Select(p => $"@{p.Name}"));
sql = $"INSERT INTO {GetTableName<TReadModel>()} ({columnList}) VALUES ({parameterList})";
_insertSqls[readModelType] = sql;
return sql;
}
public string CreateSelectSql<TReadModel>() where TReadModel : IReadModel
{
var readModelType = typeof(TReadModel);
if (_selectSqls.TryGetValue(readModelType, out var sql))
{
return sql;
}
var tableName = GetTableName<TReadModel>();
var identityColumn = GetIdentityColumn<TReadModel>();
var properties = GetPropertyInfos(readModelType);
// Use explicit column aliases to map snake_case columns to PascalCase properties
var columnList = string.Join(", ", properties.Select(p => $"{p.Name.ToSnakeCase()} AS \"{p.Name}\""));
sql = $"SELECT {columnList} FROM {tableName} WHERE {identityColumn} = @EventFlowReadModelId";
_selectSqls[readModelType] = sql;
return sql;
}
public string CreateDeleteSql<TReadModel>() where TReadModel : IReadModel
{
var readModelType = typeof(TReadModel);
if (_deleteSqls.TryGetValue(readModelType, out var sql))
{
return sql;
}
sql = $"DELETE FROM {GetTableName<TReadModel>()} WHERE {GetIdentityColumn<TReadModel>()} = @EventFlowReadModelId";
_deleteSqls[readModelType] = sql;
return sql;
}
public string CreateUpdateSql<TReadModel>() where TReadModel : IReadModel
{
var readModelType = typeof(TReadModel);
if (_updateSqls.TryGetValue(readModelType, out var sql))
{
return sql;
}
var identityColumn = GetIdentityColumn<TReadModel>();
var identityProperty = GetIdentityProperty<TReadModel>();
var versionColumn = GetVersionColumn<TReadModel>();
var versionCheck = string.IsNullOrEmpty(versionColumn)
? string.Empty
: $"AND {versionColumn} = @_PREVIOUS_VERSION";
var properties = GetPropertyInfos(readModelType);
var updateColumns = properties
.Where(p => p.Name.ToSnakeCase() != identityColumn)
.Select(p => $"{p.Name.ToSnakeCase()} = @{p.Name}");
var tableName = GetTableName<TReadModel>();
sql = $"UPDATE {tableName} SET {string.Join(", ", updateColumns)} WHERE {identityColumn} = @{identityProperty} {versionCheck}";
_updateSqls[readModelType] = sql;
return sql;
}
public string CreatePurgeSql<TReadModel>() where TReadModel : IReadModel
{
return _purgeSqls.GetOrCreate(typeof(TReadModel), t => $"DELETE FROM {GetTableName(t)}");
}
public string GetTableName<TReadModel>() where TReadModel : IReadModel
{
return GetTableName(typeof(TReadModel));
}
private static string GetTableName(Type readModelType)
{
return TableNames.GetOrAdd(
readModelType,
t =>
{
var tableAttribute = t.GetTypeInfo().GetCustomAttribute<TableAttribute>(false);
var table = string.IsNullOrEmpty(tableAttribute?.Name)
? $"ReadModel-{t.Name.Replace("ReadModel", string.Empty)}"
: tableAttribute.Name;
return string.IsNullOrEmpty(tableAttribute?.Schema)
? $"\"{table}\""
: $"\"{tableAttribute?.Schema}\".\"{table}\"";
});
}
private static string GetIdentityColumn<TReadModel>()
{
return IdentityColumns.GetOrAdd(
typeof(TReadModel),
t =>
{
var propertyInfo = GetPropertyInfos(t)
.SingleOrDefault(
pi => pi.GetCustomAttributes().Any(a => a is SqlReadModelIdentityColumnAttribute));
return (propertyInfo?.Name ?? "AggregateId").ToSnakeCase();
});
}
private static string GetIdentityProperty<TReadModel>()
{
return IdentityProperties.GetOrAdd(
typeof(TReadModel),
t =>
{
var propertyInfo = GetPropertyInfos(t)
.SingleOrDefault(
pi => pi.GetCustomAttributes().Any(a => a is SqlReadModelIdentityColumnAttribute));
return propertyInfo?.Name ?? "AggregateId";
});
}
private static string GetVersionColumn<TReadModel>()
{
return VersionColumns.GetOrAdd(
typeof(TReadModel),
t =>
{
var propertyInfo = GetPropertyInfos(t)
.SingleOrDefault(
pi => pi.GetCustomAttributes().Any(a => a is SqlReadModelVersionColumnAttribute));
if (propertyInfo != null)
{
return propertyInfo.Name.ToSnakeCase();
}
return GetPropertyInfos(t).Any(n => n.Name == "LastAggregateSequenceNumber")
? "last_aggregate_sequence_number"
: string.Empty;
});
}
private static IReadOnlyCollection<PropertyInfo> GetPropertyInfos(Type readModelType)
{
return PropertyInfos.GetOrAdd(
readModelType,
t => t.GetProperties(BindingFlags.Instance | BindingFlags.Public)
.Where(p => !p.GetCustomAttributes().Any(a => a is SqlReadModelIgnoreColumnAttribute))
.OrderBy(p => p.Name)
.ToList());
}
}

View file

@ -0,0 +1,30 @@
using Books.Api.EventFlow.ReadModels;
using Books.Api.EventFlow.Repositories;
using EventFlow;
using EventFlow.Extensions;
using EventFlow.PostgreSql.Extensions;
using EventFlow.Sql.ReadModels;
using ReadModelSqlGenerator = Books.Api.EventFlow.Customs.ReadModelSqlGenerator;
namespace Books.Api.EventFlow.Extensions;
public static class ReadModelRegistrationExtensions
{
public static IEventFlowOptions AddReadModels(this IEventFlowOptions options)
{
return options
.UsePostgreSqlReadModel<CompanyReadModel, CompanyReadModelLocator>()
.RegisterServices( sr => sr.AddSingleton<IReadModelSqlGenerator>(new ReadModelSqlGenerator()));
}
public static IServiceCollection AddRepositories(this IServiceCollection services)
{
// Register locators
services.AddTransient<CompanyReadModelLocator>();
// Register repositories
services.AddScoped<ICompanyRepository, CompanyRepository>();
return services;
}
}

View file

@ -0,0 +1,43 @@
using System.Reflection;
using Dapper;
using DbUp;
using Npgsql;
namespace Books.Api.EventFlow.Infrastructure;
public static class DatabaseMigrator
{
public static void Migrate(string connectionString)
{
EnsureDatabaseExists(connectionString);
var upgrader = DeployChanges.To
.PostgresqlDatabase(connectionString)
.WithScriptsEmbeddedInAssembly(Assembly.GetExecutingAssembly())
.LogToConsole()
.Build();
var result = upgrader.PerformUpgrade();
if (!result.Successful)
throw result.Error;
}
private static void EnsureDatabaseExists(string connectionString)
{
var builder = new NpgsqlConnectionStringBuilder(connectionString);
var database = builder.Database;
builder.Database = "postgres";
using var connection = new NpgsqlConnection(builder.ConnectionString);
connection.Open();
var exists = connection.ExecuteScalar<bool>(
"SELECT EXISTS(SELECT 1 FROM pg_database WHERE datname = @db)",
new { db = database });
if (!exists)
{
connection.Execute($"CREATE DATABASE \"{database}\"");
}
}
}

View file

@ -0,0 +1,238 @@
using System.Reflection;
using Books.Api.Infrastructure;
using EventFlow.Aggregates;
using EventFlow.EventStores;
using EventFlow.Subscribers;
using Hangfire;
namespace Books.Api.EventFlow.Infrastructure;
#pragma warning disable CS9113 // Parameter is unread
public class DispatchToSubscriberResilienceStrategy(
ILogger<DispatchToSubscriberResilienceStrategy> logger,
IScheduler scheduler,
IServiceProvider serviceProvider,
IEventJsonSerializer eventJsonSerializer) : IDispatchToSubscriberResilienceStrategy
#pragma warning restore CS9113
{
public Task BeforeHandleEventAsync(
ISubscribe subscriberTo,
IDomainEvent domainEvent,
CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
public Task HandleEventFailedAsync(
ISubscribe subscriberTo,
IDomainEvent domainEvent,
Exception exception,
bool swallowException,
CancellationToken cancellationToken)
{
var subscriberType = GetSubscriberType(subscriberTo);
var eventType = domainEvent.EventType.Name;
var aggregateId = domainEvent.GetIdentity()?.Value ?? "unknown";
logger.LogError(exception,
"[RESILIENCE] Subscriber {SubscriberType} failed to handle event {EventType} for aggregate {AggregateId}",
subscriberType.Name,
eventType,
aggregateId);
try
{
var serializedEvent = eventJsonSerializer.Serialize(domainEvent);
var domainEventInterface = domainEvent.GetType().GetTypeInfo()
.GetInterfaces()
.FirstOrDefault(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IDomainEvent<,,>));
if (domainEventInterface != null)
{
scheduler.EnqueueJob<SubscriberRetryJob>(
job => job.RetryEventDispatchAsync(
subscriberType.AssemblyQualifiedName!,
domainEventInterface.AssemblyQualifiedName!,
serializedEvent.SerializedData,
serializedEvent.SerializedMetadata),
TimeSpan.FromSeconds(30));
logger.LogWarning(
"[RESILIENCE] Scheduled retry job for {SubscriberType} / {EventType} / {AggregateId}",
subscriberType.Name,
eventType,
aggregateId);
}
}
catch (Exception e)
{
logger.LogError(e,
"[RESILIENCE] Failed to schedule retry for {SubscriberType} / {EventType}. " +
"This event will NOT be retried and may cause data inconsistency.",
subscriberType.Name,
eventType);
}
return Task.CompletedTask;
}
public Task HandleEventSucceededAsync(
ISubscribe subscriberTo,
IDomainEvent domainEvent,
CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
public Task BeforeDispatchToSubscribersAsync(
IDomainEvent domainEvent,
IReadOnlyCollection<IDomainEvent> domainEvents,
CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
public Task DispatchToSubscribersSucceededAsync(
IDomainEvent domainEvent,
IReadOnlyCollection<IDomainEvent> domainEvents,
CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
public Task<bool> HandleDispatchToSubscribersFailedAsync(
IDomainEvent domainEvent,
IReadOnlyCollection<IDomainEvent> domainEvents,
Exception exception,
CancellationToken cancellationToken)
{
return Task.FromResult(true);
}
private static Type GetSubscriberType(ISubscribe subscriberTo)
{
if (subscriberTo is ISubscribeDecorator decorator)
{
return GetSubscriberType(decorator.InnerInstance as ISubscribe
?? throw new InvalidOperationException("InnerInstance is not ISubscribe"));
}
return subscriberTo.GetType();
}
}
public interface ISubscribeDecorator
{
object InnerInstance { get; }
}
public class SubscriberRetryJob(
ILogger<SubscriberRetryJob> logger,
IServiceProvider serviceProvider,
IEventJsonSerializer eventJsonSerializer)
{
[AutomaticRetry(Attempts = 3, DelaysInSeconds = [30, 60, 120])]
public async Task RetryEventDispatchAsync(
string subscriberTypeName,
string domainEventTypeName,
string serializedData,
string serializedMetadata)
{
logger.LogInformation(
"[RETRY] Retrying event dispatch to {SubscriberType}",
subscriberTypeName);
try
{
var domainEvent = eventJsonSerializer.Deserialize(serializedData, serializedMetadata);
var subscriberType = Type.GetType(subscriberTypeName);
if (subscriberType == null)
{
logger.LogError("[RETRY] Could not resolve subscriber type: {SubscriberType}", subscriberTypeName);
return;
}
var subscriber = FindSubscriberInstance(subscriberType, domainEvent);
if (subscriber == null)
{
logger.LogError("[RETRY] Could not find subscriber instance for {SubscriberType}", subscriberTypeName);
return;
}
var domainEventType = domainEvent.GetType();
var handleAsyncMethod = subscriber.GetType().GetMethods()
.FirstOrDefault(m => m.Name == "HandleAsync" &&
m.GetParameters().Any(p => p.ParameterType.IsAssignableFrom(domainEventType)));
if (handleAsyncMethod != null)
{
await (Task)handleAsyncMethod.Invoke(subscriber, [domainEvent, CancellationToken.None])!;
logger.LogInformation("[RETRY] Successfully retried event dispatch to {SubscriberType}", subscriberTypeName);
}
else
{
logger.LogError(
"[RETRY] No matching HandleAsync method found for {SubscriberType} and {DomainEventType}",
subscriberTypeName,
domainEventTypeName);
}
}
catch (Exception ex)
{
logger.LogError(ex, "[RETRY] Failed to retry event dispatch to {SubscriberType}", subscriberTypeName);
throw;
}
}
private object? FindSubscriberInstance(Type subscriberType, object domainEvent)
{
var interfaceType = ExtractSubscriberInterface(subscriberType, domainEvent);
if (interfaceType == null)
return null;
var instances = serviceProvider.GetServices(interfaceType);
return instances.FirstOrDefault(x =>
x?.GetType() == subscriberType ||
HasInnerType(x, subscriberType));
}
private static Type? ExtractSubscriberInterface(Type subscriberType, object domainEvent)
{
var domainEventType = domainEvent.GetType();
var interfaces = subscriberType.GetInterfaces()
.Where(i => i.IsGenericType &&
(i.GetGenericTypeDefinition() == typeof(ISubscribeAsynchronousTo<,,>) ||
i.GetGenericTypeDefinition() == typeof(ISubscribeSynchronousTo<,,>)));
foreach (var iface in interfaces)
{
var genericArguments = iface.GetGenericArguments();
var eventGenericArgs = domainEventType.GenericTypeArguments;
if (eventGenericArgs.Length >= 3 && genericArguments[2] == eventGenericArgs[2])
{
return iface;
}
}
return null;
}
private static bool HasInnerType(object? obj, Type concreteType)
{
if (obj == null) return false;
var decoratorType = obj.GetType();
var field = decoratorType.GetFields(BindingFlags.NonPublic | BindingFlags.Instance)
.FirstOrDefault(x => x.Name.Contains("inner", StringComparison.InvariantCultureIgnoreCase));
var property = decoratorType.GetProperties(BindingFlags.NonPublic | BindingFlags.Instance)
.FirstOrDefault(x => x.Name.Contains("inner", StringComparison.InvariantCultureIgnoreCase));
if (field?.GetValue(obj)?.GetType() == concreteType)
return true;
if (property?.GetValue(obj)?.GetType() == concreteType)
return true;
return false;
}
}

View file

@ -0,0 +1,95 @@
using Books.Api.Infrastructure;
using EventFlow.Aggregates;
using EventFlow.ReadStores;
using Hangfire;
namespace Books.Api.EventFlow.Infrastructure;
public class ReadStoresResilienceStrategy(
ILogger<ReadStoresResilienceStrategy> logger,
IScheduler scheduler) : IDispatchToReadStoresResilienceStrategy
{
public Task BeforeUpdateAsync(
IReadStoreManager readStoreManager,
IReadOnlyCollection<IDomainEvent> domainEvents,
CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
public Task<bool> HandleUpdateFailedAsync(
IReadStoreManager readStoreManager,
IReadOnlyCollection<IDomainEvent> domainEvents,
Exception exception,
CancellationToken cancellationToken)
{
var firstEvent = domainEvents.FirstOrDefault();
var aggregateId = firstEvent?.GetIdentity()?.Value ?? "unknown";
var readModelType = readStoreManager.ReadModelType.Name;
logger.LogError(exception,
"[RESILIENCE] Failed to update read model {ReadModelType} for aggregate {AggregateId}. " +
"Events: {EventCount}. This may cause data inconsistency.",
readModelType,
aggregateId,
domainEvents.Count);
// Schedule a retry job to repopulate the read model
if (firstEvent != null)
{
var aggregateType = firstEvent.AggregateType.Name;
scheduler.EnqueueJob<ReadModelRepopulationJob>(
job => job.RepopulateReadModelAsync(
aggregateType,
aggregateId,
readModelType),
TimeSpan.FromSeconds(30));
logger.LogWarning(
"[RESILIENCE] Scheduled read model repopulation job for {ReadModelType} / {AggregateId}",
readModelType,
aggregateId);
}
// Return false to indicate we handled the failure (don't rethrow)
return Task.FromResult(false);
}
public Task UpdateSucceededAsync(
IReadStoreManager readStoreManager,
IReadOnlyCollection<IDomainEvent> domainEvents,
CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
}
public class ReadModelRepopulationJob(ILogger<ReadModelRepopulationJob> logger)
{
[AutomaticRetry(Attempts = 3, DelaysInSeconds = [30, 60, 120])]
public Task RepopulateReadModelAsync(
string aggregateType,
string aggregateId,
string readModelType)
{
logger.LogInformation(
"[REPOPULATION] Starting read model repopulation for {ReadModelType} / {AggregateId}",
readModelType,
aggregateId);
// For now, we log the repopulation attempt
// A full implementation would:
// 1. Load all events for the aggregate from the event store
// 2. Clear the existing read model entry
// 3. Replay all events to rebuild the read model
logger.LogWarning(
"[REPOPULATION] Read model repopulation for {ReadModelType} / {AggregateId} " +
"requires manual intervention. Check data consistency.",
readModelType,
aggregateId);
return Task.CompletedTask;
}
}

View file

@ -0,0 +1,80 @@
using System.ComponentModel.DataAnnotations.Schema;
using Books.Api.Domain.Companies;
using Books.Api.Domain.Companies.Events;
using EventFlow.Aggregates;
using EventFlow.PostgreSql.ReadStores.Attributes;
using EventFlow.ReadStores;
namespace Books.Api.EventFlow.ReadModels;
[Table("company_read_models")]
public class CompanyReadModel : IReadModel,
IAmReadModelFor<CompanyAggregate, CompanyId, CompanyCreatedEvent>,
IAmReadModelFor<CompanyAggregate, CompanyId, CompanyUpdatedEvent>
{
// EventFlow standard columns
[PostgreSqlReadModelIdentityColumn]
public string AggregateId { get; set; } = string.Empty;
public DateTimeOffset CreateTime { get; set; }
public DateTimeOffset UpdatedTime { get; set; }
[PostgreSqlReadModelVersionColumn]
public int LastAggregateSequenceNumber { get; set; }
// Business columns
public string Name { get; set; } = string.Empty;
public string? Cvr { get; set; }
public string? Address { get; set; }
public string? PostalCode { get; set; }
public string? City { get; set; }
public string Country { get; set; } = "DK";
public int FiscalYearStartMonth { get; set; } = 1;
public string Currency { get; set; } = "DKK";
public bool VatRegistered { get; set; }
public string? VatPeriodFrequency { get; set; }
public Task ApplyAsync(
IReadModelContext context,
IDomainEvent<CompanyAggregate, CompanyId, CompanyCreatedEvent> domainEvent,
CancellationToken cancellationToken)
{
var e = domainEvent.AggregateEvent;
AggregateId = domainEvent.AggregateIdentity.Value;
CreateTime = domainEvent.Timestamp;
UpdatedTime = domainEvent.Timestamp;
LastAggregateSequenceNumber = (int)domainEvent.AggregateSequenceNumber;
Name = e.Name;
Cvr = e.Cvr;
Address = e.Address;
PostalCode = e.PostalCode;
City = e.City;
Country = e.Country;
FiscalYearStartMonth = e.FiscalYearStartMonth;
Currency = e.Currency;
VatRegistered = e.VatRegistered;
VatPeriodFrequency = e.VatPeriodFrequency;
return Task.CompletedTask;
}
public Task ApplyAsync(
IReadModelContext context,
IDomainEvent<CompanyAggregate, CompanyId, CompanyUpdatedEvent> domainEvent,
CancellationToken cancellationToken)
{
var e = domainEvent.AggregateEvent;
UpdatedTime = domainEvent.Timestamp;
LastAggregateSequenceNumber = (int)domainEvent.AggregateSequenceNumber;
Name = e.Name;
Cvr = e.Cvr;
Address = e.Address;
PostalCode = e.PostalCode;
City = e.City;
Country = e.Country;
FiscalYearStartMonth = e.FiscalYearStartMonth;
Currency = e.Currency;
VatRegistered = e.VatRegistered;
VatPeriodFrequency = e.VatPeriodFrequency;
return Task.CompletedTask;
}
}

View file

@ -0,0 +1,24 @@
namespace Books.Api.EventFlow.ReadModels;
/// <summary>
/// DTO for reading company data from the database.
/// Uses a class with properties instead of a positional record because
/// PostgreSQL returns column names in lowercase, and Dapper matches
/// properties case-insensitively but requires exact constructor parameter names.
/// </summary>
public class CompanyReadModelDto
{
public string Id { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public string? Cvr { get; set; }
public string? Address { get; set; }
public string? PostalCode { get; set; }
public string? City { get; set; }
public string Country { get; set; } = string.Empty;
public int FiscalYearStartMonth { get; set; }
public string Currency { get; set; } = string.Empty;
public bool VatRegistered { get; set; }
public string? VatPeriodFrequency { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime UpdatedAt { get; set; }
}

View file

@ -0,0 +1,16 @@
using Books.Api.Domain.Companies;
using EventFlow.Aggregates;
using EventFlow.ReadStores;
namespace Books.Api.EventFlow.ReadModels;
public class CompanyReadModelLocator : IReadModelLocator
{
public IEnumerable<string> GetReadModelIds(IDomainEvent domainEvent)
{
if (domainEvent is IDomainEvent<CompanyAggregate, CompanyId> typedEvent)
{
yield return typedEvent.AggregateIdentity.Value;
}
}
}

View file

@ -0,0 +1,89 @@
using Books.Api.Domain.Companies;
using Books.Api.EventFlow.ReadModels;
using Dapper;
using Npgsql;
namespace Books.Api.EventFlow.Repositories;
public class CompanyRepository(NpgsqlDataSource dataSource) : ICompanyRepository
{
public async Task<CompanyReadModelDto?> GetByIdAsync(string id, CancellationToken cancellationToken = default)
{
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
const string sql = """
SELECT
aggregate_id AS Id,
name AS Name,
cvr AS Cvr,
address AS Address,
postal_code AS PostalCode,
city AS City,
country AS Country,
fiscal_year_start_month AS FiscalYearStartMonth,
currency AS Currency,
vat_registered AS VatRegistered,
vat_period_frequency AS VatPeriodFrequency,
create_time AS CreatedAt,
updated_time AS UpdatedAt
FROM company_read_models
WHERE aggregate_id = @Id
""";
return await connection.QuerySingleOrDefaultAsync<CompanyReadModelDto>(sql, new { Id = id });
}
public async Task<IEnumerable<CompanyReadModelDto>> GetByIds(List<CompanyId> ids,
CancellationToken cancellationToken = default)
{
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
const string sql = """
SELECT
aggregate_id AS Id,
name AS Name,
cvr AS Cvr,
address AS Address,
postal_code AS PostalCode,
city AS City,
country AS Country,
fiscal_year_start_month AS FiscalYearStartMonth,
currency AS Currency,
vat_registered AS VatRegistered,
vat_period_frequency AS VatPeriodFrequency,
create_time AS CreatedAt,
updated_time AS UpdatedAt
FROM company_read_models
WHERE aggregate_id = ANY(@Ids)
""";
return await connection.QueryAsync<CompanyReadModelDto>(sql, new { Ids = ids.Select(i => i.Value).ToArray() });
}
public async Task<IReadOnlyList<CompanyReadModelDto>> GetAllAsync(CancellationToken cancellationToken = default)
{
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
const string sql = """
SELECT
aggregate_id AS Id,
name AS Name,
cvr AS Cvr,
address AS Address,
postal_code AS PostalCode,
city AS City,
country AS Country,
fiscal_year_start_month AS FiscalYearStartMonth,
currency AS Currency,
vat_registered AS VatRegistered,
vat_period_frequency AS VatPeriodFrequency,
create_time AS CreatedAt,
updated_time AS UpdatedAt
FROM company_read_models
ORDER BY name
""";
var result = await connection.QueryAsync<CompanyReadModelDto>(sql);
return result.ToList();
}
}

View file

@ -0,0 +1,11 @@
using Books.Api.Domain.Companies;
using Books.Api.EventFlow.ReadModels;
namespace Books.Api.EventFlow.Repositories;
public interface ICompanyRepository
{
Task<CompanyReadModelDto?> GetByIdAsync(string id, CancellationToken cancellationToken = default);
Task<IEnumerable<CompanyReadModelDto>> GetByIds(List<CompanyId> ids, CancellationToken cancellationToken = default);
Task<IReadOnlyList<CompanyReadModelDto>> GetAllAsync(CancellationToken cancellationToken = default);
}

View file

@ -0,0 +1,14 @@
using Books.Api.GraphQL.Mutations;
using Books.Api.GraphQL.Queries;
using GraphQL.Types;
namespace Books.Api.GraphQL;
public class BooksSchema : Schema
{
public BooksSchema(IServiceProvider serviceProvider) : base(serviceProvider)
{
Query = serviceProvider.GetRequiredService<BooksQuery>();
Mutation = serviceProvider.GetRequiredService<BooksMutation>();
}
}

View file

@ -0,0 +1,37 @@
using GraphQL.Types;
namespace Books.Api.GraphQL.InputTypes;
public class CreateCompanyInputType : InputObjectGraphType<CreateCompanyInput>
{
public CreateCompanyInputType()
{
Name = "CreateCompanyInput";
Description = "Input for creating a new company";
Field(x => x.Name).Description("Company name (required)");
Field(x => x.Cvr, nullable: true).Description("Danish CVR number");
Field(x => x.Address, nullable: true).Description("Street address");
Field(x => x.PostalCode, nullable: true).Description("Postal code");
Field(x => x.City, nullable: true).Description("City");
Field(x => x.Country, nullable: true).Description("Country code (default: DK)");
Field(x => x.FiscalYearStartMonth, nullable: true).Description("Month when fiscal year starts (default: 1)");
Field(x => x.Currency, nullable: true).Description("Default currency (default: DKK)");
Field(x => x.VatRegistered, nullable: true).Description("Whether VAT registered (default: false)");
Field(x => x.VatPeriodFrequency, nullable: true).Description("VAT reporting frequency");
}
}
public class CreateCompanyInput
{
public string Name { get; set; } = string.Empty;
public string? Cvr { get; set; }
public string? Address { get; set; }
public string? PostalCode { get; set; }
public string? City { get; set; }
public string? Country { get; set; }
public int? FiscalYearStartMonth { get; set; }
public string? Currency { get; set; }
public bool? VatRegistered { get; set; }
public string? VatPeriodFrequency { get; set; }
}

View file

@ -0,0 +1,37 @@
using GraphQL.Types;
namespace Books.Api.GraphQL.InputTypes;
public class UpdateCompanyInputType : InputObjectGraphType<UpdateCompanyInput>
{
public UpdateCompanyInputType()
{
Name = "UpdateCompanyInput";
Description = "Input for updating an existing company";
Field(x => x.Name).Description("Company name (required)");
Field(x => x.Cvr, nullable: true).Description("Danish CVR number");
Field(x => x.Address, nullable: true).Description("Street address");
Field(x => x.PostalCode, nullable: true).Description("Postal code");
Field(x => x.City, nullable: true).Description("City");
Field(x => x.Country, nullable: true).Description("Country code");
Field(x => x.FiscalYearStartMonth, nullable: true).Description("Month when fiscal year starts");
Field(x => x.Currency, nullable: true).Description("Default currency");
Field(x => x.VatRegistered, nullable: true).Description("Whether VAT registered");
Field(x => x.VatPeriodFrequency, nullable: true).Description("VAT reporting frequency");
}
}
public class UpdateCompanyInput
{
public string Name { get; set; } = string.Empty;
public string? Cvr { get; set; }
public string? Address { get; set; }
public string? PostalCode { get; set; }
public string? City { get; set; }
public string? Country { get; set; }
public int? FiscalYearStartMonth { get; set; }
public string? Currency { get; set; }
public bool? VatRegistered { get; set; }
public string? VatPeriodFrequency { get; set; }
}

View file

@ -0,0 +1,82 @@
using Books.Api.Commands.Companies;
using Books.Api.Domain.Companies;
using Books.Api.EventFlow.Repositories;
using Books.Api.GraphQL.InputTypes;
using Books.Api.GraphQL.Types;
using EventFlow;
using GraphQL;
using GraphQL.Types;
namespace Books.Api.GraphQL.Mutations;
public class BooksMutation : ObjectGraphType
{
public BooksMutation()
{
Name = "Mutation";
Description = "Root mutation for the Books API";
// createCompany(input: CreateCompanyInput!): CompanyType
Field<CompanyType>("createCompany")
.Description("Create a new company")
.Argument<NonNullGraphType<CreateCompanyInputType>>("input", "The company data")
.ResolveAsync(async ctx =>
{
var input = ctx.GetArgument<CreateCompanyInput>("input");
var commandBus = ctx.RequestServices!.GetRequiredService<ICommandBus>();
var repository = ctx.RequestServices!.GetRequiredService<ICompanyRepository>();
var companyId = CompanyId.New;
var command = new CreateCompanyCommand(
companyId,
input.Name,
input.Cvr,
input.Address,
input.PostalCode,
input.City,
input.Country ?? "DK",
input.FiscalYearStartMonth ?? 1,
input.Currency ?? "DKK",
input.VatRegistered ?? false,
input.VatPeriodFrequency);
await commandBus.PublishAsync(command, ctx.CancellationToken);
// Return the created company (eventually consistent)
return await repository.GetByIdAsync(companyId.Value, ctx.CancellationToken);
});
// updateCompany(id: ID!, input: UpdateCompanyInput!): CompanyType
Field<CompanyType>("updateCompany")
.Description("Update an existing company")
.Argument<NonNullGraphType<IdGraphType>>("id", "The company ID")
.Argument<NonNullGraphType<UpdateCompanyInputType>>("input", "The updated company data")
.ResolveAsync(async ctx =>
{
var id = ctx.GetArgument<string>("id");
var input = ctx.GetArgument<UpdateCompanyInput>("input");
var commandBus = ctx.RequestServices!.GetRequiredService<ICommandBus>();
var repository = ctx.RequestServices!.GetRequiredService<ICompanyRepository>();
var companyId = CompanyId.With(id);
var command = new UpdateCompanyCommand(
companyId,
input.Name,
input.Cvr,
input.Address,
input.PostalCode,
input.City,
input.Country ?? "DK",
input.FiscalYearStartMonth ?? 1,
input.Currency ?? "DKK",
input.VatRegistered ?? false,
input.VatPeriodFrequency);
await commandBus.PublishAsync(command, ctx.CancellationToken);
return await repository.GetByIdAsync(companyId.Value, ctx.CancellationToken);
});
}
}

View file

@ -0,0 +1,37 @@
using Books.Api.Domain.Companies;
using Books.Api.EventFlow.Repositories;
using Books.Api.GraphQL.Types;
using GraphQL;
using GraphQL.Types;
namespace Books.Api.GraphQL.Queries;
public class BooksQuery : ObjectGraphType
{
public BooksQuery()
{
Name = "Query";
Description = "Root query for the Books API";
// companies: [CompanyType]
Field<ListGraphType<CompanyType>>("companies")
.Description("Get all companies")
.ResolveAsync(async ctx =>
{
var repository = ctx.RequestServices!.GetRequiredService<ICompanyRepository>();
return await repository.GetAllAsync(ctx.CancellationToken);
});
// company(id: ID!): CompanyType
Field<CompanyType>("company")
.Description("Get a company by ID")
.Argument<NonNullGraphType<IdGraphType>>("id", "The company ID")
.ResolveAsync(async ctx =>
{
var id = ctx.GetArgument<string>("id");
var repository = ctx.RequestServices!.GetRequiredService<ICompanyRepository>();
var companies = await repository.GetByIds([CompanyId.With(id)], ctx.CancellationToken);
return companies.FirstOrDefault();
});
}
}

View file

@ -0,0 +1,31 @@
using Books.Api.EventFlow.ReadModels;
using GraphQL.Types;
namespace Books.Api.GraphQL.Types;
public class CompanyType : ObjectGraphType<CompanyReadModelDto>
{
public CompanyType()
{
Name = "Company";
Description = "A company/organization for bookkeeping";
Field(x => x.Id).Description("Unique identifier");
Field(x => x.Name).Description("Company name");
Field(x => x.Cvr, nullable: true).Description("Danish CVR number");
Field(x => x.Address, nullable: true).Description("Street address");
Field(x => x.PostalCode, nullable: true).Description("Postal code");
Field(x => x.City, nullable: true).Description("City");
Field(x => x.Country).Description("Country code (e.g., DK)");
Field(x => x.FiscalYearStartMonth).Description("Month when fiscal year starts (1-12)");
Field(x => x.Currency).Description("Default currency (e.g., DKK)");
Field(x => x.VatRegistered).Description("Whether the company is VAT registered");
Field(x => x.VatPeriodFrequency, nullable: true).Description("VAT reporting frequency");
Field(x => x.CreatedAt).Description("When the company was created");
Field(x => x.UpdatedAt).Description("When the company was last updated");
// Nested fields will be added later:
// - fiscalYears: [FiscalYearType] via DataLoader
// - accounts: [AccountType] via DataLoader
}
}

View file

@ -0,0 +1,27 @@
using System.Linq.Expressions;
using Hangfire;
namespace Books.Api.Infrastructure;
public class HangfireScheduler(IBackgroundJobClient jobClient, IRecurringJobManager recurringJobManager) : IScheduler
{
public void EnqueueJob<T>(Expression<Func<T, Task>> methodCall)
{
jobClient.Enqueue(methodCall);
}
public void EnqueueJob<T>(Expression<Func<T, Task>> methodCall, TimeSpan delay)
{
jobClient.Schedule(methodCall, delay);
}
public void EnqueueJob(Expression<Func<Task>> methodCall)
{
jobClient.Enqueue(methodCall);
}
public void AddOrUpdateScheduledJob<T>(string title, Expression<Func<T?, Task>> methodCall, string cron)
{
recurringJobManager.AddOrUpdate(title, methodCall, cron);
}
}

View file

@ -0,0 +1,11 @@
using System.Linq.Expressions;
namespace Books.Api.Infrastructure;
public interface IScheduler
{
void EnqueueJob<T>(Expression<Func<T, Task>> methodCall);
void EnqueueJob<T>(Expression<Func<T, Task>> methodCall, TimeSpan delay);
void EnqueueJob(Expression<Func<Task>> methodCall);
void AddOrUpdateScheduledJob<T>(string title, Expression<Func<T?, Task>> methodCall, string cron);
}

View file

@ -0,0 +1,13 @@
namespace Books.Api.Logging;
public static class ServiceCollectionExtensions
{
public static IServiceCollection DecorateAsyncEventHandlersWithLogging(this IServiceCollection services)
{
// Decoration will be set up once we have event handlers registered
// The SubscribeAsynchronousToDecorator wraps handlers with logging
// This is a placeholder for now - implement when we have actual event handlers
return services;
}
}

View file

@ -0,0 +1,48 @@
using System.Diagnostics;
using EventFlow.Aggregates;
using EventFlow.Core;
using EventFlow.Subscribers;
namespace Books.Api.Logging;
public class SubscribeAsynchronousToDecorator<TAggregate, TIdentity, TEvent>(
ISubscribeAsynchronousTo<TAggregate, TIdentity, TEvent> inner,
ILogger<SubscribeAsynchronousToDecorator<TAggregate, TIdentity, TEvent>> logger)
: ISubscribeAsynchronousTo<TAggregate, TIdentity, TEvent>
where TAggregate : IAggregateRoot<TIdentity>
where TIdentity : IIdentity
where TEvent : IAggregateEvent<TAggregate, TIdentity>
{
public async Task HandleAsync(
IDomainEvent<TAggregate, TIdentity, TEvent> domainEvent,
CancellationToken cancellationToken)
{
var eventName = typeof(TEvent).Name;
var handlerName = inner.GetType().Name;
var aggregateId = domainEvent.AggregateIdentity.Value;
logger.LogDebug(
"Handling {EventName} for {AggregateId} with {HandlerName}",
eventName, aggregateId, handlerName);
var stopwatch = Stopwatch.StartNew();
try
{
await inner.HandleAsync(domainEvent, cancellationToken);
stopwatch.Stop();
logger.LogInformation(
"Handled {EventName} for {AggregateId} with {HandlerName} in {ElapsedMs}ms",
eventName, aggregateId, handlerName, stopwatch.ElapsedMilliseconds);
}
catch (Exception ex)
{
stopwatch.Stop();
logger.LogError(ex,
"Failed to handle {EventName} for {AggregateId} with {HandlerName} after {ElapsedMs}ms",
eventName, aggregateId, handlerName, stopwatch.ElapsedMilliseconds);
throw;
}
}
}

View file

@ -0,0 +1,35 @@
using Books.Api;
using Books.Api.GraphQL;
using GraphQL;
using GraphQL.Server.Ui.Altair;
using Hangfire;
// Enable legacy timestamp behavior for Npgsql 6.0+
// This allows DateTimeOffset with non-UTC offsets to be written to timestamptz columns
AppContext.SetSwitch("Npgsql.EnableLegacyTimestampBehavior", true);
var builder = WebApplication.CreateBuilder(args);
// Configure EventFlow, Hangfire, GraphQL and all services
Startup.ConfigureServices(builder.Services, builder.Configuration, builder.Environment);
var app = builder.Build();
// Configure the HTTP request pipeline
if (app.Environment.IsDevelopment())
{
app.UseHangfireDashboard();
}
app.UseHttpsRedirection();
// GraphQL endpoint
app.UseGraphQL<BooksSchema>("/graphql");
// GraphQL UI (development only) - use external tools like Altair, Insomnia, or GraphiQL
if (app.Environment.IsDevelopment())
{
app.UseGraphQLAltair("/graphql/ui");
}
app.Run();

View file

@ -0,0 +1,23 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "http://localhost:5142",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "https://localhost:7141;http://localhost:5142",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

View file

@ -0,0 +1,77 @@
using Books.Api.EventFlow.Extensions;
using Books.Api.EventFlow.Infrastructure;
using Books.Api.GraphQL;
using Books.Api.Infrastructure;
using Books.Api.Logging;
using EventFlow;
using EventFlow.Configuration;
using EventFlow.Extensions;
using EventFlow.Hangfire.Extensions;
using EventFlow.PostgreSql.Connections;
using EventFlow.PostgreSql.Extensions;
using EventFlow.ReadStores;
using EventFlow.Subscribers;
using GraphQL;
using Hangfire;
using Hangfire.PostgreSql;
using Npgsql;
namespace Books.Api;
public static class Startup
{
public static void ConfigureServices(IServiceCollection services, IConfiguration config, IHostEnvironment? environment = null)
{
var connectionString = config.GetConnectionString("Default")
?? throw new InvalidOperationException("Connection string 'Default' not found");
// Run database migrations (skipped in Test environment where migrations run separately with test connection string)
var isTestEnvironment = environment?.EnvironmentName == "Test";
if (!isTestEnvironment)
{
DatabaseMigrator.Migrate(connectionString);
}
// PostgreSQL data source
var dataSource = new NpgsqlDataSourceBuilder(connectionString).Build();
services.AddSingleton(dataSource);
// Hangfire
services.AddHangfire(c => c
.SetDataCompatibilityLevel(CompatibilityLevel.Version_180)
.UseSimpleAssemblyNameTypeSerializer()
.UseRecommendedSerializerSettings()
.UsePostgreSqlStorage(o => o.UseNpgsqlConnection(connectionString)));
services.AddHangfireServer();
// Scheduler abstraction over Hangfire
services.AddSingleton<IScheduler, HangfireScheduler>();
// EventFlow
services.AddEventFlow(o => o
.UsePostgreSqlEventStore()
.ConfigurePostgreSql(PostgreSqlConfiguration.New.SetConnectionString(connectionString))
.AddDefaults(typeof(Startup).Assembly)
.AddReadModels()
.Configure(c => c.IsAsynchronousSubscribersEnabled = true)
.UseHangfireJobScheduler());
// Resilience strategies
services.AddSingleton<IDispatchToReadStoresResilienceStrategy, ReadStoresResilienceStrategy>();
services.AddSingleton<IDispatchToSubscriberResilienceStrategy, DispatchToSubscriberResilienceStrategy>();
// Read model repositories
services.AddRepositories();
// Logging decorators
services.DecorateAsyncEventHandlersWithLogging();
// GraphQL
services.AddGraphQL(builder => builder
.AddSchema<BooksSchema>()
.AddSystemTextJson()
.AddDataLoader()
.AddGraphTypes(typeof(BooksSchema).Assembly)
.AddErrorInfoProvider(opt => opt.ExposeExceptionDetails = true));
}
}

View file

@ -0,0 +1,12 @@
namespace Books.Api;
public class WeatherForecast
{
public DateOnly Date { get; set; }
public int TemperatureC { get; set; }
public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
public string? Summary { get; set; }
}

View file

@ -0,0 +1,14 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"EventFlow": "Information",
"Hangfire": "Information"
}
},
"AllowedHosts": "*",
"ConnectionStrings": {
"Default": "Host=localhost;Database=books;Username=postgres;Password=postgres;Include Error Detail=true"
}
}

11
backend/Books.slnx Normal file
View file

@ -0,0 +1,11 @@
<Solution>
<Project Path="..\..\enable-banking-access\src\EnableBanking.Client\EnableBanking.Client.csproj" Type="Classic C#" />
<Project Path="..\..\enable-banking-access\src\EnableBanking.Core\EnableBanking.Core.csproj" Type="Classic C#" />
<Project Path="..\..\enable-banking-access\src\EnableBanking.Persistence.Postgres\EnableBanking.Persistence.Postgres.csproj" Type="Classic C#" />
<Project Path="..\..\enable-banking-access\tests\EnableBanking.Client.Tests\EnableBanking.Client.Tests.csproj" Type="Classic C#" />
<Project Path="..\..\enable-banking-access\tests\EnableBanking.Core.Tests\EnableBanking.Core.Tests.csproj" Type="Classic C#" />
<Project Path="..\..\ledger\src\Ledger.Core\Ledger.Core.csproj" Type="Classic C#" />
<Project Path="..\..\ledger\src\Ledger.Infrastructure\Ledger.Infrastructure.csproj" Type="Classic C#" />
<Project Path="Books.Api.Tests\Books.Api.Tests.csproj" Type="Classic C#" />
<Project Path="Books.Api/Books.Api.csproj" />
</Solution>

13
frontend/index.html Normal file
View file

@ -0,0 +1,13 @@
<!doctype html>
<html lang="da">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Bogfoering - Regnskabssystem</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

6459
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

40
frontend/package.json Normal file
View file

@ -0,0 +1,40 @@
{
"name": "books",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@ant-design/charts": "^2.2.1",
"@ant-design/icons": "^5.5.1",
"@tanstack/react-query": "^5.62.7",
"antd": "^5.22.3",
"dayjs": "^1.11.13",
"graphql": "^16.9.0",
"graphql-request": "^7.1.2",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^7.1.1",
"xlsx": "^0.18.5",
"zustand": "^5.0.2"
},
"devDependencies": {
"@eslint/js": "^9.17.0",
"@types/node": "^22.10.2",
"@types/react": "^18.3.17",
"@types/react-dom": "^18.3.5",
"@vitejs/plugin-react": "^4.3.4",
"eslint": "^9.17.0",
"eslint-plugin-react-hooks": "^5.1.0",
"eslint-plugin-react-refresh": "^0.4.16",
"globals": "^15.14.0",
"typescript": "~5.6.2",
"typescript-eslint": "^8.18.2",
"vite": "^6.0.5"
}
}

18
frontend/src/App.tsx Normal file
View file

@ -0,0 +1,18 @@
import { BrowserRouter } from 'react-router-dom';
import { App as AntApp } from 'antd';
import AppRoutes from './routes';
import AppLayout from './components/layout/AppLayout';
function App() {
return (
<AntApp>
<BrowserRouter>
<AppLayout>
<AppRoutes />
</AppLayout>
</BrowserRouter>
</AntApp>
);
}
export default App;

View file

@ -0,0 +1,73 @@
import { GraphQLClient } from 'graphql-request';
import { QueryClient } from '@tanstack/react-query';
// GraphQL endpoint - configure based on environment
const GRAPHQL_ENDPOINT = import.meta.env.VITE_GRAPHQL_ENDPOINT || 'http://localhost:5000/graphql';
// Create GraphQL client
export const graphqlClient = new GraphQLClient(GRAPHQL_ENDPOINT, {
headers: {
// Add auth headers here when authentication is implemented
// 'Authorization': `Bearer ${token}`,
},
});
// Configure headers dynamically (for auth tokens, etc.)
export const setAuthHeader = (token: string) => {
graphqlClient.setHeader('Authorization', `Bearer ${token}`);
};
export const removeAuthHeader = () => {
graphqlClient.setHeader('Authorization', '');
};
// Create TanStack Query client with default options
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
// Cache data for 5 minutes
staleTime: 5 * 60 * 1000,
// Keep unused data in cache for 30 minutes
gcTime: 30 * 60 * 1000,
// Retry failed requests 3 times
retry: 3,
// Retry delay with exponential backoff
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
// Refetch on window focus for fresh data
refetchOnWindowFocus: true,
},
mutations: {
// Retry mutations once
retry: 1,
},
},
});
// Helper function to make GraphQL requests with error handling
export async function fetchGraphQL<TData, TVariables extends Record<string, unknown>>(
query: string,
variables?: TVariables
): Promise<TData> {
try {
const data = await graphqlClient.request<TData>(query, variables);
return data;
} catch (error) {
// Log error for debugging
console.error('GraphQL Error:', error);
// Re-throw with more context
if (error instanceof Error) {
throw new Error(`GraphQL request failed: ${error.message}`);
}
throw error;
}
}
// Type-safe query helper
export function createQueryKey(base: string, params?: Record<string, unknown>): string[] {
const key = [base];
if (params) {
key.push(JSON.stringify(params));
}
return key;
}

View file

@ -0,0 +1,40 @@
import { Layout } from 'antd';
import { ReactNode } from 'react';
import Sidebar from './Sidebar';
import Header from './Header';
import { useUIStore } from '@/stores/uiStore';
const { Content } = Layout;
interface AppLayoutProps {
children: ReactNode;
}
export default function AppLayout({ children }: AppLayoutProps) {
const sidebarCollapsed = useUIStore((state) => state.sidebarCollapsed);
return (
<Layout style={{ minHeight: '100vh' }}>
<Sidebar />
<Layout
style={{
marginLeft: sidebarCollapsed ? 80 : 220,
transition: 'margin-left 0.2s',
}}
>
<Header />
<Content
style={{
margin: '16px',
padding: '16px',
background: '#fff',
borderRadius: 8,
minHeight: 'calc(100vh - 64px - 32px)',
}}
>
{children}
</Content>
</Layout>
</Layout>
);
}

View file

@ -0,0 +1,94 @@
import { Select, Space, Typography, Tag } from 'antd';
import { ShopOutlined } from '@ant-design/icons';
import { useCompanyStore } from '@/stores/companyStore';
import { formatCVR } from '@/lib/formatters';
import type { Company } from '@/types/accounting';
const { Text } = Typography;
// Mock data - will be replaced with API call
const mockCompanies: Company[] = [
{
id: '1',
name: 'Demo Virksomhed ApS',
cvr: '12345678',
address: 'Hovedgaden 1',
city: 'Koebenhavn',
postalCode: '1000',
country: 'DK',
fiscalYearStart: 1,
currency: 'DKK',
createdAt: '2024-01-01',
updatedAt: '2024-01-01',
},
{
id: '2',
name: 'Anden Virksomhed A/S',
cvr: '87654321',
address: 'Sidegaden 2',
city: 'Aarhus',
postalCode: '8000',
country: 'DK',
fiscalYearStart: 7,
currency: 'DKK',
createdAt: '2024-01-01',
updatedAt: '2024-01-01',
},
];
export default function CompanySwitcher() {
const { activeCompany, setActiveCompany, setCompanies } = useCompanyStore();
// Initialize with mock data if needed
if (useCompanyStore.getState().companies.length === 0) {
setCompanies(mockCompanies);
if (!activeCompany) {
setActiveCompany(mockCompanies[0]);
}
}
const companies = useCompanyStore((state) => state.companies);
const handleCompanyChange = (companyId: string) => {
const company = companies.find((c) => c.id === companyId);
if (company) {
setActiveCompany(company);
}
};
return (
<Space>
<ShopOutlined style={{ fontSize: 18, color: '#1677ff' }} />
<Select
value={activeCompany?.id}
onChange={handleCompanyChange}
style={{ minWidth: 280 }}
optionLabelProp="label"
popupMatchSelectWidth={false}
options={companies.map((company) => ({
value: company.id,
label: company.name,
company,
}))}
optionRender={(option) => {
const company = option.data.company as Company;
return (
<Space direction="vertical" size={0} style={{ padding: '4px 0' }}>
<Space>
<Text strong>{company.name}</Text>
{company.id === activeCompany?.id && (
<Tag color="blue" style={{ marginLeft: 8 }}>
Aktiv
</Tag>
)}
</Space>
<Text type="secondary" style={{ fontSize: 12 }}>
CVR: {formatCVR(company.cvr)}
</Text>
</Space>
);
}}
/>
</Space>
);
}

View file

@ -0,0 +1,242 @@
// FiscalYearSelector - Dropdown for selecting active fiscal year (regnskabsår)
import { useState, useEffect } from 'react';
import { Select, Space, Typography, Tag, Divider, Button } from 'antd';
import {
CalendarOutlined,
PlusOutlined,
SettingOutlined,
CheckCircleOutlined,
MinusCircleOutlined,
LockOutlined,
} from '@ant-design/icons';
import { usePeriodStore } from '@/stores/periodStore';
import type { FiscalYear } from '@/types/periods';
import { formatDateShort } from '@/lib/formatters';
import CreateFiscalYearModal from '@/components/modals/CreateFiscalYearModal';
const { Text } = Typography;
// Mock data - will be replaced with API call
const mockFiscalYears: FiscalYear[] = [
{
id: 'fy-2025',
companyId: '1',
name: '2025',
startDate: '2025-01-01',
endDate: '2025-12-31',
status: 'open',
openingBalancePosted: true,
createdAt: '2025-01-01',
updatedAt: '2025-01-01',
},
{
id: 'fy-2024',
companyId: '1',
name: '2024',
startDate: '2024-01-01',
endDate: '2024-12-31',
status: 'closed',
closingDate: '2025-01-15',
closedBy: 'user-1',
openingBalancePosted: true,
createdAt: '2024-01-01',
updatedAt: '2025-01-15',
},
{
id: 'fy-2023',
companyId: '1',
name: '2023',
startDate: '2023-01-01',
endDate: '2023-12-31',
status: 'locked',
closingDate: '2024-01-20',
closedBy: 'user-1',
openingBalancePosted: true,
createdAt: '2023-01-01',
updatedAt: '2024-01-20',
},
];
/**
* Status badge configuration
*/
const STATUS_CONFIG: Record<FiscalYear['status'], {
color: string;
icon: React.ReactNode;
label: string;
}> = {
open: {
color: 'success',
icon: <CheckCircleOutlined />,
label: 'Åben',
},
closed: {
color: 'warning',
icon: <MinusCircleOutlined />,
label: 'Lukket',
},
locked: {
color: 'error',
icon: <LockOutlined />,
label: 'Låst',
},
};
interface FiscalYearSelectorProps {
onCreateNew?: () => void;
onManage?: () => void;
}
export default function FiscalYearSelector({ onCreateNew, onManage }: FiscalYearSelectorProps) {
const {
fiscalYears,
currentFiscalYear,
setFiscalYears,
setCurrentFiscalYear,
} = usePeriodStore();
const [createModalOpen, setCreateModalOpen] = useState(false);
// Initialize with mock data if needed (will be replaced with API call)
useEffect(() => {
if (fiscalYears.length === 0) {
setFiscalYears(mockFiscalYears);
}
}, [fiscalYears.length, setFiscalYears]);
// Set default fiscal year if none selected
useEffect(() => {
if (fiscalYears.length > 0 && !currentFiscalYear) {
// Default to most recent open year, or first year
const openYear = fiscalYears.find(y => y.status === 'open');
setCurrentFiscalYear(openYear || fiscalYears[0]);
}
}, [fiscalYears, currentFiscalYear, setCurrentFiscalYear]);
const handleFiscalYearChange = (yearId: string) => {
const year = fiscalYears.find((y) => y.id === yearId);
if (year) {
setCurrentFiscalYear(year);
}
};
const handleCreateNew = () => {
if (onCreateNew) {
onCreateNew();
} else {
setCreateModalOpen(true);
}
};
const handleCloseCreateModal = () => {
setCreateModalOpen(false);
};
const handleCreateSuccess = (newYear: FiscalYear) => {
setCurrentFiscalYear(newYear);
setCreateModalOpen(false);
};
const handleManage = () => {
if (onManage) {
onManage();
} else {
// Navigate to settings page
console.log('Navigate to fiscal year settings');
}
};
// Sort fiscal years by start date descending (newest first)
const sortedYears = [...fiscalYears].sort(
(a, b) => new Date(b.startDate).getTime() - new Date(a.startDate).getTime()
);
return (
<Space>
<CalendarOutlined style={{ fontSize: 16, color: '#8c8c8c' }} />
<Select
value={currentFiscalYear?.id}
onChange={handleFiscalYearChange}
style={{ minWidth: 200 }}
optionLabelProp="label"
popupMatchSelectWidth={false}
dropdownRender={(menu) => (
<>
{menu}
<Divider style={{ margin: '8px 0' }} />
<Space style={{ padding: '0 8px 8px' }}>
<Button
type="text"
icon={<PlusOutlined />}
onClick={handleCreateNew}
size="small"
>
Opret nyt regnskabsår
</Button>
<Button
type="text"
icon={<SettingOutlined />}
onClick={handleManage}
size="small"
>
Administrer
</Button>
</Space>
</>
)}
options={sortedYears.map((year) => ({
value: year.id,
label: `Regnskabsår ${year.name}`,
year,
}))}
optionRender={(option) => {
const year = option.data.year;
// Type guard - ensure year exists and has required properties
if (!year || typeof year !== 'object' || !('status' in year)) {
return null;
}
const fiscalYear = year as FiscalYear;
const statusConfig = STATUS_CONFIG[fiscalYear.status];
return (
<Space
direction="vertical"
size={0}
style={{ padding: '4px 0', width: '100%' }}
>
<Space style={{ width: '100%', justifyContent: 'space-between' }}>
<Text strong>{fiscalYear.name}</Text>
<Tag
color={statusConfig.color}
icon={statusConfig.icon}
style={{ marginLeft: 8 }}
>
{statusConfig.label}
</Tag>
</Space>
<Text type="secondary" style={{ fontSize: 12 }}>
{formatDateShort(fiscalYear.startDate)} - {formatDateShort(fiscalYear.endDate)}
</Text>
</Space>
);
}}
/>
{currentFiscalYear && (
<Tag
color={STATUS_CONFIG[currentFiscalYear.status].color}
icon={STATUS_CONFIG[currentFiscalYear.status].icon}
>
{STATUS_CONFIG[currentFiscalYear.status].label}
</Tag>
)}
{/* Create Fiscal Year Modal */}
<CreateFiscalYearModal
open={createModalOpen}
onClose={handleCloseCreateModal}
onSuccess={handleCreateSuccess}
/>
</Space>
);
}

View file

@ -0,0 +1,113 @@
import { Layout, Space, Button, Dropdown, Avatar, Typography, Divider } from 'antd';
import {
UserOutlined,
LogoutOutlined,
SettingOutlined,
BellOutlined,
QuestionCircleOutlined,
} from '@ant-design/icons';
import type { MenuProps } from 'antd';
import CompanySwitcher from './CompanySwitcher';
import FiscalYearSelector from './FiscalYearSelector';
const { Header: AntHeader } = Layout;
const { Text } = Typography;
export default function Header() {
const userMenuItems: MenuProps['items'] = [
{
key: 'profile',
icon: <UserOutlined />,
label: 'Min profil',
},
{
key: 'settings',
icon: <SettingOutlined />,
label: 'Indstillinger',
},
{
type: 'divider',
},
{
key: 'logout',
icon: <LogoutOutlined />,
label: 'Log ud',
danger: true,
},
];
const handleUserMenuClick: MenuProps['onClick'] = ({ key }) => {
switch (key) {
case 'logout':
// Handle logout
console.log('Logout clicked');
break;
case 'settings':
// Navigate to settings
break;
case 'profile':
// Navigate to profile
break;
}
};
return (
<AntHeader
style={{
padding: '0 24px',
background: '#fff',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
borderBottom: '1px solid #f0f0f0',
position: 'sticky',
top: 0,
zIndex: 10,
}}
>
{/* Left side - Company Switcher and Fiscal Year Selector */}
<Space split={<Divider type="vertical" style={{ height: 24, margin: '0 8px' }} />}>
<CompanySwitcher />
<FiscalYearSelector />
</Space>
{/* Right side - User actions */}
<Space size="middle">
{/* Help */}
<Button
type="text"
icon={<QuestionCircleOutlined />}
title="Hjaelp"
/>
{/* Notifications */}
<Button
type="text"
icon={<BellOutlined />}
title="Notifikationer"
/>
{/* User Menu */}
<Dropdown
menu={{
items: userMenuItems,
onClick: handleUserMenuClick,
}}
placement="bottomRight"
trigger={['click']}
>
<Space style={{ cursor: 'pointer' }}>
<Avatar
size="small"
icon={<UserOutlined />}
style={{ backgroundColor: '#1677ff' }}
/>
<Text style={{ maxWidth: 120 }} ellipsis>
Bruger
</Text>
</Space>
</Dropdown>
</Space>
</AntHeader>
);
}

View file

@ -0,0 +1,135 @@
import { Layout, Menu } from 'antd';
import {
DashboardOutlined,
BookOutlined,
BankOutlined,
AccountBookOutlined,
PercentageOutlined,
TeamOutlined,
SettingOutlined,
FileTextOutlined,
ThunderboltOutlined,
} from '@ant-design/icons';
import { useNavigate, useLocation } from 'react-router-dom';
import { useUIStore } from '@/stores/uiStore';
import type { MenuProps } from 'antd';
const { Sider } = Layout;
type MenuItem = Required<MenuProps>['items'][number];
function getItem(
label: React.ReactNode,
key: string,
icon?: React.ReactNode,
children?: MenuItem[]
): MenuItem {
return {
key,
icon,
children,
label,
} as MenuItem;
}
const menuItems: MenuItem[] = [
getItem('Dashboard', '/', <DashboardOutlined />),
getItem('Bogfoering', 'accounting', <BookOutlined />, [
getItem('Hurtig Bogfoering', '/hurtig-bogforing', <ThunderboltOutlined />),
getItem('Kassekladde', '/kassekladde', <FileTextOutlined />),
getItem('Kontooversigt', '/kontooversigt', <AccountBookOutlined />),
]),
getItem('Bank', 'bank', <BankOutlined />, [
getItem('Bankafstemning', '/bankafstemning', <BankOutlined />),
]),
getItem('Rapportering', 'reporting', <PercentageOutlined />, [
getItem('Momsindberetning', '/momsindberetning', <PercentageOutlined />),
getItem('Loenforstaelse', '/loenforstaelse', <TeamOutlined />),
]),
getItem('Indstillinger', '/indstillinger', <SettingOutlined />),
];
export default function Sidebar() {
const navigate = useNavigate();
const location = useLocation();
const { sidebarCollapsed, toggleSidebar } = useUIStore();
const handleMenuClick: MenuProps['onClick'] = ({ key }) => {
if (key.startsWith('/')) {
navigate(key);
}
};
// Determine selected keys based on current path
const selectedKeys = [location.pathname];
// Determine open keys for submenus
const getOpenKeys = () => {
const path = location.pathname;
if (path === '/kassekladde' || path === '/kontooversigt' || path === '/hurtig-bogforing') {
return ['accounting'];
}
if (path === '/bankafstemning') {
return ['bank'];
}
if (path === '/momsindberetning' || path === '/loenforstaelse') {
return ['reporting'];
}
return [];
};
return (
<Sider
collapsible
collapsed={sidebarCollapsed}
onCollapse={toggleSidebar}
width={220}
style={{
overflow: 'auto',
height: '100vh',
position: 'fixed',
left: 0,
top: 0,
bottom: 0,
zIndex: 100,
}}
>
{/* Logo */}
<div
style={{
height: 64,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
borderBottom: '1px solid rgba(255, 255, 255, 0.1)',
}}
>
<span
style={{
color: '#fff',
fontSize: sidebarCollapsed ? 16 : 18,
fontWeight: 600,
whiteSpace: 'nowrap',
}}
>
{sidebarCollapsed ? 'B' : 'Bogfoering'}
</span>
</div>
{/* Navigation Menu */}
<Menu
theme="dark"
mode="inline"
selectedKeys={selectedKeys}
defaultOpenKeys={getOpenKeys()}
items={menuItems}
onClick={handleMenuClick}
style={{ borderRight: 0 }}
/>
</Sider>
);
}

View file

@ -0,0 +1,521 @@
// CloseFiscalYearWizard - Multi-step wizard for year-end closing (årsafslutning)
import { useState, useEffect, useMemo } from 'react';
import {
Modal,
Steps,
Button,
Alert,
Typography,
Table,
Statistic,
Row,
Col,
Card,
Select,
Divider,
Checkbox,
Result,
Tag,
Form,
} from 'antd';
import {
CheckCircleOutlined,
WarningOutlined,
LockOutlined,
ArrowRightOutlined,
ExclamationCircleOutlined,
} from '@ant-design/icons';
import { usePeriodStore } from '@/stores/periodStore';
import {
calculateClosingSummary,
validateFiscalYearClose,
generateClosingEntries,
calculateClosingBalances,
type FiscalYearClosingSummary,
type GeneratedClosingEntry,
} from '@/lib/fiscalYear';
import { formatCurrency } from '@/lib/formatters';
import type { FiscalYear } from '@/types/periods';
import type { Account, Transaction } from '@/types/accounting';
const { Text, Title, Paragraph } = Typography;
interface CloseFiscalYearWizardProps {
open: boolean;
fiscalYear: FiscalYear;
accounts: Account[];
transactions: Transaction[];
onClose: () => void;
onSuccess?: () => void;
}
type WizardStep = 'validation' | 'summary' | 'transfer' | 'confirm' | 'complete';
// Default "Overført resultat" account (equity)
const DEFAULT_RESULT_ACCOUNT = {
id: 'acc-3900',
accountNumber: '3900',
name: 'Overført resultat',
};
export default function CloseFiscalYearWizard({
open,
fiscalYear,
accounts,
transactions,
onClose,
onSuccess,
}: CloseFiscalYearWizardProps) {
const [currentStep, setCurrentStep] = useState<WizardStep>('validation');
const [isSubmitting, setIsSubmitting] = useState(false);
const [resultAccountId, setResultAccountId] = useState<string>(DEFAULT_RESULT_ACCOUNT.id);
const [createNextYear, setCreateNextYear] = useState(true);
const [closeOpenPeriods, setCloseOpenPeriods] = useState(true);
const [confirmLock, setConfirmLock] = useState(false);
const {
periods,
closeFiscalYear,
lockFiscalYear,
closePeriod,
lockPeriod,
} = usePeriodStore();
// Reset wizard when opened
useEffect(() => {
if (open) {
setCurrentStep('validation');
setIsSubmitting(false);
setConfirmLock(false);
}
}, [open]);
// Calculate closing balances
const closingBalances = useMemo(
() => calculateClosingBalances(fiscalYear, accounts, transactions),
[fiscalYear, accounts, transactions]
);
// Calculate summary
const summary: FiscalYearClosingSummary = useMemo(
() => calculateClosingSummary(fiscalYear, periods, closingBalances, transactions),
[fiscalYear, periods, closingBalances, transactions]
);
// Validate fiscal year close
const validation = useMemo(
() => validateFiscalYearClose(fiscalYear, periods),
[fiscalYear, periods]
);
// Generate closing entries preview
const closingEntries: GeneratedClosingEntry[] = useMemo(() => {
const resultAccount = accounts.find((a) => a.id === resultAccountId) || {
id: resultAccountId,
accountNumber: DEFAULT_RESULT_ACCOUNT.accountNumber,
name: DEFAULT_RESULT_ACCOUNT.name,
};
return generateClosingEntries(
fiscalYear,
closingBalances,
resultAccount.id,
resultAccount.accountNumber,
resultAccount.name
);
}, [fiscalYear, closingBalances, resultAccountId, accounts]);
// Equity accounts for result transfer
const equityAccounts = useMemo(
() => accounts.filter((a) => a.type === 'equity'),
[accounts]
);
// Year periods
const yearPeriods = useMemo(
() => periods.filter((p) => p.fiscalYearId === fiscalYear.id),
[periods, fiscalYear.id]
);
const openPeriodsInYear = yearPeriods.filter((p) => p.status === 'open');
const handleNext = () => {
const steps: WizardStep[] = ['validation', 'summary', 'transfer', 'confirm', 'complete'];
const currentIndex = steps.indexOf(currentStep);
if (currentIndex < steps.length - 1) {
setCurrentStep(steps[currentIndex + 1]);
}
};
const handlePrevious = () => {
const steps: WizardStep[] = ['validation', 'summary', 'transfer', 'confirm', 'complete'];
const currentIndex = steps.indexOf(currentStep);
if (currentIndex > 0) {
setCurrentStep(steps[currentIndex - 1]);
}
};
const handleSubmit = async () => {
setIsSubmitting(true);
try {
// 1. Close open periods if requested
if (closeOpenPeriods) {
for (const period of openPeriodsInYear) {
closePeriod(period.id, 'system');
}
}
// 2. Lock all periods in the year
for (const period of yearPeriods) {
lockPeriod(period.id, 'system');
}
// 3. Close and lock the fiscal year
closeFiscalYear(fiscalYear.id, 'system');
lockFiscalYear(fiscalYear.id, 'system');
// 4. Move to complete step
setCurrentStep('complete');
onSuccess?.();
} catch (error) {
console.error('Failed to close fiscal year:', error);
} finally {
setIsSubmitting(false);
}
};
const getStepNumber = (step: WizardStep): number => {
const steps: WizardStep[] = ['validation', 'summary', 'transfer', 'confirm', 'complete'];
return steps.indexOf(step);
};
const renderValidationStep = () => (
<div>
<Title level={5}>Validering af regnskabsår {fiscalYear.name}</Title>
<Paragraph type="secondary">
Før årsafslutning kontrolleres regnskabsåret for eventuelle problemer.
</Paragraph>
{validation.errors.length > 0 && (
<Alert
type="error"
message="Fejl fundet"
description={
<ul style={{ margin: 0, paddingLeft: 16 }}>
{validation.errors.map((err, idx) => (
<li key={idx}>{err.messageDanish}</li>
))}
</ul>
}
style={{ marginBottom: 16 }}
/>
)}
{validation.warnings.length > 0 && (
<Alert
type="warning"
icon={<WarningOutlined />}
message="Advarsler"
description={
<ul style={{ margin: 0, paddingLeft: 16 }}>
{validation.warnings.map((warn, idx) => (
<li key={idx}>{warn.messageDanish}</li>
))}
</ul>
}
style={{ marginBottom: 16 }}
/>
)}
{summary.unreconciledCount > 0 && (
<Alert
type="info"
icon={<ExclamationCircleOutlined />}
message={`${summary.unreconciledCount} transaktioner er ikke afstemt`}
description="Det anbefales at afstemme alle transaktioner før årsafslutning."
style={{ marginBottom: 16 }}
/>
)}
{openPeriodsInYear.length > 0 && (
<Card size="small" style={{ marginBottom: 16 }}>
<Checkbox
checked={closeOpenPeriods}
onChange={(e) => setCloseOpenPeriods(e.target.checked)}
>
<Text>
Luk automatisk {openPeriodsInYear.length} åbne periode(r) ved årsafslutning
</Text>
</Checkbox>
</Card>
)}
{validation.isValid && validation.warnings.length === 0 && (
<Alert
type="success"
icon={<CheckCircleOutlined />}
message="Regnskabsåret er klar til afslutning"
description="Ingen fejl eller advarsler fundet."
/>
)}
</div>
);
const renderSummaryStep = () => (
<div>
<Title level={5}>Resultatoversigt for {fiscalYear.name}</Title>
<Row gutter={16} style={{ marginBottom: 24 }}>
<Col span={8}>
<Card>
<Statistic
title="Samlet indtægt"
value={summary.totalRevenue}
formatter={(val) => formatCurrency(Number(val))}
valueStyle={{ color: '#3f8600' }}
/>
</Card>
</Col>
<Col span={8}>
<Card>
<Statistic
title="Samlet udgift"
value={summary.totalExpenses}
formatter={(val) => formatCurrency(Number(val))}
valueStyle={{ color: '#cf1322' }}
/>
</Card>
</Col>
<Col span={8}>
<Card>
<Statistic
title="Årsresultat"
value={summary.netResult}
formatter={(val) => formatCurrency(Number(val))}
valueStyle={{ color: summary.netResult >= 0 ? '#3f8600' : '#cf1322' }}
prefix={summary.netResult >= 0 ? '+' : ''}
/>
</Card>
</Col>
</Row>
<Divider />
<Text type="secondary">
Årsresultatet <strong>{formatCurrency(summary.netResult)}</strong> vil blive
overført til egenkapitalen ved årsafslutning.
</Text>
</div>
);
const renderTransferStep = () => (
<div>
<Title level={5}>Resultatoverførsel</Title>
<Paragraph type="secondary">
Vælg hvilken egenkapitalkonto årsresultatet skal overføres til.
</Paragraph>
<Form.Item label="Egenkapitalkonto for resultatoverførsel">
<Select
value={resultAccountId}
onChange={setResultAccountId}
style={{ width: '100%' }}
options={[
...equityAccounts.map((account) => ({
value: account.id,
label: `${account.accountNumber} - ${account.name}`,
})),
{
value: DEFAULT_RESULT_ACCOUNT.id,
label: `${DEFAULT_RESULT_ACCOUNT.accountNumber} - ${DEFAULT_RESULT_ACCOUNT.name} (Standard)`,
},
]}
/>
</Form.Item>
<Alert
type="info"
message="Resultatoverførsel"
description={
<Text>
Årsresultatet <strong>{formatCurrency(summary.netResult)}</strong> vil blive
bogført som {summary.netResult >= 0 ? 'kredit' : 'debet'} den valgte konto.
</Text>
}
/>
</div>
);
const renderConfirmStep = () => (
<div>
<Title level={5}>Bekræft årsafslutning</Title>
<Alert
type="warning"
icon={<LockOutlined />}
message="Permanent handling"
description="Når regnskabsåret er låst, kan det ikke genåbnes. Alle perioder vil også blive låst."
style={{ marginBottom: 16 }}
/>
<Card title="Lukkeposter der oprettes" size="small" style={{ marginBottom: 16 }}>
<Table
dataSource={closingEntries.map((entry, idx) => ({ ...entry, key: idx }))}
columns={[
{
title: 'Type',
dataIndex: 'type',
key: 'type',
render: (type: string) => (
<Tag>
{type === 'revenue-close' ? 'Luk indtægter' : 'Luk udgifter'}
</Tag>
),
},
{
title: 'Beskrivelse',
dataIndex: 'descriptionDanish',
key: 'description',
},
{
title: 'Beløb',
dataIndex: 'totalAmount',
key: 'amount',
align: 'right',
render: (amount: number) => formatCurrency(amount),
},
]}
pagination={false}
size="small"
/>
</Card>
<Card size="small" style={{ marginBottom: 16 }}>
<Checkbox
checked={createNextYear}
onChange={(e) => setCreateNextYear(e.target.checked)}
>
<Text>Opret automatisk næste regnskabsår</Text>
</Checkbox>
</Card>
<Alert
type="error"
icon={<ExclamationCircleOutlined />}
message="Bekræft låsning"
description={
<Checkbox
checked={confirmLock}
onChange={(e) => setConfirmLock(e.target.checked)}
style={{ marginTop: 8 }}
>
<Text strong>
Jeg bekræfter at jeg vil låse regnskabsår {fiscalYear.name} permanent.
Dette kan ikke fortrydes.
</Text>
</Checkbox>
}
/>
</div>
);
const renderCompleteStep = () => (
<Result
status="success"
icon={<CheckCircleOutlined />}
title="Årsafslutning gennemført"
subTitle={`Regnskabsår ${fiscalYear.name} er nu lukket og låst.`}
extra={[
<Button type="primary" key="close" onClick={onClose}>
Luk
</Button>,
]}
/>
);
const renderStepContent = () => {
switch (currentStep) {
case 'validation':
return renderValidationStep();
case 'summary':
return renderSummaryStep();
case 'transfer':
return renderTransferStep();
case 'confirm':
return renderConfirmStep();
case 'complete':
return renderCompleteStep();
default:
return null;
}
};
const canProceed = currentStep === 'validation' ? validation.isValid : true;
const isLastStep = currentStep === 'confirm';
const isComplete = currentStep === 'complete';
return (
<Modal
title={`Årsafslutning - ${fiscalYear.name}`}
open={open}
onCancel={onClose}
width={700}
footer={
isComplete
? null
: [
<Button key="cancel" onClick={onClose}>
Annuller
</Button>,
currentStep !== 'validation' && (
<Button key="prev" onClick={handlePrevious}>
Tilbage
</Button>
),
isLastStep ? (
<Button
key="submit"
type="primary"
danger
icon={<LockOutlined />}
loading={isSubmitting}
disabled={!confirmLock}
onClick={handleSubmit}
>
Gennemfør årsafslutning
</Button>
) : (
<Button
key="next"
type="primary"
onClick={handleNext}
disabled={!canProceed}
icon={<ArrowRightOutlined />}
>
Næste
</Button>
),
].filter(Boolean)
}
destroyOnClose
>
<Steps
current={getStepNumber(currentStep)}
size="small"
style={{ marginBottom: 24 }}
items={[
{ title: 'Validering' },
{ title: 'Oversigt' },
{ title: 'Overførsel' },
{ title: 'Bekræft' },
{ title: 'Færdig' },
]}
/>
{renderStepContent()}
</Modal>
);
}

View file

@ -0,0 +1,308 @@
// CreateFiscalYearModal - Modal for creating a new fiscal year (regnskabsår)
import { useState, useEffect } from 'react';
import {
Modal,
Form,
DatePicker,
Input,
Select,
Alert,
Space,
Typography,
Divider,
} from 'antd';
import {
CalendarOutlined,
InfoCircleOutlined,
WarningOutlined,
} from '@ant-design/icons';
import dayjs from 'dayjs';
import type { Dayjs } from 'dayjs';
import { usePeriodStore } from '@/stores/periodStore';
import { useCompanyStore } from '@/stores/companyStore';
import {
getFiscalYearBoundaries,
validateFiscalYearCreation,
} from '@/lib/fiscalYear';
import { generateAccountingPeriods } from '@/lib/periods';
import type { FiscalYear, PeriodFrequency } from '@/types/periods';
const { Text } = Typography;
const { RangePicker } = DatePicker;
interface CreateFiscalYearModalProps {
open: boolean;
onClose: () => void;
onSuccess?: (fiscalYear: FiscalYear) => void;
suggestedDate?: string; // Pre-populate based on trigger context
autoCreate?: boolean; // If true, show confirmation mode for auto-creation
}
interface FormValues {
dateRange: [Dayjs, Dayjs];
name: string;
periodFrequency: PeriodFrequency;
}
export default function CreateFiscalYearModal({
open,
onClose,
onSuccess,
suggestedDate,
autoCreate = false,
}: CreateFiscalYearModalProps) {
const [form] = Form.useForm<FormValues>();
const [isSubmitting, setIsSubmitting] = useState(false);
const [validationResult, setValidationResult] = useState<ReturnType<typeof validateFiscalYearCreation> | null>(null);
const { activeCompany } = useCompanyStore();
const { fiscalYears, addFiscalYear, setPeriods, periods, setCurrentFiscalYear } = usePeriodStore();
// Calculate suggested fiscal year boundaries
useEffect(() => {
if (open && activeCompany) {
const dateToUse = suggestedDate || dayjs().format('YYYY-MM-DD');
const boundaries = getFiscalYearBoundaries(
dateToUse,
activeCompany.fiscalYearStart
);
form.setFieldsValue({
dateRange: [dayjs(boundaries.startDate), dayjs(boundaries.endDate)],
name: boundaries.name,
periodFrequency: 'monthly',
});
// Validate immediately
handleValidation(boundaries.startDate, boundaries.endDate);
}
}, [open, activeCompany, suggestedDate, form]);
const handleValidation = (startDate: string, endDate: string) => {
const result = validateFiscalYearCreation(
{ startDate, endDate },
fiscalYears
);
setValidationResult(result);
};
const handleDateRangeChange = (dates: [Dayjs | null, Dayjs | null] | null) => {
if (dates && dates[0] && dates[1]) {
const startDate = dates[0].format('YYYY-MM-DD');
const endDate = dates[1].format('YYYY-MM-DD');
// Auto-update name
const startYear = dates[0].year();
const endYear = dates[1].year();
const name = startYear === endYear ? `${startYear}` : `${startYear}/${endYear}`;
form.setFieldValue('name', name);
handleValidation(startDate, endDate);
}
};
const handleSubmit = async () => {
try {
const values = await form.validateFields();
setIsSubmitting(true);
if (!activeCompany) {
throw new Error('No active company');
}
const startDate = values.dateRange[0].format('YYYY-MM-DD');
const endDate = values.dateRange[1].format('YYYY-MM-DD');
// Create fiscal year object
const newFiscalYear: FiscalYear = {
id: `fy-${values.name}-${Date.now()}`,
companyId: activeCompany.id,
name: values.name,
startDate,
endDate,
status: 'open',
openingBalancePosted: false,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
// Generate accounting periods and add required fields
const generatedPeriods = generateAccountingPeriods(newFiscalYear, values.periodFrequency);
const now = new Date().toISOString();
const newPeriods = generatedPeriods.map((p, idx) => ({
...p,
id: `period-${newFiscalYear.id}-${idx + 1}`,
createdAt: now,
updatedAt: now,
}));
// Add to store
addFiscalYear(newFiscalYear);
setPeriods([...periods, ...newPeriods]);
// Set as current if this is the first or most recent
const allYears = [...fiscalYears, newFiscalYear];
const sortedYears = allYears.sort(
(a, b) => new Date(b.startDate).getTime() - new Date(a.startDate).getTime()
);
if (sortedYears[0].id === newFiscalYear.id) {
setCurrentFiscalYear(newFiscalYear);
}
onSuccess?.(newFiscalYear);
onClose();
} catch (error) {
console.error('Failed to create fiscal year:', error);
} finally {
setIsSubmitting(false);
}
};
const hasErrors = validationResult?.errors && validationResult.errors.length > 0;
const hasWarnings = validationResult?.warnings && validationResult.warnings.length > 0;
return (
<Modal
title={
<Space>
<CalendarOutlined />
{autoCreate ? 'Opret nyt regnskabsår?' : 'Opret nyt regnskabsår'}
</Space>
}
open={open}
onCancel={onClose}
onOk={handleSubmit}
okText={autoCreate ? 'Ja, opret regnskabsår' : 'Opret regnskabsår'}
cancelText="Annuller"
okButtonProps={{
disabled: hasErrors || isSubmitting || !activeCompany,
loading: isSubmitting,
}}
width={520}
destroyOnClose
>
{!activeCompany && (
<Alert
type="error"
message="Ingen virksomhed valgt"
description="Du skal vælge en virksomhed før du kan oprette et regnskabsår."
style={{ marginBottom: 16 }}
/>
)}
{autoCreate && suggestedDate && (
<Alert
type="info"
icon={<InfoCircleOutlined />}
message="Dato udenfor eksisterende regnskabsår"
description={
<Text>
Datoen <strong>{dayjs(suggestedDate).format('D. MMMM YYYY')}</strong> falder
udenfor eksisterende regnskabsår. Vil du oprette et nyt regnskabsår?
</Text>
}
style={{ marginBottom: 16 }}
/>
)}
<Form
form={form}
layout="vertical"
initialValues={{
periodFrequency: 'monthly',
}}
>
<Form.Item
name="dateRange"
label="Periode"
rules={[{ required: true, message: 'Vælg start- og slutdato' }]}
>
<RangePicker
style={{ width: '100%' }}
format="DD-MM-YYYY"
onChange={handleDateRangeChange}
/>
</Form.Item>
<Form.Item
name="name"
label="Navn"
rules={[{ required: true, message: 'Indtast navn på regnskabsåret' }]}
>
<Input
placeholder="f.eks. 2025 eller 2024/2025"
prefix={<CalendarOutlined />}
/>
</Form.Item>
<Form.Item
name="periodFrequency"
label="Regnskabsperioder"
tooltip="Hvor ofte skal regnskabsperioder oprettes?"
>
<Select
options={[
{ value: 'monthly', label: 'Månedlig (12 perioder)' },
{ value: 'quarterly', label: 'Kvartalsvis (4 perioder)' },
{ value: 'half-yearly', label: 'Halvårlig (2 perioder)' },
{ value: 'yearly', label: 'Årlig (1 periode)' },
]}
/>
</Form.Item>
{/* Validation feedback */}
{hasErrors && (
<Alert
type="error"
message="Kan ikke oprette regnskabsår"
description={
<ul style={{ margin: 0, paddingLeft: 16 }}>
{validationResult?.errors.map((err, idx) => (
<li key={idx}>{err.messageDanish}</li>
))}
</ul>
}
style={{ marginTop: 16 }}
/>
)}
{hasWarnings && !hasErrors && (
<Alert
type="warning"
icon={<WarningOutlined />}
message="Advarsler"
description={
<ul style={{ margin: 0, paddingLeft: 16 }}>
{validationResult?.warnings.map((warn, idx) => (
<li key={idx}>{warn.messageDanish}</li>
))}
</ul>
}
style={{ marginTop: 16 }}
/>
)}
{validationResult?.isValid && !hasWarnings && (
<Alert
type="success"
message="Klar til oprettelse"
description="Regnskabsåret kan oprettes uden problemer."
style={{ marginTop: 16 }}
/>
)}
</Form>
{activeCompany && activeCompany.fiscalYearStart !== 1 && (
<>
<Divider style={{ margin: '16px 0' }} />
<Text type="secondary" style={{ fontSize: 12 }}>
<InfoCircleOutlined style={{ marginRight: 4 }} />
Din virksomhed har skævt regnskabsår (starter i måned {activeCompany.fiscalYearStart}).
Datoerne er automatisk justeret baseret dette.
</Text>
</>
)}
</Modal>
);
}

View file

@ -0,0 +1,220 @@
// AccountQuickPicker - Quick account selection with favorites
import { useState, useMemo } from 'react';
import { Select, Button, Space, Tag, Typography, Divider, Input } from 'antd';
import { StarOutlined, StarFilled, SearchOutlined } from '@ant-design/icons';
import type { Account } from '@/types/accounting';
import type { VATCode } from '@/types/vat';
import { VAT_CODE_CONFIG } from '@/lib/vatCodes';
import { useTopFavorites, useSimpleBookingStore } from '@/stores/simpleBookingStore';
const { Text } = Typography;
interface AccountOption {
id: string;
accountNumber: string;
name: string;
type: string;
}
interface AccountQuickPickerProps {
accounts: AccountOption[];
value?: string;
onChange: (accountId: string, account: AccountOption) => void;
isExpense?: boolean;
favoriteLimit?: number;
placeholder?: string;
}
export function AccountQuickPicker({
accounts,
value,
onChange,
isExpense = true,
favoriteLimit = 6,
placeholder = 'Soeg efter konto...',
}: AccountQuickPickerProps) {
const [searchValue, setSearchValue] = useState('');
const topFavorites = useTopFavorites(favoriteLimit);
const { addFavoriteAccount, removeFavoriteAccount, incrementFavoriteUsage, favoriteAccounts } = useSimpleBookingStore();
// Filter accounts based on search
const filteredAccounts = useMemo(() => {
if (!searchValue) return accounts;
const search = searchValue.toLowerCase();
return accounts.filter(
(account) =>
account.accountNumber.toLowerCase().includes(search) ||
account.name.toLowerCase().includes(search)
);
}, [accounts, searchValue]);
// Check if an account is a favorite
const isFavorite = (accountId: string) =>
favoriteAccounts.some((f) => f.accountId === accountId);
// Handle account selection
const handleSelect = (accountId: string) => {
const account = accounts.find((a) => a.id === accountId);
if (account) {
onChange(accountId, account);
// Increment favorite usage if it's a favorite
if (isFavorite(accountId)) {
incrementFavoriteUsage(accountId);
}
}
};
// Handle favorite toggle
const handleToggleFavorite = (account: AccountOption, event: React.MouseEvent) => {
event.stopPropagation();
if (isFavorite(account.id)) {
removeFavoriteAccount(account.id);
} else {
addFavoriteAccount({
accountId: account.id,
accountNumber: account.accountNumber,
accountName: account.name,
defaultVATCode: isExpense ? 'K25' : 'S25',
});
}
};
// Get quick buttons for favorites
const quickButtons = topFavorites.map((fav) => {
const account = accounts.find((a) => a.id === fav.accountId);
if (!account) return null;
return (
<Button
key={fav.id}
size="small"
type={value === fav.accountId ? 'primary' : 'default'}
onClick={() => handleSelect(fav.accountId)}
style={{ marginRight: 4, marginBottom: 4 }}
>
{account.accountNumber} {account.name.substring(0, 12)}
{account.name.length > 12 ? '...' : ''}
</Button>
);
}).filter(Boolean);
// Select options
const selectOptions = filteredAccounts.map((account) => ({
value: account.id,
label: (
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span>
<Text strong style={{ marginRight: 8 }}>{account.accountNumber}</Text>
{account.name}
</span>
<span onClick={(e) => handleToggleFavorite(account, e)} style={{ cursor: 'pointer' }}>
{isFavorite(account.id) ? (
<StarFilled style={{ color: '#faad14' }} />
) : (
<StarOutlined style={{ color: '#d9d9d9' }} />
)}
</span>
</div>
),
searchValue: `${account.accountNumber} ${account.name}`,
}));
return (
<div className="account-quick-picker">
{/* Quick favorite buttons */}
{quickButtons.length > 0 && (
<div style={{ marginBottom: 8 }}>
<Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 4 }}>
Hurtige valg:
</Text>
<div style={{ display: 'flex', flexWrap: 'wrap' }}>
{quickButtons}
</div>
</div>
)}
{/* Full search select */}
<Select
showSearch
style={{ width: '100%' }}
placeholder={placeholder}
value={value}
onChange={handleSelect}
onSearch={setSearchValue}
filterOption={false}
options={selectOptions}
optionFilterProp="searchValue"
suffixIcon={<SearchOutlined />}
notFoundContent={
<div style={{ padding: '8px', textAlign: 'center' }}>
<Text type="secondary">Ingen konti fundet</Text>
</div>
}
/>
</div>
);
}
interface VATCodePickerProps {
value?: VATCode;
onChange: (code: VATCode) => void;
isExpense?: boolean;
showDescription?: boolean;
}
export function VATCodePicker({
value,
onChange,
isExpense = true,
showDescription = false,
}: VATCodePickerProps) {
// Get relevant VAT codes based on transaction type
const relevantCodes = useMemo(() => {
if (isExpense) {
// Input VAT codes for expenses
return ['K25', 'EU_VARE', 'EU_YDELSE', 'NONE'] as VATCode[];
} else {
// Output VAT codes for income
return ['S25', 'MOMSFRI', 'EKSPORT', 'NONE'] as VATCode[];
}
}, [isExpense]);
const options = relevantCodes.map((code) => {
const config = VAT_CODE_CONFIG[code];
return {
value: code,
label: (
<div>
<Text strong style={{ marginRight: 8 }}>{code}</Text>
{config.nameDanish}
{config.rate > 0 && (
<Tag size="small" style={{ marginLeft: 8 }}>
{(config.rate * 100).toFixed(0)}%
</Tag>
)}
</div>
),
};
});
const selectedConfig = value ? VAT_CODE_CONFIG[value] : null;
return (
<div className="vat-code-picker">
<Select
style={{ width: '100%' }}
placeholder="Vaelg momskode"
value={value}
onChange={onChange}
options={options}
/>
{showDescription && selectedConfig && (
<Text type="secondary" style={{ fontSize: 12, display: 'block', marginTop: 4 }}>
{selectedConfig.description}
</Text>
)}
</div>
);
}
export default AccountQuickPicker;

View file

@ -0,0 +1,165 @@
// BankTransactionCard - Card component for displaying a bank transaction
import { Card, Button, Space, Tag, Typography, Tooltip } from 'antd';
import { CheckOutlined, SplitCellsOutlined, BankOutlined } from '@ant-design/icons';
import { formatCurrency, formatDateShort } from '@/lib/formatters';
import { accountingColors } from '@/styles/theme';
import type { PendingBankTransaction } from '@/stores/simpleBookingStore';
const { Text } = Typography;
interface BankTransactionCardProps {
transaction: PendingBankTransaction;
onBook: (transaction: PendingBankTransaction) => void;
onSplit: (transaction: PendingBankTransaction) => void;
isSelected?: boolean;
disabled?: boolean;
}
export function BankTransactionCard({
transaction,
onBook,
onSplit,
isSelected = false,
disabled = false,
}: BankTransactionCardProps) {
const isExpense = transaction.amount < 0;
const amountColor = isExpense ? accountingColors.debit : accountingColors.credit;
return (
<Card
size="small"
className={`bank-transaction-card ${isSelected ? 'selected' : ''} ${transaction.isBooked ? 'booked' : ''}`}
style={{
marginBottom: 8,
borderLeft: `3px solid ${isExpense ? accountingColors.debit : accountingColors.credit}`,
opacity: transaction.isBooked ? 0.6 : 1,
background: isSelected ? '#f0f5ff' : transaction.isBooked ? '#fafafa' : '#fff',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
{/* Left: Amount and description */}
<div style={{ flex: 1 }}>
<div style={{ marginBottom: 4 }}>
<Text
strong
style={{
fontSize: 16,
color: amountColor,
}}
>
{formatCurrency(transaction.amount)}
</Text>
</div>
<Text
style={{
display: 'block',
fontSize: 13,
color: '#262626',
lineHeight: 1.4,
}}
>
{transaction.description}
</Text>
<div style={{ marginTop: 6 }}>
<Space size={4}>
<Text type="secondary" style={{ fontSize: 12 }}>
{formatDateShort(transaction.date)}
</Text>
{transaction.counterparty && (
<>
<Text type="secondary" style={{ fontSize: 12 }}></Text>
<Text type="secondary" style={{ fontSize: 12 }}>
{transaction.counterparty}
</Text>
</>
)}
</Space>
</div>
</div>
{/* Right: Actions */}
<div style={{ marginLeft: 16 }}>
{transaction.isBooked ? (
<Tag color="success" icon={<CheckOutlined />}>
Bogfoert
</Tag>
) : (
<Space>
<Tooltip title="Bogfoer">
<Button
type="primary"
size="small"
onClick={() => onBook(transaction)}
disabled={disabled}
>
Bogfoer
</Button>
</Tooltip>
<Tooltip title="Opdel paa flere konti">
<Button
size="small"
icon={<SplitCellsOutlined />}
onClick={() => onSplit(transaction)}
disabled={disabled}
>
Opdel...
</Button>
</Tooltip>
</Space>
)}
</div>
</div>
</Card>
);
}
interface BankTransactionListProps {
transactions: PendingBankTransaction[];
onBook: (transaction: PendingBankTransaction) => void;
onSplit: (transaction: PendingBankTransaction) => void;
showBooked?: boolean;
selectedId?: string;
disabled?: boolean;
}
export function BankTransactionList({
transactions,
onBook,
onSplit,
showBooked = false,
selectedId,
disabled = false,
}: BankTransactionListProps) {
const filteredTransactions = showBooked
? transactions
: transactions.filter((tx) => !tx.isBooked);
if (filteredTransactions.length === 0) {
return (
<Card style={{ textAlign: 'center', padding: '24px 0' }}>
<BankOutlined style={{ fontSize: 32, color: '#bfbfbf', marginBottom: 8 }} />
<div>
<Text type="secondary">Ingen uboerte banktransaktioner</Text>
</div>
</Card>
);
}
return (
<div className="bank-transaction-list">
{filteredTransactions.map((transaction) => (
<BankTransactionCard
key={transaction.id}
transaction={transaction}
onBook={onBook}
onSplit={onSplit}
isSelected={transaction.id === selectedId}
disabled={disabled}
/>
))}
</div>
);
}
export default BankTransactionCard;

View file

@ -0,0 +1,298 @@
// QuickBookModal - Modal for simple one-account booking
import { useState, useEffect, useMemo, useRef } from 'react';
import {
Modal,
Form,
Input,
Card,
Table,
Typography,
Alert,
Divider,
Tag,
} from 'antd';
import { CheckCircleOutlined, WarningOutlined } from '@ant-design/icons';
import { formatCurrency, formatDateShort } from '@/lib/formatters';
import { accountingColors } from '@/styles/theme';
import { generateSimpleDoubleEntry, type SimpleBookingInput } from '@/lib/accounting';
import type { VATCode } from '@/types/vat';
import type { Account } from '@/types/accounting';
import { AccountQuickPicker, VATCodePicker } from './AccountQuickPicker';
import {
useBookingPreview,
useIsBookingSaving,
useSimpleBookingStore,
} from '@/stores/simpleBookingStore';
const { Text } = Typography;
interface QuickBookModalProps {
accounts: Account[];
onSubmit: (transaction: ReturnType<typeof generateSimpleDoubleEntry>) => Promise<void>;
}
export function QuickBookModal({ accounts, onSubmit }: QuickBookModalProps) {
const { modal, closeModal, setPreview } = useSimpleBookingStore();
const preview = useBookingPreview();
const isSaving = useIsBookingSaving();
const [selectedAccountId, setSelectedAccountId] = useState<string | undefined>();
const [selectedVATCode, setSelectedVATCode] = useState<VATCode>('K25');
const [description, setDescription] = useState('');
const bankTransaction = modal.bankTransaction;
const isOpen = modal.isOpen && modal.type === 'simple';
const isExpense = bankTransaction ? bankTransaction.amount < 0 : true;
// Track previous open state to detect modal opening
const wasOpenRef = useRef(false);
const lastTransactionIdRef = useRef<string | null>(null);
// Reset form only when modal opens or transaction changes
useEffect(() => {
const isNewlyOpened = isOpen && !wasOpenRef.current;
const isNewTransaction = bankTransaction && bankTransaction.id !== lastTransactionIdRef.current;
if (isOpen && bankTransaction && (isNewlyOpened || isNewTransaction)) {
// Calculate isExpense inside effect to avoid dependency
const expense = bankTransaction.amount < 0;
setSelectedAccountId(undefined);
setSelectedVATCode(expense ? 'K25' : 'S25');
setDescription(bankTransaction.description);
setPreview(null);
lastTransactionIdRef.current = bankTransaction.id;
}
wasOpenRef.current = isOpen;
}, [isOpen, bankTransaction, setPreview]);
// Generate preview when inputs change
useEffect(() => {
if (!bankTransaction || !selectedAccountId) {
setPreview(null);
return;
}
const account = accounts.find((a) => a.id === selectedAccountId);
if (!account) {
setPreview(null);
return;
}
const input: SimpleBookingInput = {
bankTransaction: {
id: bankTransaction.id,
date: bankTransaction.date,
amount: bankTransaction.amount,
description: description || bankTransaction.description,
counterparty: bankTransaction.counterparty,
bankAccountId: bankTransaction.bankAccountId,
bankAccountNumber: bankTransaction.bankAccountNumber,
},
contraAccountId: account.id,
contraAccountNumber: account.accountNumber,
contraAccountName: account.name,
vatCode: selectedVATCode,
description: description || undefined,
};
const result = generateSimpleDoubleEntry(input);
setPreview(result);
}, [bankTransaction, selectedAccountId, selectedVATCode, description, accounts, setPreview]);
// Account options for picker
const accountOptions = useMemo(
() =>
accounts.map((a) => ({
id: a.id,
accountNumber: a.accountNumber,
name: a.name,
type: a.type,
})),
[accounts]
);
const handleAccountChange = (accountId: string) => {
setSelectedAccountId(accountId);
};
const handleSubmit = async () => {
if (!preview || !preview.isValid) return;
await onSubmit(preview);
closeModal();
};
const previewColumns = [
{
title: 'Konto',
key: 'account',
render: (_: unknown, record: { accountNumber: string; accountName: string }) => (
<span>
<Text strong>{record.accountNumber}</Text>
<Text style={{ marginLeft: 8 }}>{record.accountName}</Text>
</span>
),
},
{
title: 'Debet',
dataIndex: 'debit',
key: 'debit',
align: 'right' as const,
render: (value: number) =>
value > 0 ? (
<Text style={{ color: accountingColors.debit }}>{formatCurrency(value)}</Text>
) : null,
},
{
title: 'Kredit',
dataIndex: 'credit',
key: 'credit',
align: 'right' as const,
render: (value: number) =>
value > 0 ? (
<Text style={{ color: accountingColors.credit }}>{formatCurrency(value)}</Text>
) : null,
},
];
if (!bankTransaction) return null;
return (
<Modal
title="Bogfoer transaktion"
open={isOpen}
onCancel={closeModal}
onOk={handleSubmit}
okText="Bogfoer"
cancelText="Annuller"
okButtonProps={{
disabled: !preview?.isValid || isSaving,
loading: isSaving,
}}
width={600}
destroyOnClose
>
{/* Bank transaction summary */}
<Card
size="small"
style={{
marginBottom: 16,
borderLeft: `3px solid ${isExpense ? accountingColors.debit : accountingColors.credit}`,
}}
>
<div>
<Text
strong
style={{
fontSize: 18,
color: isExpense ? accountingColors.debit : accountingColors.credit,
}}
>
{formatCurrency(bankTransaction.amount)}
</Text>
</div>
<Text>{bankTransaction.description}</Text>
<div style={{ marginTop: 4 }}>
<Text type="secondary">{formatDateShort(bankTransaction.date)}</Text>
{bankTransaction.counterparty && (
<>
<Text type="secondary"> </Text>
<Text type="secondary">{bankTransaction.counterparty}</Text>
</>
)}
</div>
</Card>
{/* Account selection */}
<Form layout="vertical">
<Form.Item label="Vaelg konto" required>
<AccountQuickPicker
accounts={accountOptions}
value={selectedAccountId}
onChange={handleAccountChange}
isExpense={isExpense}
placeholder="Soeg efter konto..."
/>
</Form.Item>
<Form.Item label="Momskode" required>
<VATCodePicker
value={selectedVATCode}
onChange={setSelectedVATCode}
isExpense={isExpense}
showDescription
/>
</Form.Item>
<Form.Item label="Beskrivelse (valgfri)">
<Input
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder={bankTransaction.description}
/>
</Form.Item>
</Form>
{/* Preview */}
{preview && (
<>
<Divider style={{ margin: '16px 0' }} />
<div>
<div style={{ display: 'flex', alignItems: 'center', marginBottom: 8 }}>
<Text strong style={{ marginRight: 8 }}>Forhaandsvisning:</Text>
{preview.isValid ? (
<Tag color="success" icon={<CheckCircleOutlined />}>
Balancerer
</Tag>
) : (
<Tag color="error" icon={<WarningOutlined />}>
Fejl
</Tag>
)}
</div>
{!preview.isValid && preview.validationMessage && (
<Alert
type="error"
message={preview.validationMessage}
style={{ marginBottom: 8 }}
/>
)}
<Table
dataSource={preview.lines.map((line, idx) => ({ ...line, key: idx }))}
columns={previewColumns}
pagination={false}
size="small"
bordered
summary={() => {
const totalDebit = preview.lines.reduce((sum, l) => sum + l.debit, 0);
const totalCredit = preview.lines.reduce((sum, l) => sum + l.credit, 0);
return (
<Table.Summary.Row>
<Table.Summary.Cell index={0}>
<Text strong>Total</Text>
</Table.Summary.Cell>
<Table.Summary.Cell index={1} align="right">
<Text strong style={{ color: accountingColors.debit }}>
{formatCurrency(totalDebit)}
</Text>
</Table.Summary.Cell>
<Table.Summary.Cell index={2} align="right">
<Text strong style={{ color: accountingColors.credit }}>
{formatCurrency(totalCredit)}
</Text>
</Table.Summary.Cell>
</Table.Summary.Row>
);
}}
/>
</div>
</>
)}
</Modal>
);
}
export default QuickBookModal;

View file

@ -0,0 +1,472 @@
// SplitBookModal - Modal for splitting one bank transaction to multiple accounts
import { useState, useEffect, useMemo, useRef } from 'react';
import {
Modal,
Form,
Input,
InputNumber,
Card,
Table,
Space,
Typography,
Alert,
Divider,
Tag,
Button,
Row,
Col,
} from 'antd';
import {
CheckCircleOutlined,
WarningOutlined,
PlusOutlined,
DeleteOutlined,
} from '@ant-design/icons';
import { formatCurrency, formatDateShort } from '@/lib/formatters';
import { accountingColors } from '@/styles/theme';
import { generateSplitDoubleEntry, type SplitBookingInput, type SplitBookingLine } from '@/lib/accounting';
import type { VATCode } from '@/types/vat';
import type { Account } from '@/types/accounting';
import { AccountQuickPicker, VATCodePicker } from './AccountQuickPicker';
import {
useBookingPreview,
useIsBookingSaving,
useSimpleBookingStore,
} from '@/stores/simpleBookingStore';
const { Text } = Typography;
interface SplitBookModalProps {
accounts: Account[];
onSubmit: (transaction: ReturnType<typeof generateSplitDoubleEntry>) => Promise<void>;
}
export function SplitBookModal({ accounts, onSubmit }: SplitBookModalProps) {
const {
modal,
closeModal,
setPreview,
splitState,
addSplitLine,
removeSplitLine,
clearSplitLines,
} = useSimpleBookingStore();
const preview = useBookingPreview();
const isSaving = useIsBookingSaving();
// New line form state
const [newLineAccountId, setNewLineAccountId] = useState<string | undefined>();
const [newLineAmount, setNewLineAmount] = useState<number | null>(null);
const [newLineVATCode, setNewLineVATCode] = useState<VATCode>('K25');
const [newLineDescription, setNewLineDescription] = useState('');
const bankTransaction = modal.bankTransaction;
const isOpen = modal.isOpen && modal.type === 'split';
const isExpense = bankTransaction ? bankTransaction.amount < 0 : true;
// Track previous open state to detect modal opening
const wasOpenRef = useRef(false);
const lastTransactionIdRef = useRef<string | null>(null);
// Reset form only when modal opens or transaction changes
useEffect(() => {
const isNewlyOpened = isOpen && !wasOpenRef.current;
const isNewTransaction = bankTransaction && bankTransaction.id !== lastTransactionIdRef.current;
if (isOpen && bankTransaction && (isNewlyOpened || isNewTransaction)) {
// Calculate isExpense inside effect to avoid dependency
const expense = bankTransaction.amount < 0;
clearSplitLines();
setNewLineAccountId(undefined);
setNewLineAmount(null);
setNewLineVATCode(expense ? 'K25' : 'S25');
setNewLineDescription('');
setPreview(null);
lastTransactionIdRef.current = bankTransaction.id;
}
wasOpenRef.current = isOpen;
}, [isOpen, bankTransaction, clearSplitLines, setPreview]);
// Generate preview when lines change
useEffect(() => {
if (!bankTransaction || splitState.lines.length === 0) {
setPreview(null);
return;
}
const input: SplitBookingInput = {
bankTransaction: {
id: bankTransaction.id,
date: bankTransaction.date,
amount: bankTransaction.amount,
description: bankTransaction.description,
counterparty: bankTransaction.counterparty,
bankAccountId: bankTransaction.bankAccountId,
bankAccountNumber: bankTransaction.bankAccountNumber,
},
lines: splitState.lines,
};
const result = generateSplitDoubleEntry(input);
setPreview(result);
}, [bankTransaction, splitState.lines, setPreview]);
// Account options for picker
const accountOptions = useMemo(
() =>
accounts.map((a) => ({
id: a.id,
accountNumber: a.accountNumber,
name: a.name,
type: a.type,
})),
[accounts]
);
// Add a new split line
const handleAddLine = () => {
if (!newLineAccountId || !newLineAmount) return;
const account = accounts.find((a) => a.id === newLineAccountId);
if (!account) return;
const newLine: SplitBookingLine = {
accountId: account.id,
accountNumber: account.accountNumber,
accountName: account.name,
amount: newLineAmount,
vatCode: newLineVATCode,
description: newLineDescription || undefined,
};
addSplitLine(newLine);
// Reset form for next line
setNewLineAccountId(undefined);
setNewLineAmount(null);
setNewLineDescription('');
};
// Fill remaining amount
const handleFillRemaining = () => {
setNewLineAmount(splitState.remainingAmount);
};
const handleSubmit = async () => {
if (!preview || !preview.isValid) return;
await onSubmit(preview);
closeModal();
};
// Preview table columns
const previewColumns = [
{
title: 'Konto',
key: 'account',
render: (_: unknown, record: { accountNumber: string; accountName: string }) => (
<span>
<Text strong>{record.accountNumber}</Text>
<Text style={{ marginLeft: 8 }}>{record.accountName}</Text>
</span>
),
},
{
title: 'Debet',
dataIndex: 'debit',
key: 'debit',
align: 'right' as const,
render: (value: number) =>
value > 0 ? (
<Text style={{ color: accountingColors.debit }}>{formatCurrency(value)}</Text>
) : null,
},
{
title: 'Kredit',
dataIndex: 'credit',
key: 'credit',
align: 'right' as const,
render: (value: number) =>
value > 0 ? (
<Text style={{ color: accountingColors.credit }}>{formatCurrency(value)}</Text>
) : null,
},
];
// Split lines table columns
const splitLinesColumns = [
{
title: '#',
key: 'index',
width: 40,
render: (_: unknown, __: unknown, index: number) => index + 1,
},
{
title: 'Konto',
key: 'account',
render: (_: unknown, record: SplitBookingLine) => (
<span>
<Text strong>{record.accountNumber}</Text>
<Text style={{ marginLeft: 8 }}>{record.accountName}</Text>
</span>
),
},
{
title: 'Beloeb',
dataIndex: 'amount',
key: 'amount',
align: 'right' as const,
render: (value: number) => formatCurrency(value),
},
{
title: 'Moms',
dataIndex: 'vatCode',
key: 'vatCode',
render: (code: VATCode) => <Tag>{code}</Tag>,
},
{
title: '',
key: 'actions',
width: 50,
render: (_: unknown, __: unknown, index: number) => (
<Button
type="text"
danger
size="small"
icon={<DeleteOutlined />}
onClick={() => removeSplitLine(index)}
/>
),
},
];
if (!bankTransaction) return null;
const canAddLine =
newLineAccountId &&
newLineAmount &&
newLineAmount > 0 &&
newLineAmount <= splitState.remainingAmount + 0.01;
const canSubmit =
preview?.isValid && splitState.lines.length > 0 && splitState.remainingAmount < 0.01;
return (
<Modal
title="Opdel transaktion"
open={isOpen}
onCancel={closeModal}
onOk={handleSubmit}
okText="Bogfoer opdeling"
cancelText="Annuller"
okButtonProps={{
disabled: !canSubmit || isSaving,
loading: isSaving,
}}
width={800}
destroyOnClose
>
{/* Bank transaction summary */}
<Card
size="small"
style={{
marginBottom: 16,
borderLeft: `3px solid ${isExpense ? accountingColors.debit : accountingColors.credit}`,
}}
>
<Row justify="space-between" align="middle">
<Col>
<Text>Banktransaktion:</Text>
<Text
strong
style={{
fontSize: 18,
marginLeft: 8,
color: isExpense ? accountingColors.debit : accountingColors.credit,
}}
>
{formatCurrency(bankTransaction.amount)}
</Text>
<Text style={{ marginLeft: 16 }}>{bankTransaction.description}</Text>
</Col>
<Col>
<Text type="secondary">{formatDateShort(bankTransaction.date)}</Text>
</Col>
</Row>
</Card>
{/* Remaining amount indicator */}
<Alert
type={splitState.remainingAmount < 0.01 ? 'success' : 'warning'}
message={
<span>
Restbeloeb:{' '}
<Text strong style={{ color: splitState.remainingAmount < 0.01 ? accountingColors.credit : accountingColors.warning }}>
{formatCurrency(splitState.remainingAmount)}
</Text>
{splitState.remainingAmount < 0.01 && ' - Fuld fordeling'}
</span>
}
style={{ marginBottom: 16 }}
/>
{/* Existing split lines */}
{splitState.lines.length > 0 && (
<div style={{ marginBottom: 16 }}>
<Text strong style={{ display: 'block', marginBottom: 8 }}>
Fordeling:
</Text>
<Table
dataSource={splitState.lines.map((line, idx) => ({ ...line, key: idx }))}
columns={splitLinesColumns}
pagination={false}
size="small"
/>
</div>
)}
{/* Add new line form */}
{splitState.remainingAmount > 0.01 && (
<Card size="small" title="Tilfoej linje" style={{ marginBottom: 16 }}>
<Row gutter={16}>
<Col span={10}>
<Form.Item label="Konto" style={{ marginBottom: 8 }}>
<AccountQuickPicker
accounts={accountOptions}
value={newLineAccountId}
onChange={setNewLineAccountId}
isExpense={isExpense}
placeholder="Vaelg konto..."
/>
</Form.Item>
</Col>
<Col span={6}>
<Form.Item label="Beloeb" style={{ marginBottom: 8 }}>
<Space.Compact style={{ width: '100%' }}>
<InputNumber
style={{ width: '100%' }}
value={newLineAmount}
onChange={(value) => setNewLineAmount(value)}
min={0.01}
max={splitState.remainingAmount}
precision={2}
placeholder="0,00"
addonAfter="kr"
/>
<Button onClick={handleFillRemaining} title="Udfyld resten">
Rest
</Button>
</Space.Compact>
</Form.Item>
</Col>
<Col span={5}>
<Form.Item label="Moms" style={{ marginBottom: 8 }}>
<VATCodePicker
value={newLineVATCode}
onChange={setNewLineVATCode}
isExpense={isExpense}
/>
</Form.Item>
</Col>
<Col span={3}>
<Form.Item label=" " style={{ marginBottom: 8 }}>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={handleAddLine}
disabled={!canAddLine}
>
Tilfoej
</Button>
</Form.Item>
</Col>
</Row>
<Row>
<Col span={24}>
<Form.Item label="Beskrivelse (valgfri)" style={{ marginBottom: 0 }}>
<Input
value={newLineDescription}
onChange={(e) => setNewLineDescription(e.target.value)}
placeholder={bankTransaction.description}
/>
</Form.Item>
</Col>
</Row>
</Card>
)}
{/* Preview */}
{preview && preview.lines.length > 0 && (
<>
<Divider style={{ margin: '16px 0' }} />
<div>
<div style={{ display: 'flex', alignItems: 'center', marginBottom: 8 }}>
<Text strong style={{ marginRight: 8 }}>
Forhaandsvisning af bilag:
</Text>
{preview.isValid ? (
<Tag color="success" icon={<CheckCircleOutlined />}>
Balancerer
</Tag>
) : (
<Tag color="error" icon={<WarningOutlined />}>
Fejl
</Tag>
)}
</div>
{!preview.isValid && preview.validationMessage && (
<Alert
type="error"
message={preview.validationMessage}
style={{ marginBottom: 8 }}
/>
)}
<Table
dataSource={preview.lines.map((line, idx) => ({ ...line, key: idx }))}
columns={previewColumns}
pagination={false}
size="small"
bordered
summary={() => {
const totalDebit = preview.lines.reduce((sum, l) => sum + l.debit, 0);
const totalCredit = preview.lines.reduce((sum, l) => sum + l.credit, 0);
return (
<Table.Summary.Row>
<Table.Summary.Cell index={0}>
<Text strong>Total</Text>
</Table.Summary.Cell>
<Table.Summary.Cell index={1} align="right">
<Text strong style={{ color: accountingColors.debit }}>
{formatCurrency(totalDebit)}
</Text>
</Table.Summary.Cell>
<Table.Summary.Cell index={2} align="right">
<Text strong style={{ color: accountingColors.credit }}>
{formatCurrency(totalCredit)}
</Text>
</Table.Summary.Cell>
</Table.Summary.Row>
);
}}
/>
<div style={{ marginTop: 8, textAlign: 'right' }}>
<Text type="secondary">
Debet = Kredit{' '}
{preview.isValid ? (
<CheckCircleOutlined style={{ color: '#52c41a' }} />
) : (
<WarningOutlined style={{ color: '#ff4d4f' }} />
)}
</Text>
</div>
</div>
</>
)}
</Modal>
);
}
export default SplitBookModal;

View file

@ -0,0 +1,6 @@
// Simple Booking Components - Export barrel
export { BankTransactionCard, BankTransactionList } from './BankTransactionCard';
export { AccountQuickPicker, VATCodePicker } from './AccountQuickPicker';
export { QuickBookModal } from './QuickBookModal';
export { SplitBookModal } from './SplitBookModal';

View file

@ -0,0 +1,302 @@
import { Table, Button, Space, Tooltip, Empty, Typography } from 'antd';
import { DownloadOutlined, ReloadOutlined } from '@ant-design/icons';
import type { TableColumnType, TableProps } from 'antd';
import { useDataTable } from '@/hooks/useDataTable';
import { formatCurrency, formatDate, formatNumber } from '@/lib/formatters';
import { getAmountClass } from '@/lib/formatters';
import * as XLSX from 'xlsx';
import { useCallback, useMemo } from 'react';
const { Text } = Typography;
export interface DataTableColumn<T> extends Omit<TableColumnType<T>, 'render' | 'dataIndex'> {
dataIndex: string | string[];
title: string;
sortable?: boolean;
filterable?: boolean;
render?: (value: unknown, record: T, index: number) => React.ReactNode;
columnType?: 'text' | 'number' | 'currency' | 'date' | 'boolean' | 'actions';
decimalPlaces?: number;
showSign?: boolean;
exportable?: boolean;
}
interface DataTableProps<T extends { id: string }> {
// Data source - either from hook or direct data
queryKey?: string[];
query?: string;
variables?: Record<string, unknown>;
dataPath?: string;
totalPath?: string;
// Or direct data
data?: T[];
loading?: boolean;
// Columns
columns: DataTableColumn<T>[];
// Pagination
pageSize?: number;
pageSizeOptions?: number[];
// Features
rowSelection?: 'single' | 'multiple' | false;
selectedRowKeys?: string[];
onSelectionChange?: (selectedRowKeys: string[], selectedRows: T[]) => void;
onRowClick?: (record: T) => void;
exportable?: boolean;
exportFilename?: string;
refreshable?: boolean;
// Row styling
rowClassName?: (record: T, index: number) => string;
// Actions
toolbarActions?: React.ReactNode;
toolbarTitle?: string;
// Empty state
emptyText?: string;
// Scroll
scroll?: TableProps<T>['scroll'];
}
export default function DataTable<T extends { id: string }>({
queryKey,
query,
variables,
dataPath,
totalPath,
data: directData,
loading: directLoading,
columns,
pageSize = 20,
rowSelection = false,
selectedRowKeys: externalSelectedKeys,
onSelectionChange,
onRowClick,
exportable = true,
exportFilename = 'export',
refreshable = true,
rowClassName,
toolbarActions,
toolbarTitle,
emptyText = 'Ingen data fundet',
scroll,
}: DataTableProps<T>) {
// Use hook if query is provided, otherwise use direct data
const hookEnabled = !!(queryKey && query && dataPath && totalPath);
const {
data: hookData,
total,
isLoading: hookLoading,
pagination,
handleTableChange,
refetch,
} = useDataTable<T>({
queryKey: queryKey || [],
query: query || '',
variables,
dataPath: dataPath || '',
totalPath: totalPath || '',
pageSize,
enabled: hookEnabled,
});
const data = hookEnabled ? hookData : (directData || []);
const isLoading = hookEnabled ? hookLoading : (directLoading || false);
// Process columns with default renders based on columnType
const processedColumns = useMemo(() => {
return columns.map((col) => {
const processedCol: TableColumnType<T> = {
...col,
sorter: col.sortable,
};
// Add default render based on columnType if no custom render
if (!col.render && col.columnType) {
switch (col.columnType) {
case 'currency':
processedCol.render = (value: unknown) => {
const numValue = typeof value === 'number' ? value : 0;
return (
<span className={`currency tabular-nums ${getAmountClass(numValue)}`}>
{formatCurrency(numValue, {
showSign: col.showSign,
decimalPlaces: col.decimalPlaces,
})}
</span>
);
};
processedCol.align = 'right';
break;
case 'number':
processedCol.render = (value: unknown) => {
const numValue = typeof value === 'number' ? value : 0;
return (
<span className="tabular-nums">
{formatNumber(numValue, col.decimalPlaces)}
</span>
);
};
processedCol.align = 'right';
break;
case 'date':
processedCol.render = (value: unknown) => {
if (!value) return '-';
return formatDate(value as string);
};
break;
case 'boolean':
processedCol.render = (value: unknown) => {
return value ? 'Ja' : 'Nej';
};
break;
}
} else if (col.render) {
processedCol.render = col.render;
}
return processedCol;
});
}, [columns]);
// Row selection config
const rowSelectionConfig = useMemo(() => {
if (!rowSelection) return undefined;
return {
type: rowSelection === 'single' ? ('radio' as const) : ('checkbox' as const),
selectedRowKeys: externalSelectedKeys,
onChange: (keys: React.Key[], rows: T[]) => {
onSelectionChange?.(keys as string[], rows);
},
};
}, [rowSelection, externalSelectedKeys, onSelectionChange]);
// Export to Excel
const handleExport = useCallback(() => {
const exportData = data.map((record) => {
const row: Record<string, unknown> = {};
columns.forEach((col) => {
if (col.exportable !== false) {
const value = Array.isArray(col.dataIndex)
? col.dataIndex.reduce(
(obj: unknown, k) =>
obj && typeof obj === 'object' ? (obj as Record<string, unknown>)[k] : undefined,
record
)
: record[col.dataIndex as keyof T];
// Format value for export
if (col.columnType === 'currency' && typeof value === 'number') {
row[col.title] = value;
} else if (col.columnType === 'date' && value) {
row[col.title] = formatDate(value as string);
} else {
row[col.title] = value;
}
}
});
return row;
});
const worksheet = XLSX.utils.json_to_sheet(exportData);
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, worksheet, 'Data');
const filename = `${exportFilename}_${new Date().toISOString().split('T')[0]}.xlsx`;
XLSX.writeFile(workbook, filename);
}, [data, columns, exportFilename]);
// Row click handler
const onRow = useCallback(
(record: T) => ({
onClick: () => onRowClick?.(record),
style: onRowClick ? { cursor: 'pointer' } : undefined,
}),
[onRowClick]
);
return (
<div>
{/* Toolbar */}
{(toolbarTitle || toolbarActions || exportable || refreshable) && (
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 16,
}}
>
<div>
{toolbarTitle && (
<Text strong style={{ fontSize: 16 }}>
{toolbarTitle}
</Text>
)}
</div>
<Space>
{toolbarActions}
{refreshable && hookEnabled && (
<Tooltip title="Genindlaes">
<Button
icon={<ReloadOutlined />}
onClick={() => refetch()}
loading={isLoading}
/>
</Tooltip>
)}
{exportable && data.length > 0 && (
<Tooltip title="Eksporter til Excel">
<Button
icon={<DownloadOutlined />}
onClick={handleExport}
>
Eksporter
</Button>
</Tooltip>
)}
</Space>
</div>
)}
{/* Table */}
<Table<T>
columns={processedColumns}
dataSource={data}
rowKey="id"
loading={isLoading}
pagination={hookEnabled ? pagination : { pageSize, showSizeChanger: true }}
onChange={hookEnabled ? handleTableChange : undefined}
rowSelection={rowSelectionConfig}
onRow={onRow}
rowClassName={rowClassName}
scroll={scroll || { x: 'max-content' }}
size="small"
bordered
locale={{
emptyText: <Empty description={emptyText} />,
}}
style={{ width: '100%' }}
/>
{/* Footer with total count */}
{hookEnabled && total > 0 && (
<div style={{ marginTop: 8, textAlign: 'right' }}>
<Text type="secondary">
Total: {total.toLocaleString('da-DK')} poster
</Text>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,70 @@
import { useCompanyStore } from '@/stores/companyStore';
import { useCallback } from 'react';
import type { Company } from '@/types/accounting';
/**
* Hook for accessing and managing the current company context
*/
export function useCompany() {
const activeCompany = useCompanyStore((state) => state.activeCompany);
const companies = useCompanyStore((state) => state.companies);
const isLoading = useCompanyStore((state) => state.isLoading);
const setActiveCompany = useCompanyStore((state) => state.setActiveCompany);
/**
* Get the current company ID or throw if not set
*/
const requireCompanyId = useCallback((): string => {
if (!activeCompany) {
throw new Error('No company selected. Please select a company first.');
}
return activeCompany.id;
}, [activeCompany]);
/**
* Check if a company is selected
*/
const hasCompany = !!activeCompany;
/**
* Switch to a different company
*/
const switchCompany = useCallback(
(companyId: string) => {
const company = companies.find((c) => c.id === companyId);
if (company) {
setActiveCompany(company);
}
},
[companies, setActiveCompany]
);
/**
* Get company by ID
*/
const getCompanyById = useCallback(
(id: string): Company | undefined => {
return companies.find((c) => c.id === id);
},
[companies]
);
return {
// Current company
company: activeCompany,
companyId: activeCompany?.id,
companyName: activeCompany?.name,
// All companies
companies,
// State
isLoading,
hasCompany,
// Actions
requireCompanyId,
switchCompany,
getCompanyById,
};
}

View file

@ -0,0 +1,176 @@
import { useState, useCallback, useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { graphqlClient } from '@/api/client';
import type { SorterResult, TablePaginationConfig } from 'antd/es/table/interface';
import type { FilterValue } from 'antd/es/table/interface';
interface UseDataTableOptions {
queryKey: string[];
query: string;
variables?: Record<string, unknown>;
dataPath: string;
totalPath: string;
pageSize?: number;
enabled?: boolean;
}
interface UseDataTableReturn<T> {
data: T[];
total: number;
isLoading: boolean;
isError: boolean;
error: Error | null;
pagination: TablePaginationConfig;
handleTableChange: (
pagination: TablePaginationConfig,
filters: Record<string, FilterValue | null>,
sorter: SorterResult<T> | SorterResult<T>[]
) => void;
refetch: () => void;
currentPage: number;
currentPageSize: number;
sortField: string | undefined;
sortOrder: 'asc' | 'desc' | undefined;
filters: Record<string, unknown>;
setFilters: (filters: Record<string, unknown>) => void;
}
function getNestedValue(obj: unknown, path: string): unknown {
return path.split('.').reduce((acc: unknown, part: string) => {
if (acc && typeof acc === 'object' && part in acc) {
return (acc as Record<string, unknown>)[part];
}
return undefined;
}, obj);
}
export function useDataTable<T extends { id: string }>({
queryKey,
query,
variables = {},
dataPath,
totalPath,
pageSize = 20,
enabled = true,
}: UseDataTableOptions): UseDataTableReturn<T> {
const [currentPage, setCurrentPage] = useState(1);
const [currentPageSize, setCurrentPageSize] = useState(pageSize);
const [sortField, setSortField] = useState<string | undefined>();
const [sortOrder, setSortOrder] = useState<'asc' | 'desc' | undefined>();
const [filters, setFilters] = useState<Record<string, unknown>>({});
// Build query variables with pagination, sorting, and filtering
const queryVariables = useMemo(() => {
const vars: Record<string, unknown> = {
...variables,
first: currentPageSize,
after: currentPage > 1 ? String((currentPage - 1) * currentPageSize) : undefined,
};
if (sortField && sortOrder) {
vars.sort = {
field: sortField,
direction: sortOrder.toUpperCase(),
};
}
if (Object.keys(filters).length > 0) {
vars.filter = filters;
}
return vars;
}, [variables, currentPage, currentPageSize, sortField, sortOrder, filters]);
// Execute GraphQL query
const { data: response, isLoading, isError, error, refetch } = useQuery({
queryKey: [...queryKey, queryVariables],
queryFn: async () => {
return graphqlClient.request(query, queryVariables);
},
enabled,
});
// Extract data and total from response
const data = useMemo(() => {
if (!response) return [];
const items = getNestedValue(response, dataPath);
return Array.isArray(items) ? items : [];
}, [response, dataPath]) as T[];
const total = useMemo(() => {
if (!response) return 0;
const count = getNestedValue(response, totalPath);
return typeof count === 'number' ? count : 0;
}, [response, totalPath]);
// Pagination config for Ant Design Table
const pagination: TablePaginationConfig = useMemo(
() => ({
current: currentPage,
pageSize: currentPageSize,
total,
showSizeChanger: true,
showQuickJumper: true,
pageSizeOptions: ['10', '20', '50', '100'],
showTotal: (total, range) =>
`${range[0]}-${range[1]} af ${total} poster`,
}),
[currentPage, currentPageSize, total]
);
// Handle table changes (pagination, sorting, filtering)
const handleTableChange = useCallback(
(
newPagination: TablePaginationConfig,
tableFilters: Record<string, FilterValue | null>,
sorter: SorterResult<T> | SorterResult<T>[]
) => {
// Handle pagination
if (newPagination.current !== currentPage) {
setCurrentPage(newPagination.current || 1);
}
if (newPagination.pageSize !== currentPageSize) {
setCurrentPageSize(newPagination.pageSize || pageSize);
setCurrentPage(1); // Reset to first page on page size change
}
// Handle sorting
const singleSorter = Array.isArray(sorter) ? sorter[0] : sorter;
if (singleSorter.field && singleSorter.order) {
setSortField(String(singleSorter.field));
setSortOrder(singleSorter.order === 'ascend' ? 'asc' : 'desc');
} else {
setSortField(undefined);
setSortOrder(undefined);
}
// Handle filters from table columns
const newFilters: Record<string, unknown> = {};
Object.entries(tableFilters).forEach(([key, value]) => {
if (value !== null && value.length > 0) {
newFilters[key] = value.length === 1 ? value[0] : value;
}
});
// Merge with existing external filters
setFilters((prev) => ({ ...prev, ...newFilters }));
},
[currentPage, currentPageSize, pageSize]
);
return {
data,
total,
isLoading,
isError,
error: error as Error | null,
pagination,
handleTableChange,
refetch,
currentPage,
currentPageSize,
sortField,
sortOrder,
filters,
setFilters,
};
}

View file

@ -0,0 +1,345 @@
// Period Hook - React hook for period context and validation
import { useMemo, useCallback } from 'react';
import { usePeriodStore } from '@/stores/periodStore';
import {
getPeriodForDate,
getPreviousPeriod,
getSamePeriodPreviousYear,
getYearToDateRange,
canPostToDate,
validatePeriodClose,
} from '@/lib/periods';
import type { AccountingPeriod, FiscalYear, PeriodStatus } from '@/types/periods';
import type { Transaction } from '@/types/accounting';
/**
* Hook for accessing period context in components
*/
export function usePeriodContext() {
const {
currentFiscalYear,
currentPeriod,
selectedPeriod,
selectedVATPeriod,
comparisonPeriod,
comparisonType,
periods,
fiscalYears,
vatPeriods,
isLoading,
} = usePeriodStore();
const effectivePeriod = selectedPeriod || currentPeriod;
return {
// Current context
currentFiscalYear,
currentPeriod,
selectedPeriod,
effectivePeriod,
selectedVATPeriod,
comparisonPeriod,
comparisonType,
// Lists
periods,
fiscalYears,
vatPeriods,
// Loading
isLoading,
};
}
/**
* Hook for period selection and navigation
*/
export function usePeriodSelector() {
const {
periods,
fiscalYears,
selectedPeriod,
setSelectedPeriod,
setComparisonPeriod,
clearComparison,
} = usePeriodStore();
const selectPeriod = useCallback(
(periodId: string) => {
const period = periods.find((p) => p.id === periodId);
if (period) {
setSelectedPeriod(period);
}
},
[periods, setSelectedPeriod]
);
const selectPreviousPeriod = useCallback(() => {
if (!selectedPeriod) return;
const previous = getPreviousPeriod(selectedPeriod, periods);
if (previous) {
setSelectedPeriod(previous);
}
}, [selectedPeriod, periods, setSelectedPeriod]);
const selectNextPeriod = useCallback(() => {
if (!selectedPeriod) return;
const currentIndex = periods.findIndex((p) => p.id === selectedPeriod.id);
if (currentIndex >= 0 && currentIndex < periods.length - 1) {
setSelectedPeriod(periods[currentIndex + 1]);
}
}, [selectedPeriod, periods, setSelectedPeriod]);
const enableComparison = useCallback(
(type: 'previous-period' | 'previous-year' | 'custom', customPeriod?: AccountingPeriod) => {
if (!selectedPeriod) return;
let comparisonPeriodData: AccountingPeriod | undefined;
if (type === 'previous-period') {
comparisonPeriodData = getPreviousPeriod(selectedPeriod, periods);
} else if (type === 'previous-year') {
comparisonPeriodData = getSamePeriodPreviousYear(selectedPeriod, periods);
} else if (type === 'custom' && customPeriod) {
comparisonPeriodData = customPeriod;
}
if (comparisonPeriodData) {
setComparisonPeriod(comparisonPeriodData, type);
}
},
[selectedPeriod, periods, setComparisonPeriod]
);
const disableComparison = useCallback(() => {
clearComparison();
}, [clearComparison]);
// Get periods grouped by fiscal year
const periodsByYear = useMemo(() => {
const grouped: Record<string, AccountingPeriod[]> = {};
for (const period of periods) {
if (!grouped[period.fiscalYearId]) {
grouped[period.fiscalYearId] = [];
}
grouped[period.fiscalYearId].push(period);
}
return grouped;
}, [periods]);
// Get open periods only
const openPeriods = useMemo(
() => periods.filter((p) => p.status === 'open'),
[periods]
);
return {
// State
selectedPeriod,
periods,
fiscalYears,
periodsByYear,
openPeriods,
// Actions
selectPeriod,
selectPreviousPeriod,
selectNextPeriod,
enableComparison,
disableComparison,
};
}
/**
* Hook for posting validation
*/
export function usePostingValidation() {
const { periods, periodSettings } = usePeriodStore();
const validatePostingDate = useCallback(
(date: string) => {
if (!periodSettings) {
// Default to strict validation if no settings
return canPostToDate(date, periods, {
preventPostingToClosedPeriods: true,
preventPostingToFuturePeriods: true,
});
}
return canPostToDate(date, periods, {
preventPostingToClosedPeriods: periodSettings.preventPostingToClosedPeriods,
preventPostingToFuturePeriods: periodSettings.preventPostingToFuturePeriods,
});
},
[periods, periodSettings]
);
const getPeriodStatus = useCallback(
(date: string): PeriodStatus | 'no-period' => {
const period = getPeriodForDate(date, periods);
return period?.status || 'no-period';
},
[periods]
);
const isDatePostable = useCallback(
(date: string): boolean => {
return validatePostingDate(date).allowed;
},
[validatePostingDate]
);
return {
validatePostingDate,
getPeriodStatus,
isDatePostable,
};
}
/**
* Hook for period management (closing, locking, etc.)
*/
export function usePeriodManagement() {
const {
periods,
closePeriod,
reopenPeriod,
lockPeriod,
updatePeriod,
} = usePeriodStore();
const canClosePeriod = useCallback(
(periodId: string, transactions: Transaction[]): { canClose: boolean; errors: string[]; warnings: string[] } => {
const period = periods.find((p) => p.id === periodId);
if (!period) {
return {
canClose: false,
errors: ['Periode ikke fundet'],
warnings: [],
};
}
const validation = validatePeriodClose(period, transactions, {
requireAllReconciled: true,
});
return {
canClose: validation.isValid,
errors: validation.errors.map((e) => e.messageDanish),
warnings: validation.warnings.map((w) => w.messageDanish),
};
},
[periods]
);
const closeAccountingPeriod = useCallback(
(periodId: string, userId: string) => {
closePeriod(periodId, userId);
},
[closePeriod]
);
const reopenAccountingPeriod = useCallback(
(periodId: string, userId: string) => {
reopenPeriod(periodId, userId);
},
[reopenPeriod]
);
const lockAccountingPeriod = useCallback(
(periodId: string, userId: string) => {
lockPeriod(periodId, userId);
},
[lockPeriod]
);
const getPeriodActions = useCallback(
(periodId: string) => {
const period = periods.find((p) => p.id === periodId);
if (!period) return { canClose: false, canReopen: false, canLock: false };
return {
canClose: period.status === 'open',
canReopen: period.status === 'closed',
canLock: period.status === 'closed',
};
},
[periods]
);
return {
canClosePeriod,
closeAccountingPeriod,
reopenAccountingPeriod,
lockAccountingPeriod,
getPeriodActions,
};
}
/**
* Hook for year-to-date calculations
*/
export function useYearToDate() {
const { currentFiscalYear, selectedPeriod, periods } = usePeriodStore();
const ytdRange = useMemo(() => {
if (!currentFiscalYear || !selectedPeriod) return null;
return getYearToDateRange(selectedPeriod, currentFiscalYear);
}, [currentFiscalYear, selectedPeriod]);
const ytdPeriods = useMemo(() => {
if (!currentFiscalYear || !selectedPeriod) return [];
return periods.filter((p) => {
if (p.fiscalYearId !== currentFiscalYear.id) return false;
return p.periodNumber <= selectedPeriod.periodNumber;
});
}, [currentFiscalYear, selectedPeriod, periods]);
return {
ytdRange,
ytdPeriods,
fiscalYear: currentFiscalYear,
};
}
/**
* Combined hook for common period operations
*/
export function usePeriod() {
const context = usePeriodContext();
const selector = usePeriodSelector();
const validation = usePostingValidation();
const management = usePeriodManagement();
const ytd = useYearToDate();
return {
// Context
...context,
// Selector
selectPeriod: selector.selectPeriod,
selectPreviousPeriod: selector.selectPreviousPeriod,
selectNextPeriod: selector.selectNextPeriod,
enableComparison: selector.enableComparison,
disableComparison: selector.disableComparison,
periodsByYear: selector.periodsByYear,
openPeriods: selector.openPeriods,
// Validation
validatePostingDate: validation.validatePostingDate,
getPeriodStatus: validation.getPeriodStatus,
isDatePostable: validation.isDatePostable,
// Management
canClosePeriod: management.canClosePeriod,
closeAccountingPeriod: management.closeAccountingPeriod,
reopenAccountingPeriod: management.reopenAccountingPeriod,
lockAccountingPeriod: management.lockAccountingPeriod,
getPeriodActions: management.getPeriodActions,
// YTD
ytdRange: ytd.ytdRange,
ytdPeriods: ytd.ytdPeriods,
};
}

View file

@ -0,0 +1,586 @@
import type { TransactionLine, AccountType } from '@/types/accounting';
/**
* Validate that total debits equal total credits (double-entry principle)
*/
export function validateDoubleEntry(lines: TransactionLine[]): {
valid: boolean;
totalDebit: number;
totalCredit: number;
difference: number;
} {
const totalDebit = lines.reduce((sum, line) => sum + (line.debit || 0), 0);
const totalCredit = lines.reduce((sum, line) => sum + (line.credit || 0), 0);
const difference = Math.abs(totalDebit - totalCredit);
// Allow for small floating point differences (< 0.01)
const valid = difference < 0.01;
return {
valid,
totalDebit,
totalCredit,
difference,
};
}
/**
* Calculate line balance (debit - credit)
*/
export function calculateLineBalance(line: TransactionLine): number {
return (line.debit || 0) - (line.credit || 0);
}
/**
* Get account type from account number (Danish standard)
*/
export function getAccountTypeFromNumber(accountNumber: string): AccountType {
const num = parseInt(accountNumber, 10);
if (num >= 1000 && num < 2000) return 'asset';
if (num >= 2000 && num < 3000) return 'liability';
if (num >= 3000 && num < 4000) return 'equity';
if (num >= 4000 && num < 5000) return 'revenue';
if (num >= 5000 && num < 6000) return 'cogs';
if (num >= 6000 && num < 7000) return 'expense';
if (num >= 7000 && num < 8000) return 'personnel';
if (num >= 8000 && num < 9000) return 'financial';
if (num >= 9000 && num < 10000) return 'extraordinary';
return 'asset'; // Default fallback
}
/**
* Get Danish name for account type
*/
export function getAccountTypeName(type: AccountType): string {
const names: Record<AccountType, string> = {
asset: 'Aktiver',
liability: 'Passiver',
equity: 'Egenkapital',
revenue: 'Indtægter',
cogs: 'Vareforbrug',
expense: 'Driftsomkostninger',
personnel: 'Personaleomkostninger',
financial: 'Finansielle poster',
extraordinary: 'Ekstraordinære poster',
};
return names[type];
}
/**
* Get account number range for type
*/
export function getAccountNumberRange(type: AccountType): { min: number; max: number } {
const ranges: Record<AccountType, { min: number; max: number }> = {
asset: { min: 1000, max: 1999 },
liability: { min: 2000, max: 2999 },
equity: { min: 3000, max: 3999 },
revenue: { min: 4000, max: 4999 },
cogs: { min: 5000, max: 5999 },
expense: { min: 6000, max: 6999 },
personnel: { min: 7000, max: 7999 },
financial: { min: 8000, max: 8999 },
extraordinary: { min: 9000, max: 9999 },
};
return ranges[type];
}
/**
* Check if account is a balance sheet account (remains between periods)
*/
export function isBalanceSheetAccount(type: AccountType): boolean {
return ['asset', 'liability', 'equity'].includes(type);
}
/**
* Check if account is an income statement account (resets each period)
*/
export function isIncomeStatementAccount(type: AccountType): boolean {
return !isBalanceSheetAccount(type);
}
/**
* Calculate normal balance direction for account type
* Returns 'debit' or 'credit'
*/
export function getNormalBalance(type: AccountType): 'debit' | 'credit' {
// Asset and expense accounts normally have debit balances
// Liability, equity, and revenue accounts normally have credit balances
switch (type) {
case 'asset':
case 'cogs':
case 'expense':
case 'personnel':
case 'financial':
case 'extraordinary':
return 'debit';
case 'liability':
case 'equity':
case 'revenue':
return 'credit';
}
}
/**
* Danish VAT rates
*/
export const VAT_RATES = {
standard: 0.25, // 25% standard rate
reduced: 0, // No reduced rate in Denmark
zero: 0, // Zero-rated (export, etc.)
} as const;
// Note: VAT calculation functions (calculateVATFromGross, calculateVATFromNet)
// are available from '@/lib/vatCodes' - use those to avoid duplication
/**
* Standard Danish VAT codes
*/
export const VAT_CODES = {
S25: { code: 'S25', name: 'Udgående moms 25%', rate: 0.25, type: 'output' },
K25: { code: 'K25', name: 'Indgående moms 25%', rate: 0.25, type: 'input' },
E0: { code: 'E0', name: 'EU-varekøb 0%', rate: 0, type: 'eu' },
U0: { code: 'U0', name: 'Eksport 0%', rate: 0, type: 'export' },
NONE: { code: 'NONE', name: 'Ingen moms', rate: 0, type: 'none' },
} as const;
/**
* Generate next transaction number
*/
export function generateTransactionNumber(lastNumber: string | null, prefix: string = ''): string {
if (!lastNumber) {
return `${prefix}1`;
}
const numPart = lastNumber.replace(/\D/g, '');
const nextNum = parseInt(numPart, 10) + 1;
return `${prefix}${nextNum}`;
}
// =====================================================
// AUTO-BOOKING FUNCTIONS (Simple Booking)
// =====================================================
import type { VATCode } from '@/types/vat';
import { VAT_CODE_CONFIG, VAT_ACCOUNTS } from '@/lib/vatCodes';
/**
* Bank transaction input for auto-booking
*/
export interface BankTransactionInput {
id: string;
date: string;
amount: number; // Negative = expense, Positive = income
description: string;
counterparty?: string;
bankAccountId: string;
bankAccountNumber: string;
}
/**
* Simple booking input (one bank transaction -> one contra account)
*/
export interface SimpleBookingInput {
bankTransaction: BankTransactionInput;
contraAccountId: string;
contraAccountNumber: string;
contraAccountName: string;
vatCode: VATCode;
description?: string;
}
/**
* Split booking line (for distributing one bank transaction to multiple accounts)
*/
export interface SplitBookingLine {
accountId: string;
accountNumber: string;
accountName: string;
amount: number; // Gross amount for this line
vatCode: VATCode;
description?: string;
}
/**
* Split booking input (one bank transaction -> multiple contra accounts)
*/
export interface SplitBookingInput {
bankTransaction: BankTransactionInput;
lines: SplitBookingLine[];
}
/**
* Generated transaction line with all details
*/
export interface GeneratedTransactionLine {
accountId: string;
accountNumber: string;
accountName: string;
description: string;
debit: number;
credit: number;
vatCode?: VATCode;
vatAmount?: number;
}
/**
* Generated transaction result
*/
export interface GeneratedTransaction {
date: string;
description: string;
lines: GeneratedTransactionLine[];
bankTransactionId: string;
isValid: boolean;
validationMessage?: string;
}
/**
* Generate simple double-entry from bank transaction
*
* For expense (negative bank amount, e.g., -15.000 kr husleje):
* 1. 6100 Husleje Debit: 12.000 kr (net)
* 2. 5610 Moms (ind) Debit: 3.000 kr (25% VAT)
* 3. 1000 Bank Credit: 15.000 kr (gross)
*
* For income (positive bank amount, e.g., +15.625 kr salg):
* 1. 1000 Bank Debit: 15.625 kr (gross)
* 2. 4000 Salg Credit: 12.500 kr (net)
* 3. 5710 Moms (udg) Credit: 3.125 kr (25% VAT)
*/
export function generateSimpleDoubleEntry(input: SimpleBookingInput): GeneratedTransaction {
const { bankTransaction, contraAccountId, contraAccountNumber, contraAccountName, vatCode } = input;
const description = input.description || bankTransaction.description;
const grossAmount = Math.abs(bankTransaction.amount);
const isExpense = bankTransaction.amount < 0;
const vatConfig = VAT_CODE_CONFIG[vatCode];
const lines: GeneratedTransactionLine[] = [];
// Calculate VAT if applicable
// Note: Round net first, then calculate VAT as gross - net
// This ensures net + vat = gross (no floating point drift)
let netAmount = grossAmount;
let vatAmount = 0;
if (vatConfig.rate > 0) {
const rawNet = grossAmount / (1 + vatConfig.rate);
netAmount = Math.round(rawNet * 100) / 100;
// Calculate VAT as difference to guarantee gross = net + vat
vatAmount = Math.round((grossAmount - netAmount) * 100) / 100;
}
if (isExpense) {
// Expense: Money leaving bank account
// Contra account (expense) gets debited with net amount
lines.push({
accountId: contraAccountId,
accountNumber: contraAccountNumber,
accountName: contraAccountName,
description,
debit: netAmount,
credit: 0,
vatCode,
vatAmount: vatConfig.rate > 0 ? vatAmount : undefined,
});
// VAT account (input VAT) gets debited if applicable
if (vatAmount > 0 && vatConfig.deductible) {
lines.push({
accountId: `vat-input-${vatCode}`,
accountNumber: VAT_ACCOUNTS.inputVAT,
accountName: 'Indgaaende moms',
description: `Moms: ${description}`,
debit: vatAmount,
credit: 0,
});
}
// For reverse charge (EU purchases), also credit output VAT
if (vatConfig.reverseCharge && vatAmount > 0) {
const outputVatAccount = vatCode === 'EU_VARE' ? VAT_ACCOUNTS.euVAT : VAT_ACCOUNTS.outputVAT;
lines.push({
accountId: `vat-output-${vatCode}`,
accountNumber: outputVatAccount,
accountName: vatCode === 'EU_VARE' ? 'EU-moms (erhvervelse)' : 'Udgaaende moms',
description: `Moms: ${description}`,
debit: 0,
credit: vatAmount,
});
}
// Bank account gets credited with gross amount
lines.push({
accountId: bankTransaction.bankAccountId,
accountNumber: bankTransaction.bankAccountNumber,
accountName: 'Bank',
description,
debit: 0,
credit: grossAmount,
});
} else {
// Income: Money entering bank account
// Bank account gets debited with gross amount
lines.push({
accountId: bankTransaction.bankAccountId,
accountNumber: bankTransaction.bankAccountNumber,
accountName: 'Bank',
description,
debit: grossAmount,
credit: 0,
});
// Contra account (revenue) gets credited with net amount
lines.push({
accountId: contraAccountId,
accountNumber: contraAccountNumber,
accountName: contraAccountName,
description,
debit: 0,
credit: netAmount,
vatCode,
vatAmount: vatConfig.rate > 0 ? vatAmount : undefined,
});
// VAT account (output VAT) gets credited if applicable
if (vatAmount > 0 && vatConfig.type === 'output') {
lines.push({
accountId: 'vat-output',
accountNumber: VAT_ACCOUNTS.outputVAT,
accountName: 'Udgaaende moms',
description: `Moms: ${description}`,
debit: 0,
credit: vatAmount,
});
}
}
// Validate double-entry
const validation = validateDoubleEntry(
lines.map((l) => ({ ...l, id: '', transactionId: '' }))
);
return {
date: bankTransaction.date,
description,
lines,
bankTransactionId: bankTransaction.id,
isValid: validation.valid,
validationMessage: validation.valid
? undefined
: `Ubalance: Difference ${validation.difference.toFixed(2)} kr`,
};
}
/**
* Generate split double-entry from bank transaction
* Distributes one bank transaction to multiple contra accounts
*/
export function generateSplitDoubleEntry(input: SplitBookingInput): GeneratedTransaction {
const { bankTransaction, lines: splitLines } = input;
const grossAmount = Math.abs(bankTransaction.amount);
const isExpense = bankTransaction.amount < 0;
const generatedLines: GeneratedTransactionLine[] = [];
// Validate that split lines sum to bank transaction amount
const splitTotal = splitLines.reduce((sum, line) => sum + Math.abs(line.amount), 0);
if (Math.abs(splitTotal - grossAmount) > 0.01) {
return {
date: bankTransaction.date,
description: bankTransaction.description,
lines: [],
bankTransactionId: bankTransaction.id,
isValid: false,
validationMessage: `Split-beloeb (${splitTotal.toFixed(2)} kr) matcher ikke banktransaktion (${grossAmount.toFixed(2)} kr)`,
};
}
if (isExpense) {
// Process each split line for expenses
for (const splitLine of splitLines) {
const vatConfig = VAT_CODE_CONFIG[splitLine.vatCode];
const lineGross = Math.abs(splitLine.amount);
let lineNet = lineGross;
let lineVat = 0;
// Round net first, then calculate VAT as gross - net
// This ensures net + vat = gross (no floating point drift)
if (vatConfig.rate > 0) {
const rawNet = lineGross / (1 + vatConfig.rate);
lineNet = Math.round(rawNet * 100) / 100;
lineVat = Math.round((lineGross - lineNet) * 100) / 100;
}
const description = splitLine.description || bankTransaction.description;
// Contra account (expense) gets debited with net amount
generatedLines.push({
accountId: splitLine.accountId,
accountNumber: splitLine.accountNumber,
accountName: splitLine.accountName,
description,
debit: lineNet,
credit: 0,
vatCode: splitLine.vatCode,
vatAmount: lineVat > 0 ? lineVat : undefined,
});
// VAT account (input VAT) gets debited if applicable
if (lineVat > 0 && vatConfig.deductible) {
generatedLines.push({
accountId: `vat-input-${splitLine.vatCode}`,
accountNumber: VAT_ACCOUNTS.inputVAT,
accountName: 'Indgaaende moms',
description: `Moms: ${description}`,
debit: lineVat,
credit: 0,
});
}
// For reverse charge, also credit output VAT
if (vatConfig.reverseCharge && lineVat > 0) {
const outputVatAccount = splitLine.vatCode === 'EU_VARE' ? VAT_ACCOUNTS.euVAT : VAT_ACCOUNTS.outputVAT;
generatedLines.push({
accountId: `vat-output-${splitLine.vatCode}`,
accountNumber: outputVatAccount,
accountName: splitLine.vatCode === 'EU_VARE' ? 'EU-moms (erhvervelse)' : 'Udgaaende moms',
description: `Moms: ${description}`,
debit: 0,
credit: lineVat,
});
}
}
// Bank account gets credited with total gross amount
generatedLines.push({
accountId: bankTransaction.bankAccountId,
accountNumber: bankTransaction.bankAccountNumber,
accountName: 'Bank',
description: bankTransaction.description,
debit: 0,
credit: grossAmount,
});
} else {
// Income: Bank account gets debited first
generatedLines.push({
accountId: bankTransaction.bankAccountId,
accountNumber: bankTransaction.bankAccountNumber,
accountName: 'Bank',
description: bankTransaction.description,
debit: grossAmount,
credit: 0,
});
// Process each split line for income
for (const splitLine of splitLines) {
const vatConfig = VAT_CODE_CONFIG[splitLine.vatCode];
const lineGross = Math.abs(splitLine.amount);
let lineNet = lineGross;
let lineVat = 0;
// Round net first, then calculate VAT as gross - net
// This ensures net + vat = gross (no floating point drift)
if (vatConfig.rate > 0) {
const rawNet = lineGross / (1 + vatConfig.rate);
lineNet = Math.round(rawNet * 100) / 100;
lineVat = Math.round((lineGross - lineNet) * 100) / 100;
}
const description = splitLine.description || bankTransaction.description;
// Contra account (revenue) gets credited with net amount
generatedLines.push({
accountId: splitLine.accountId,
accountNumber: splitLine.accountNumber,
accountName: splitLine.accountName,
description,
debit: 0,
credit: lineNet,
vatCode: splitLine.vatCode,
vatAmount: lineVat > 0 ? lineVat : undefined,
});
// VAT account (output VAT) gets credited if applicable
if (lineVat > 0 && vatConfig.type === 'output') {
generatedLines.push({
accountId: 'vat-output',
accountNumber: VAT_ACCOUNTS.outputVAT,
accountName: 'Udgaaende moms',
description: `Moms: ${description}`,
debit: 0,
credit: lineVat,
});
}
}
}
// Validate double-entry
const validation = validateDoubleEntry(
generatedLines.map((l) => ({ ...l, id: '', transactionId: '' }))
);
return {
date: bankTransaction.date,
description: bankTransaction.description,
lines: generatedLines,
bankTransactionId: bankTransaction.id,
isValid: validation.valid,
validationMessage: validation.valid
? undefined
: `Ubalance: Difference ${validation.difference.toFixed(2)} kr`,
};
}
/**
* Get suggested VAT code based on account type
*/
export function getSuggestedVATCode(accountNumber: string, isExpense: boolean): VATCode {
const accountType = getAccountTypeFromNumber(accountNumber);
// Expenses typically use input VAT (K25)
if (isExpense) {
// Some expense types are typically VAT-exempt
if (accountType === 'financial') return 'NONE';
if (accountType === 'personnel') return 'NONE';
return 'K25';
}
// Revenue typically uses output VAT (S25)
if (accountType === 'revenue') {
return 'S25';
}
return 'NONE';
}
/**
* Consolidate generated lines by account
* Combines lines with the same account to reduce line count
*/
export function consolidateTransactionLines(
lines: GeneratedTransactionLine[]
): GeneratedTransactionLine[] {
const consolidated: Map<string, GeneratedTransactionLine> = new Map();
for (const line of lines) {
const key = line.accountNumber;
const existing = consolidated.get(key);
if (existing) {
existing.debit += line.debit;
existing.credit += line.credit;
// Append description if different
if (line.description && !existing.description.includes(line.description)) {
existing.description = `${existing.description}; ${line.description}`;
}
} else {
consolidated.set(key, { ...line });
}
}
// Filter out lines with zero amounts
return Array.from(consolidated.values()).filter(
(line) => line.debit > 0 || line.credit > 0
);
}

View file

@ -0,0 +1,611 @@
// Fiscal Year Utilities for Danish Accounting (Regnskabsår)
import dayjs from 'dayjs';
import type {
FiscalYear,
AccountingPeriod,
ClosingEntryType,
} from '@/types/periods';
import type { Account, Transaction } from '@/types/accounting';
// =====================================================
// FISCAL YEAR BOUNDARIES
// =====================================================
/**
* Calculate fiscal year boundaries for a given date
* Based on company's fiscal year start month
*/
export function getFiscalYearBoundaries(
date: string,
fiscalYearStartMonth: number
): { startDate: string; endDate: string; name: string } {
const d = dayjs(date);
const year = d.year();
const month = d.month() + 1; // dayjs months are 0-indexed
let startYear: number;
let endYear: number;
if (fiscalYearStartMonth === 1) {
// Calendar year (January start)
startYear = year;
endYear = year;
} else {
// Non-calendar fiscal year (e.g., July start)
if (month >= fiscalYearStartMonth) {
// We're in the first part of a fiscal year that spans two calendar years
startYear = year;
endYear = year + 1;
} else {
// We're in the second part of a fiscal year
startYear = year - 1;
endYear = year;
}
}
const startDate = dayjs()
.year(startYear)
.month(fiscalYearStartMonth - 1)
.date(1)
.format('YYYY-MM-DD');
const endDate = dayjs()
.year(endYear)
.month(fiscalYearStartMonth - 1)
.subtract(1, 'day')
.endOf('month')
.format('YYYY-MM-DD');
// Name format: "2025" for calendar year, "2024/2025" for split year
const name = startYear === endYear ? `${startYear}` : `${startYear}/${endYear}`;
return { startDate, endDate, name };
}
/**
* Check if a date falls within a fiscal year
*/
export function isDateInFiscalYear(date: string, fiscalYear: FiscalYear): boolean {
const d = dayjs(date);
const start = dayjs(fiscalYear.startDate);
const end = dayjs(fiscalYear.endDate);
return (d.isAfter(start) || d.isSame(start, 'day')) &&
(d.isBefore(end) || d.isSame(end, 'day'));
}
/**
* Find the fiscal year that contains a given date
*/
export function findFiscalYearForDate(
date: string,
fiscalYears: FiscalYear[]
): FiscalYear | undefined {
return fiscalYears.find((fy) => isDateInFiscalYear(date, fy));
}
/**
* Check if a date is outside all existing fiscal years
*/
export function isDateOutsideFiscalYears(
date: string,
fiscalYears: FiscalYear[]
): boolean {
return !findFiscalYearForDate(date, fiscalYears);
}
// =====================================================
// FISCAL YEAR CREATION
// =====================================================
/**
* Generate a new fiscal year object
*/
export function createFiscalYearObject(
companyId: string,
startDate: string,
endDate: string,
name: string
): Omit<FiscalYear, 'id' | 'createdAt' | 'updatedAt'> {
return {
companyId,
name,
startDate,
endDate,
status: 'open',
openingBalancePosted: false,
};
}
/**
* Generate fiscal year for a specific date
*/
export function generateFiscalYearForDate(
date: string,
companyId: string,
fiscalYearStartMonth: number
): Omit<FiscalYear, 'id' | 'createdAt' | 'updatedAt'> {
const { startDate, endDate, name } = getFiscalYearBoundaries(date, fiscalYearStartMonth);
return createFiscalYearObject(companyId, startDate, endDate, name);
}
// =====================================================
// OPENING BALANCE CALCULATION
// =====================================================
/**
* Account balance at end of fiscal year
*/
export interface AccountClosingBalance {
accountId: string;
accountNumber: string;
accountName: string;
accountType: Account['type'];
closingBalance: number;
}
/**
* Opening balance for new fiscal year
*/
export interface OpeningBalance {
accountId: string;
accountNumber: string;
accountName: string;
accountType: Account['type'];
openingBalance: number;
}
/**
* Calculate closing balances for all accounts in a fiscal year
*/
export function calculateClosingBalances(
fiscalYear: FiscalYear,
accounts: Account[],
transactions: Transaction[]
): AccountClosingBalance[] {
// Filter transactions to this fiscal year
const yearTransactions = transactions.filter((tx) => {
if (tx.isVoided) return false;
return isDateInFiscalYear(tx.date, fiscalYear);
});
// Calculate balance per account
const balances = new Map<string, number>();
for (const tx of yearTransactions) {
for (const line of tx.lines) {
const current = balances.get(line.accountId) || 0;
const change = (line.debit || 0) - (line.credit || 0);
balances.set(line.accountId, current + change);
}
}
// Build closing balance records
return accounts.map((account) => ({
accountId: account.id,
accountNumber: account.accountNumber,
accountName: account.name,
accountType: account.type,
closingBalance: balances.get(account.id) || 0,
}));
}
/**
* Calculate opening balances for a new fiscal year
* Based on previous year's closing balances
*
* Rules:
* - Balance sheet accounts (asset, liability, equity): carry forward
* - Income statement accounts (revenue, expense, cogs, personnel, financial, extraordinary): zero
* - Result is transferred to retained earnings (handled separately in closing entries)
*/
export function calculateOpeningBalances(
closingBalances: AccountClosingBalance[]
): OpeningBalance[] {
const balanceSheetTypes: Account['type'][] = ['asset', 'liability', 'equity'];
return closingBalances
.filter((cb) => balanceSheetTypes.includes(cb.accountType))
.map((cb) => ({
accountId: cb.accountId,
accountNumber: cb.accountNumber,
accountName: cb.accountName,
accountType: cb.accountType,
openingBalance: cb.closingBalance,
}));
}
// =====================================================
// YEAR-END CLOSING ENTRIES
// =====================================================
/**
* Closing summary for display before closing
*/
export interface FiscalYearClosingSummary {
fiscalYearId: string;
totalRevenue: number;
totalExpenses: number;
netResult: number;
unreconciledCount: number;
openPeriodsCount: number;
canClose: boolean;
warnings: string[];
errors: string[];
}
/**
* Calculate closing summary for a fiscal year
*/
export function calculateClosingSummary(
fiscalYear: FiscalYear,
periods: AccountingPeriod[],
closingBalances: AccountClosingBalance[],
transactions: Transaction[]
): FiscalYearClosingSummary {
const warnings: string[] = [];
const errors: string[] = [];
// Calculate totals from closing balances
const revenueTypes: Account['type'][] = ['revenue'];
const expenseTypes: Account['type'][] = ['cogs', 'expense', 'personnel', 'financial', 'extraordinary'];
// Revenue is typically credit (negative balance in our debit-credit system)
const totalRevenue = Math.abs(
closingBalances
.filter((cb) => revenueTypes.includes(cb.accountType))
.reduce((sum, cb) => sum + cb.closingBalance, 0)
);
// Expenses are typically debit (positive balance)
const totalExpenses = closingBalances
.filter((cb) => expenseTypes.includes(cb.accountType))
.reduce((sum, cb) => sum + cb.closingBalance, 0);
const netResult = totalRevenue - totalExpenses;
// Check for unreconciled transactions
const yearTransactions = transactions.filter(
(tx) => !tx.isVoided && isDateInFiscalYear(tx.date, fiscalYear)
);
const unreconciledCount = yearTransactions.filter((tx) => !tx.isReconciled).length;
if (unreconciledCount > 0) {
warnings.push(
`${unreconciledCount} transaktioner er ikke afstemt`
);
}
// Check for open periods
const yearPeriods = periods.filter((p) => p.fiscalYearId === fiscalYear.id);
const openPeriodsCount = yearPeriods.filter((p) => p.status === 'open').length;
if (openPeriodsCount > 0) {
warnings.push(
`${openPeriodsCount} perioder er stadig åbne`
);
}
// Check if fiscal year is already closed/locked
if (fiscalYear.status === 'locked') {
errors.push('Regnskabsåret er allerede låst');
}
const canClose = errors.length === 0;
return {
fiscalYearId: fiscalYear.id,
totalRevenue,
totalExpenses,
netResult,
unreconciledCount,
openPeriodsCount,
canClose,
warnings,
errors,
};
}
/**
* Generate closing entries for year-end
*/
export interface GeneratedClosingEntry {
type: ClosingEntryType;
description: string;
descriptionDanish: string;
lines: Array<{
accountId: string;
accountNumber: string;
accountName: string;
debit: number;
credit: number;
}>;
totalAmount: number;
}
/**
* Generate year-end closing entries
*/
export function generateClosingEntries(
fiscalYear: FiscalYear,
closingBalances: AccountClosingBalance[],
resultAccountId: string,
resultAccountNumber: string,
resultAccountName: string
): GeneratedClosingEntry[] {
const entries: GeneratedClosingEntry[] = [];
const revenueTypes: Account['type'][] = ['revenue'];
const expenseTypes: Account['type'][] = ['cogs', 'expense', 'personnel', 'financial', 'extraordinary'];
// 1. Close revenue accounts to result account
const revenueBalances = closingBalances.filter(
(cb) => revenueTypes.includes(cb.accountType) && cb.closingBalance !== 0
);
if (revenueBalances.length > 0) {
const revenueLines = revenueBalances.map((cb) => ({
accountId: cb.accountId,
accountNumber: cb.accountNumber,
accountName: cb.accountName,
// Revenue typically has credit balance (negative), so we debit to close
debit: cb.closingBalance < 0 ? Math.abs(cb.closingBalance) : 0,
credit: cb.closingBalance > 0 ? cb.closingBalance : 0,
}));
const totalRevenue = revenueBalances.reduce((sum, cb) => sum + cb.closingBalance, 0);
// Counter entry to result account
revenueLines.push({
accountId: resultAccountId,
accountNumber: resultAccountNumber,
accountName: resultAccountName,
debit: totalRevenue > 0 ? totalRevenue : 0,
credit: totalRevenue < 0 ? Math.abs(totalRevenue) : 0,
});
entries.push({
type: 'revenue-close',
description: `Close revenue accounts for fiscal year ${fiscalYear.name}`,
descriptionDanish: `Luk indtægtskonti for regnskabsår ${fiscalYear.name}`,
lines: revenueLines,
totalAmount: Math.abs(totalRevenue),
});
}
// 2. Close expense accounts to result account
const expenseBalances = closingBalances.filter(
(cb) => expenseTypes.includes(cb.accountType) && cb.closingBalance !== 0
);
if (expenseBalances.length > 0) {
const expenseLines = expenseBalances.map((cb) => ({
accountId: cb.accountId,
accountNumber: cb.accountNumber,
accountName: cb.accountName,
// Expenses typically have debit balance (positive), so we credit to close
debit: cb.closingBalance < 0 ? Math.abs(cb.closingBalance) : 0,
credit: cb.closingBalance > 0 ? cb.closingBalance : 0,
}));
const totalExpenses = expenseBalances.reduce((sum, cb) => sum + cb.closingBalance, 0);
// Counter entry to result account
expenseLines.push({
accountId: resultAccountId,
accountNumber: resultAccountNumber,
accountName: resultAccountName,
debit: totalExpenses < 0 ? Math.abs(totalExpenses) : 0,
credit: totalExpenses > 0 ? totalExpenses : 0,
});
entries.push({
type: 'expense-close',
description: `Close expense accounts for fiscal year ${fiscalYear.name}`,
descriptionDanish: `Luk udgiftskonti for regnskabsår ${fiscalYear.name}`,
lines: expenseLines,
totalAmount: Math.abs(totalExpenses),
});
}
return entries;
}
// =====================================================
// VALIDATION
// =====================================================
/**
* Validation result for fiscal year operations
*/
export interface FiscalYearValidation {
isValid: boolean;
canProceed: boolean;
errors: Array<{ code: string; message: string; messageDanish: string }>;
warnings: Array<{ code: string; message: string; messageDanish: string }>;
}
/**
* Validate if a fiscal year can be closed
*/
export function validateFiscalYearClose(
fiscalYear: FiscalYear,
periods: AccountingPeriod[]
): FiscalYearValidation {
const errors: FiscalYearValidation['errors'] = [];
const warnings: FiscalYearValidation['warnings'] = [];
// Check if already locked
if (fiscalYear.status === 'locked') {
errors.push({
code: 'ALREADY_LOCKED',
message: 'Fiscal year is already locked',
messageDanish: 'Regnskabsåret er allerede låst',
});
}
// Check for open periods
const yearPeriods = periods.filter((p) => p.fiscalYearId === fiscalYear.id);
const openPeriods = yearPeriods.filter((p) => p.status === 'open');
if (openPeriods.length > 0) {
warnings.push({
code: 'OPEN_PERIODS',
message: `${openPeriods.length} accounting period(s) are still open`,
messageDanish: `${openPeriods.length} regnskabsperiode(r) er stadig åbne`,
});
}
// Check for future periods
const futurePeriods = yearPeriods.filter((p) => p.status === 'future');
if (futurePeriods.length > 0) {
warnings.push({
code: 'FUTURE_PERIODS',
message: `${futurePeriods.length} accounting period(s) have not started yet`,
messageDanish: `${futurePeriods.length} regnskabsperiode(r) er ikke startet endnu`,
});
}
return {
isValid: errors.length === 0,
canProceed: errors.length === 0,
errors,
warnings,
};
}
/**
* Validate if a new fiscal year can be created
*/
export function validateFiscalYearCreation(
newYear: Pick<FiscalYear, 'startDate' | 'endDate'>,
existingYears: FiscalYear[]
): FiscalYearValidation {
const errors: FiscalYearValidation['errors'] = [];
const warnings: FiscalYearValidation['warnings'] = [];
const newStart = dayjs(newYear.startDate);
const newEnd = dayjs(newYear.endDate);
// Check for overlapping fiscal years
for (const existing of existingYears) {
const existingStart = dayjs(existing.startDate);
const existingEnd = dayjs(existing.endDate);
// Use strict inequality - two fiscal years that touch at the boundary
// (one ends on date X, other starts on date X) are allowed
const overlaps = newStart.isBefore(existingEnd) && newEnd.isAfter(existingStart);
if (overlaps) {
errors.push({
code: 'OVERLAP',
message: `Overlaps with existing fiscal year ${existing.name}`,
messageDanish: `Overlapper med eksisterende regnskabsår ${existing.name}`,
});
}
}
// Check if fiscal year length is reasonable (300-400 days typically)
const daysDiff = newEnd.diff(newStart, 'day');
if (daysDiff < 300) {
warnings.push({
code: 'SHORT_YEAR',
message: `Fiscal year is only ${daysDiff} days`,
messageDanish: `Regnskabsåret er kun ${daysDiff} dage`,
});
}
if (daysDiff > 400) {
warnings.push({
code: 'LONG_YEAR',
message: `Fiscal year is ${daysDiff} days (typically should be ~365)`,
messageDanish: `Regnskabsåret er ${daysDiff} dage (normalt omkring 365)`,
});
}
return {
isValid: errors.length === 0,
canProceed: errors.length === 0,
errors,
warnings,
};
}
// =====================================================
// HELPERS
// =====================================================
/**
* Get previous fiscal year
*/
export function getPreviousFiscalYear(
fiscalYear: FiscalYear,
allYears: FiscalYear[]
): FiscalYear | undefined {
const sorted = [...allYears].sort(
(a, b) => new Date(b.startDate).getTime() - new Date(a.startDate).getTime()
);
const currentIndex = sorted.findIndex((y) => y.id === fiscalYear.id);
if (currentIndex === -1 || currentIndex === sorted.length - 1) {
return undefined;
}
return sorted[currentIndex + 1];
}
/**
* Get next fiscal year
*/
export function getNextFiscalYear(
fiscalYear: FiscalYear,
allYears: FiscalYear[]
): FiscalYear | undefined {
const sorted = [...allYears].sort(
(a, b) => new Date(b.startDate).getTime() - new Date(a.startDate).getTime()
);
const currentIndex = sorted.findIndex((y) => y.id === fiscalYear.id);
if (currentIndex === -1 || currentIndex === 0) {
return undefined;
}
return sorted[currentIndex - 1];
}
/**
* Format fiscal year for display
*/
export function formatFiscalYear(fiscalYear: FiscalYear): string {
return `Regnskabsår ${fiscalYear.name}`;
}
/**
* Get fiscal year status color
*/
export function getFiscalYearStatusColor(status: FiscalYear['status']): string {
switch (status) {
case 'open':
return 'green';
case 'closed':
return 'orange';
case 'locked':
return 'red';
default:
return 'default';
}
}
/**
* Get fiscal year status label in Danish
*/
export function getFiscalYearStatusLabel(status: FiscalYear['status']): string {
switch (status) {
case 'open':
return 'Åben';
case 'closed':
return 'Lukket';
case 'locked':
return 'Låst';
default:
return status;
}
}

View file

@ -0,0 +1,167 @@
import dayjs from 'dayjs';
import 'dayjs/locale/da';
// Set Danish locale as default
dayjs.locale('da');
/**
* Format a number as Danish currency (kr.)
*/
export function formatCurrency(
value: number,
options: {
showSign?: boolean;
decimalPlaces?: number;
currency?: string;
} = {}
): string {
const { showSign = false, decimalPlaces = 2, currency = 'kr.' } = options;
const absValue = Math.abs(value);
const formattedNumber = absValue.toLocaleString('da-DK', {
minimumFractionDigits: decimalPlaces,
maximumFractionDigits: decimalPlaces,
});
let result = `${formattedNumber} ${currency}`;
if (showSign && value !== 0) {
result = value > 0 ? `+${result}` : `-${result}`;
} else if (value < 0) {
result = `-${result}`;
}
return result;
}
/**
* Format a number with Danish locale (1.234,56)
*/
export function formatNumber(
value: number,
decimalPlaces: number = 2
): string {
return value.toLocaleString('da-DK', {
minimumFractionDigits: decimalPlaces,
maximumFractionDigits: decimalPlaces,
});
}
/**
* Format a date in Danish format
*/
export function formatDate(
date: string | Date,
format: string = 'DD/MM/YYYY'
): string {
return dayjs(date).format(format);
}
/**
* Format a date with time
*/
export function formatDateTime(
date: string | Date,
format: string = 'DD/MM/YYYY HH:mm'
): string {
return dayjs(date).format(format);
}
/**
* Format a date in short Danish format (e.g., "15. jan 2025")
*/
export function formatDateShort(date: string | Date): string {
return dayjs(date).format('D. MMM YYYY');
}
/**
* Format a date in long Danish format (e.g., "15. januar 2025")
*/
export function formatDateLong(date: string | Date): string {
return dayjs(date).format('D. MMMM YYYY');
}
/**
* Format a date for API requests (ISO format)
*/
export function formatDateISO(date: string | Date): string {
return dayjs(date).format('YYYY-MM-DD');
}
/**
* Format period (month/year)
*/
export function formatPeriod(date: string | Date): string {
return dayjs(date).format('MMMM YYYY');
}
/**
* Format account number (e.g., "1000" -> "1000")
*/
export function formatAccountNumber(accountNumber: string): string {
// Already formatted, but could add padding if needed
return accountNumber.padStart(4, '0');
}
/**
* Format CVR number (e.g., "12345678" -> "12 34 56 78")
*/
export function formatCVR(cvr: string): string {
const cleaned = cvr.replace(/\D/g, '');
if (cleaned.length !== 8) return cvr;
return `${cleaned.slice(0, 2)} ${cleaned.slice(2, 4)} ${cleaned.slice(4, 6)} ${cleaned.slice(6, 8)}`;
}
/**
* Get CSS class for amount (positive/negative/zero)
*/
export function getAmountClass(value: number): string {
if (value > 0) return 'amount-positive';
if (value < 0) return 'amount-negative';
return 'amount-zero';
}
/**
* Parse a Danish formatted number string to number
*/
export function parseDanishNumber(value: string): number {
// Replace Danish thousand separator (.) with nothing
// Replace Danish decimal separator (,) with dot
const normalized = value
.replace(/\./g, '')
.replace(',', '.')
.replace(/[^\d.-]/g, '');
return parseFloat(normalized) || 0;
}
/**
* Format transaction number
*/
export function formatTransactionNumber(number: string | number): string {
return `#${number}`;
}
/**
* Format percentage
*/
export function formatPercentage(value: number, decimalPlaces: number = 1): string {
return `${formatNumber(value * 100, decimalPlaces)}%`;
}
/**
* Truncate text with ellipsis
*/
export function truncateText(text: string, maxLength: number): string {
if (text.length <= maxLength) return text;
return `${text.slice(0, maxLength - 3)}...`;
}
/**
* Format file size
*/
export function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
}

541
frontend/src/lib/periods.ts Normal file
View file

@ -0,0 +1,541 @@
// Period Calculation Utilities for Danish Accounting
import dayjs from 'dayjs';
import type {
FiscalYear,
AccountingPeriod,
VATPeriod,
PeriodFrequency,
PeriodStatus,
PeriodValidationResult,
DANISH_MONTHS,
DANISH_MONTHS_SHORT,
} from '@/types/periods';
import type { VATPeriodicitet } from '@/types/periods';
import type { Transaction } from '@/types/accounting';
// =====================================================
// PERIOD GENERATION
// =====================================================
/**
* Generate accounting periods for a fiscal year
*/
export function generateAccountingPeriods(
fiscalYear: FiscalYear,
frequency: PeriodFrequency
): Omit<AccountingPeriod, 'id' | 'createdAt' | 'updatedAt'>[] {
const periods: Omit<AccountingPeriod, 'id' | 'createdAt' | 'updatedAt'>[] = [];
const startDate = dayjs(fiscalYear.startDate);
const endDate = dayjs(fiscalYear.endDate);
let periodCount: number;
let monthsPerPeriod: number;
switch (frequency) {
case 'monthly':
periodCount = 12;
monthsPerPeriod = 1;
break;
case 'quarterly':
periodCount = 4;
monthsPerPeriod = 3;
break;
case 'half-yearly':
periodCount = 2;
monthsPerPeriod = 6;
break;
case 'yearly':
periodCount = 1;
monthsPerPeriod = 12;
break;
}
for (let i = 0; i < periodCount; i++) {
const periodStart = startDate.add(i * monthsPerPeriod, 'month');
const periodEnd = periodStart.add(monthsPerPeriod, 'month').subtract(1, 'day');
// Make sure we don't exceed fiscal year end
const actualEnd = periodEnd.isAfter(endDate) ? endDate : periodEnd;
const { name, shortName } = getPeriodDisplayName(
periodStart.format('YYYY-MM-DD'),
actualEnd.format('YYYY-MM-DD'),
frequency
);
const status = determinePeriodStatus(
periodStart.format('YYYY-MM-DD'),
actualEnd.format('YYYY-MM-DD')
);
periods.push({
companyId: fiscalYear.companyId,
fiscalYearId: fiscalYear.id,
periodNumber: i + 1,
name,
shortName,
startDate: periodStart.format('YYYY-MM-DD'),
endDate: actualEnd.format('YYYY-MM-DD'),
status,
});
}
return periods;
}
/**
* Generate VAT periods for a year
*/
export function generateVATPeriods(
companyId: string,
periodicitet: VATPeriodicitet,
year: number
): Omit<VATPeriod, 'id' | 'createdAt' | 'updatedAt'>[] {
const periods: Omit<VATPeriod, 'id' | 'createdAt' | 'updatedAt'>[] = [];
let periodCount: number;
let monthsPerPeriod: number;
let deadlineDays: number;
switch (periodicitet) {
case 'monthly':
periodCount = 12;
monthsPerPeriod = 1;
deadlineDays = 25;
break;
case 'quarterly':
periodCount = 4;
monthsPerPeriod = 3;
deadlineDays = 40;
break;
case 'half-yearly':
periodCount = 2;
monthsPerPeriod = 6;
deadlineDays = 60;
break;
case 'yearly':
periodCount = 1;
monthsPerPeriod = 12;
deadlineDays = 90;
break;
}
for (let i = 0; i < periodCount; i++) {
const startMonth = i * monthsPerPeriod + 1;
const periodStart = dayjs(`${year}-${String(startMonth).padStart(2, '0')}-01`);
const periodEnd = periodStart.add(monthsPerPeriod, 'month').subtract(1, 'day');
const deadline = periodEnd.add(deadlineDays, 'day');
const name = getVATPeriodName(periodicitet, year, i + 1);
const status = determineVATPeriodStatus(
periodStart.format('YYYY-MM-DD'),
periodEnd.format('YYYY-MM-DD')
);
periods.push({
companyId,
periodicitet,
year,
periodNumber: i + 1,
name,
startDate: periodStart.format('YYYY-MM-DD'),
endDate: periodEnd.format('YYYY-MM-DD'),
deadline: deadline.format('YYYY-MM-DD'),
status,
});
}
return periods;
}
// =====================================================
// PERIOD NAMING
// =====================================================
/**
* Get period display name based on frequency
*/
export function getPeriodDisplayName(
startDate: string,
endDate: string,
frequency: PeriodFrequency
): { name: string; shortName: string } {
const start = dayjs(startDate);
const MONTHS = [
'Januar', 'Februar', 'Marts', 'April', 'Maj', 'Juni',
'Juli', 'August', 'September', 'Oktober', 'November', 'December'
];
const MONTHS_SHORT = [
'Jan', 'Feb', 'Mar', 'Apr', 'Maj', 'Jun',
'Jul', 'Aug', 'Sep', 'Okt', 'Nov', 'Dec'
];
switch (frequency) {
case 'monthly':
return {
name: `${MONTHS[start.month()]} ${start.year()}`,
shortName: `${MONTHS_SHORT[start.month()]} ${start.year()}`,
};
case 'quarterly': {
const quarter = Math.floor(start.month() / 3) + 1;
return {
name: `Q${quarter} ${start.year()}`,
shortName: `Q${quarter}`,
};
}
case 'half-yearly': {
const half = start.month() < 6 ? 1 : 2;
return {
name: `H${half} ${start.year()}`,
shortName: `H${half}`,
};
}
case 'yearly':
return {
name: `${start.year()}`,
shortName: `${start.year()}`,
};
}
}
/**
* Get VAT period name
*/
export function getVATPeriodName(
periodicitet: VATPeriodicitet,
year: number,
periodNumber: number
): string {
const MONTHS = [
'Januar', 'Februar', 'Marts', 'April', 'Maj', 'Juni',
'Juli', 'August', 'September', 'Oktober', 'November', 'December'
];
switch (periodicitet) {
case 'monthly':
return `${MONTHS[periodNumber - 1]} ${year}`;
case 'quarterly':
return `Q${periodNumber} ${year}`;
case 'half-yearly':
return `H${periodNumber} ${year}`;
case 'yearly':
return `${year}`;
}
}
/**
* Get Danish month name
*/
export function getDanishMonthName(month: number): string {
const MONTHS = [
'Januar', 'Februar', 'Marts', 'April', 'Maj', 'Juni',
'Juli', 'August', 'September', 'Oktober', 'November', 'December'
];
return MONTHS[month - 1] || '';
}
// =====================================================
// PERIOD STATUS
// =====================================================
/**
* Determine period status based on dates
*/
export function determinePeriodStatus(
startDate: string,
endDate: string
): PeriodStatus {
const now = dayjs();
const start = dayjs(startDate);
const end = dayjs(endDate);
if (now.isBefore(start)) return 'future';
if (now.isBefore(end) || now.isSame(end, 'day')) return 'open';
return 'closed';
}
/**
* Determine VAT period status
*/
export function determineVATPeriodStatus(
startDate: string,
endDate: string
): 'future' | 'open' | 'closed' {
const now = dayjs();
const start = dayjs(startDate);
const end = dayjs(endDate);
if (now.isBefore(start)) return 'future';
if (now.isBefore(end) || now.isSame(end, 'day')) return 'open';
return 'closed';
}
// =====================================================
// PERIOD LOOKUP
// =====================================================
/**
* Get period containing a specific date
*/
export function getPeriodForDate(
date: string,
periods: AccountingPeriod[]
): AccountingPeriod | undefined {
const targetDate = dayjs(date);
return periods.find((period) => {
const start = dayjs(period.startDate);
const end = dayjs(period.endDate);
return (
(targetDate.isAfter(start) || targetDate.isSame(start, 'day')) &&
(targetDate.isBefore(end) || targetDate.isSame(end, 'day'))
);
});
}
/**
* Get previous period (for comparison)
*/
export function getPreviousPeriod(
currentPeriod: AccountingPeriod,
allPeriods: AccountingPeriod[]
): AccountingPeriod | undefined {
const currentStart = dayjs(currentPeriod.startDate);
return allPeriods.find((period) => {
const periodEnd = dayjs(period.endDate);
// Previous period ends the day before current period starts
return periodEnd.add(1, 'day').isSame(currentStart, 'day');
});
}
/**
* Get same period from previous year
*/
export function getSamePeriodPreviousYear(
currentPeriod: AccountingPeriod,
allPeriods: AccountingPeriod[]
): AccountingPeriod | undefined {
const currentStart = dayjs(currentPeriod.startDate);
const previousYearStart = currentStart.subtract(1, 'year');
return allPeriods.find((period) => {
const periodStart = dayjs(period.startDate);
return (
periodStart.month() === previousYearStart.month() &&
periodStart.year() === previousYearStart.year() &&
period.periodNumber === currentPeriod.periodNumber
);
});
}
/**
* Calculate year-to-date range
*/
export function getYearToDateRange(
currentPeriod: AccountingPeriod,
fiscalYear: FiscalYear
): { startDate: string; endDate: string } {
return {
startDate: fiscalYear.startDate,
endDate: currentPeriod.endDate,
};
}
// =====================================================
// PERIOD VALIDATION
// =====================================================
/**
* Check if a date can be posted to based on period status
*/
export function canPostToDate(
date: string,
periods: AccountingPeriod[],
settings: {
preventPostingToClosedPeriods: boolean;
preventPostingToFuturePeriods: boolean;
}
): { allowed: boolean; reason?: string; reasonDanish?: string } {
const period = getPeriodForDate(date, periods);
if (!period) {
return {
allowed: false,
reason: 'No period found for this date',
reasonDanish: 'Ingen periode fundet for denne dato',
};
}
if (period.status === 'locked') {
return {
allowed: false,
reason: 'Period is locked',
reasonDanish: 'Perioden er laast',
};
}
if (period.status === 'closed' && settings.preventPostingToClosedPeriods) {
return {
allowed: false,
reason: 'Period is closed',
reasonDanish: 'Perioden er lukket',
};
}
if (period.status === 'future' && settings.preventPostingToFuturePeriods) {
return {
allowed: false,
reason: 'Cannot post to future periods',
reasonDanish: 'Kan ikke bogfoere i fremtidige perioder',
};
}
return { allowed: true };
}
/**
* Validate if period can be closed
*/
export function validatePeriodClose(
period: AccountingPeriod,
transactions: Transaction[],
options?: {
requireAllReconciled?: boolean;
}
): PeriodValidationResult {
const errors: PeriodValidationResult['errors'] = [];
const warnings: PeriodValidationResult['warnings'] = [];
// Check if period is already locked
if (period.status === 'locked') {
errors.push({
code: 'PERIOD_LOCKED',
message: 'Period is already locked and cannot be modified',
messageDanish: 'Perioden er allerede laast og kan ikke aendres',
});
}
// Check for unreconciled transactions
if (options?.requireAllReconciled) {
const unreconciledCount = transactions.filter((tx) => !tx.isReconciled).length;
if (unreconciledCount > 0) {
warnings.push({
code: 'UNRECONCILED_TRANSACTIONS',
message: `${unreconciledCount} transactions are not reconciled`,
messageDanish: `${unreconciledCount} transaktioner er ikke afstemt`,
});
}
}
return {
isValid: errors.length === 0,
canPost: period.status !== 'locked',
errors,
warnings,
};
}
// =====================================================
// VAT PERIOD HELPERS
// =====================================================
/**
* Calculate VAT deadlines based on SKAT rules
*/
export function calculateVATDeadline(
periodEndDate: string,
periodicitet: VATPeriodicitet
): { deadline: string; paymentDeadline: string } {
const endDate = dayjs(periodEndDate);
let deadlineDays: number;
switch (periodicitet) {
case 'monthly':
deadlineDays = 25;
break;
case 'quarterly':
deadlineDays = 40;
break;
case 'half-yearly':
deadlineDays = 60;
break;
case 'yearly':
deadlineDays = 90;
break;
}
const deadline = endDate.add(deadlineDays, 'day');
// Payment deadline is typically same as reporting deadline
const paymentDeadline = deadline;
return {
deadline: deadline.format('YYYY-MM-DD'),
paymentDeadline: paymentDeadline.format('YYYY-MM-DD'),
};
}
/**
* Format period for SKAT export
*/
export function formatPeriodForSKAT(
periodicitet: VATPeriodicitet,
year: number,
periodNumber: number
): string {
switch (periodicitet) {
case 'monthly':
return `${year}${String(periodNumber).padStart(2, '0')}`;
case 'quarterly':
return `${year}Q${periodNumber}`;
case 'half-yearly':
return `${year}H${periodNumber}`;
case 'yearly':
return `${year}`;
}
}
// =====================================================
// FISCAL YEAR HELPERS
// =====================================================
/**
* Create a new fiscal year
*/
export function createFiscalYear(
companyId: string,
startMonth: number,
year: number
): Omit<FiscalYear, 'id' | 'createdAt' | 'updatedAt'> {
const startDate = dayjs(`${year}-${String(startMonth).padStart(2, '0')}-01`);
const endDate = startDate.add(1, 'year').subtract(1, 'day');
// Determine fiscal year name
const name = startMonth === 1
? `${year}`
: `${year}/${year + 1}`;
return {
companyId,
name,
startDate: startDate.format('YYYY-MM-DD'),
endDate: endDate.format('YYYY-MM-DD'),
status: 'open',
openingBalancePosted: false,
};
}
/**
* Check if a date is within a fiscal year
*/
export function isDateInFiscalYear(date: string, fiscalYear: FiscalYear): boolean {
const targetDate = dayjs(date);
const start = dayjs(fiscalYear.startDate);
const end = dayjs(fiscalYear.endDate);
return (
(targetDate.isAfter(start) || targetDate.isSame(start, 'day')) &&
(targetDate.isBefore(end) || targetDate.isSame(end, 'day'))
);
}

View file

@ -0,0 +1,419 @@
// VAT Calculation Engine for Danish SKAT Compliance
import dayjs from 'dayjs';
import type {
VATBoxId,
BasisBoxId,
VATReportPeriod,
VATTransactionLine,
VATCalculationResult,
VATCalculationWarning,
VATCalculationError,
CalculatedVATBox,
VATReport,
SKATExportCSV,
SKATExportXML,
} from '@/types/vat';
import type { Transaction, TransactionLine, Company } from '@/types/accounting';
import {
VAT_CODE_CONFIG,
SKAT_VAT_BOXES,
calculateVATFromGross,
isValidVATCode,
} from './vatCodes';
// =====================================================
// MAIN CALCULATION FUNCTION
// =====================================================
/**
* Calculate VAT report from transactions
*/
export function calculateVATReport(
transactions: Transaction[],
period: VATReportPeriod
): VATCalculationResult {
const warnings: VATCalculationWarning[] = [];
const errors: VATCalculationError[] = [];
const vatLines: VATTransactionLine[] = [];
// Initialize box accumulators
const boxes: Record<VATBoxId | BasisBoxId, {
amount: number;
transactionCount: number;
transactionIds: string[];
}> = {
A: { amount: 0, transactionCount: 0, transactionIds: [] },
B: { amount: 0, transactionCount: 0, transactionIds: [] },
C: { amount: 0, transactionCount: 0, transactionIds: [] },
D: { amount: 0, transactionCount: 0, transactionIds: [] },
'1': { amount: 0, transactionCount: 0, transactionIds: [] },
'2': { amount: 0, transactionCount: 0, transactionIds: [] },
'3': { amount: 0, transactionCount: 0, transactionIds: [] },
'4': { amount: 0, transactionCount: 0, transactionIds: [] },
};
// Process each transaction
for (const transaction of transactions) {
// Check transaction is within period
const txDate = dayjs(transaction.date);
const periodStart = dayjs(period.startDate);
const periodEnd = dayjs(period.endDate);
if (txDate.isBefore(periodStart) || txDate.isAfter(periodEnd)) {
warnings.push({
type: 'period_mismatch',
message: `Transaction ${transaction.transactionNumber} is outside the period`,
messageDanish: `Transaktion ${transaction.transactionNumber} er uden for perioden`,
transactionId: transaction.id,
severity: 'medium',
});
continue;
}
// Skip voided transactions
if (transaction.isVoided) continue;
// Process each line
for (const line of transaction.lines) {
const vatLine = processTransactionLine(transaction, line, warnings, errors);
if (vatLine) {
vatLines.push(vatLine);
// Add to appropriate boxes
const codeConfig = VAT_CODE_CONFIG[vatLine.vatCode];
if (codeConfig.affectsBoxes.vatBox) {
const boxId = codeConfig.affectsBoxes.vatBox;
boxes[boxId].amount += vatLine.vatAmount;
if (!boxes[boxId].transactionIds.includes(transaction.id)) {
boxes[boxId].transactionIds.push(transaction.id);
boxes[boxId].transactionCount++;
}
}
if (codeConfig.affectsBoxes.basisBox) {
const basisId = codeConfig.affectsBoxes.basisBox;
boxes[basisId].amount += Math.abs(vatLine.netAmount);
if (!boxes[basisId].transactionIds.includes(transaction.id)) {
boxes[basisId].transactionIds.push(transaction.id);
boxes[basisId].transactionCount++;
}
}
// For reverse charge, also add to input VAT (Box B) as deductible
if (codeConfig.reverseCharge && codeConfig.deductible) {
boxes.B.amount += vatLine.vatAmount;
if (!boxes.B.transactionIds.includes(transaction.id)) {
boxes.B.transactionIds.push(transaction.id);
boxes.B.transactionCount++;
}
}
}
}
}
// Calculate summary
const totalOutputVAT = boxes.A.amount + boxes.C.amount + boxes.D.amount;
const totalInputVAT = boxes.B.amount;
const netVAT = totalOutputVAT - totalInputVAT;
return {
period,
transactions: vatLines,
boxes,
summary: {
totalOutputVAT: roundToOre(totalOutputVAT),
totalInputVAT: roundToOre(totalInputVAT),
netVAT: roundToOre(netVAT),
},
warnings,
errors,
};
}
/**
* Process a single transaction line for VAT
*/
function processTransactionLine(
transaction: Transaction,
line: TransactionLine,
_warnings: VATCalculationWarning[],
errors: VATCalculationError[]
): VATTransactionLine | null {
// Skip lines without VAT code
if (!line.vatCode) {
return null;
}
// Validate VAT code instead of using type assertion
if (!isValidVATCode(line.vatCode)) {
errors.push({
type: 'invalid_vat_code',
message: `Invalid VAT code: ${line.vatCode}`,
messageDanish: `Ugyldig momskode: ${line.vatCode}`,
transactionId: transaction.id,
});
return null;
}
const vatCode = line.vatCode;
// Skip lines without VAT relevance
if (vatCode === 'NONE') {
return null;
}
const codeConfig = VAT_CODE_CONFIG[vatCode];
// Calculate net amount (amount without VAT)
const grossAmount = line.debit > 0 ? line.debit : line.credit;
let netAmount: number;
let vatAmount: number;
if (line.vatAmount !== undefined && line.vatAmount !== null) {
// VAT amount explicitly provided
vatAmount = line.vatAmount;
netAmount = grossAmount - vatAmount;
} else if (codeConfig.rate > 0) {
// Calculate VAT from gross amount
const calculated = calculateVATFromGross(grossAmount, codeConfig.rate);
netAmount = calculated.net;
vatAmount = calculated.vat;
} else {
// Zero-rated or exempt
netAmount = grossAmount;
vatAmount = 0;
}
// Determine sign based on transaction type
const isOutput = codeConfig.type === 'output';
const isInput = codeConfig.type === 'input' || codeConfig.type === 'reverse_charge';
// For output VAT (sales), credit increases VAT liability
// For input VAT (purchases), debit increases VAT deduction
if (isOutput && line.debit > 0) {
// This is a reversal/return of sales
netAmount = -netAmount;
vatAmount = -vatAmount;
} else if (isInput && line.credit > 0) {
// This is a reversal/return of purchases
netAmount = -netAmount;
vatAmount = -vatAmount;
}
return {
transactionId: transaction.id,
transactionLineId: line.id,
transactionNumber: transaction.transactionNumber,
transactionDate: transaction.date,
accountId: line.accountId,
accountNumber: line.account?.accountNumber || '',
accountName: line.account?.name || '',
description: line.description || transaction.description,
debit: line.debit,
credit: line.credit,
netAmount: roundToOre(netAmount),
vatCode,
vatAmount: roundToOre(Math.abs(vatAmount)),
vatRate: codeConfig.rate,
isReverseCharge: codeConfig.reverseCharge,
};
}
// =====================================================
// EXPORT FUNCTIONS
// =====================================================
/**
* Generate SKAT CSV export data
*/
export function generateSKATCSV(
calculation: VATCalculationResult,
company: Company
): SKATExportCSV {
return {
cvr: company.cvr.replace(/\s/g, ''),
periode: formatPeriodForSKATExport(calculation.period),
rubrikA: Math.round(calculation.boxes.A.amount),
rubrikB: Math.round(calculation.boxes.B.amount),
rubrikC: Math.round(calculation.boxes.C.amount),
rubrikD: Math.round(calculation.boxes.D.amount),
felt1: Math.round(calculation.boxes['1'].amount),
felt2: Math.round(calculation.boxes['2'].amount),
felt3: Math.round(calculation.boxes['3'].amount),
felt4: Math.round(calculation.boxes['4'].amount),
};
}
/**
* Generate SKAT XML export data
*/
export function generateSKATXML(
calculation: VATCalculationResult,
company: Company
): SKATExportXML {
return {
version: '1.0',
cvr: company.cvr.replace(/\s/g, ''),
periodeStart: calculation.period.startDate,
periodeSlut: calculation.period.endDate,
angivelse: {
salgsmoms: roundToOre(calculation.boxes.A.amount),
koebsmoms: roundToOre(calculation.boxes.B.amount),
euVarekoebMoms: roundToOre(calculation.boxes.C.amount),
ydelseskoebMoms: roundToOre(calculation.boxes.D.amount),
salgMedMoms: roundToOre(calculation.boxes['1'].amount),
salgUdenMoms: roundToOre(calculation.boxes['2'].amount),
euVarekoeb: roundToOre(calculation.boxes['3'].amount),
ydelseskoeb: roundToOre(calculation.boxes['4'].amount),
},
};
}
/**
* Export VAT report as CSV string
*/
export function exportVATReportCSV(
calculation: VATCalculationResult,
company: Company
): string {
const data = generateSKATCSV(calculation, company);
const header = 'CVR;Periode;RubrikA;RubrikB;RubrikC;RubrikD;Felt1;Felt2;Felt3;Felt4';
const row = `${data.cvr};${data.periode};${data.rubrikA};${data.rubrikB};${data.rubrikC};${data.rubrikD};${data.felt1};${data.felt2};${data.felt3};${data.felt4}`;
return `${header}\n${row}`;
}
/**
* Export VAT report as XML string
*/
export function exportVATReportXML(
calculation: VATCalculationResult,
company: Company
): string {
const data = generateSKATXML(calculation, company);
return `<?xml version="1.0" encoding="UTF-8"?>
<Momsangivelse version="${data.version}">
<Virksomhed>
<CVR>${data.cvr}</CVR>
<Navn>${company.name}</Navn>
</Virksomhed>
<Periode>
<Start>${data.periodeStart}</Start>
<Slut>${data.periodeSlut}</Slut>
</Periode>
<Angivelse>
<Salgsmoms>${data.angivelse.salgsmoms}</Salgsmoms>
<Koebsmoms>${data.angivelse.koebsmoms}</Koebsmoms>
<EUVarekoebMoms>${data.angivelse.euVarekoebMoms}</EUVarekoebMoms>
<YdelseskoebMoms>${data.angivelse.ydelseskoebMoms}</YdelseskoebMoms>
<SalgMedMoms>${data.angivelse.salgMedMoms}</SalgMedMoms>
<SalgUdenMoms>${data.angivelse.salgUdenMoms}</SalgUdenMoms>
<EUVarekoeb>${data.angivelse.euVarekoeb}</EUVarekoeb>
<Ydelseskoeb>${data.angivelse.ydelseskoeb}</Ydelseskoeb>
</Angivelse>
<Resultat>
<UdgaaendeMoms>${calculation.summary.totalOutputVAT}</UdgaaendeMoms>
<IndgaaendeMoms>${calculation.summary.totalInputVAT}</IndgaaendeMoms>
<NettoMoms>${calculation.summary.netVAT}</NettoMoms>
</Resultat>
</Momsangivelse>`;
}
// =====================================================
// HELPER FUNCTIONS
// =====================================================
/**
* Round to Danish ore (2 decimal places)
*/
function roundToOre(amount: number): number {
return Math.round(amount * 100) / 100;
}
/**
* Format period for SKAT export
*/
function formatPeriodForSKATExport(period: VATReportPeriod): string {
switch (period.periodicitet) {
case 'monthly':
return dayjs(period.startDate).format('YYYYMM');
case 'quarterly':
return `${period.year}Q${period.periodNumber}`;
case 'half-yearly':
return `${period.year}H${period.periodNumber}`;
case 'yearly':
return `${period.year}`;
}
}
/**
* Format VAT period for display
*/
export function formatVATPeriodDisplay(period: VATReportPeriod): string {
const MONTHS = [
'Januar', 'Februar', 'Marts', 'April', 'Maj', 'Juni',
'Juli', 'August', 'September', 'Oktober', 'November', 'December'
];
switch (period.periodicitet) {
case 'monthly':
return `${MONTHS[period.periodNumber - 1]} ${period.year}`;
case 'quarterly':
return `Q${period.periodNumber} ${period.year}`;
case 'half-yearly':
return `H${period.periodNumber} ${period.year}`;
case 'yearly':
return `${period.year}`;
}
}
/**
* Create empty calculated VAT boxes
*/
export function createEmptyCalculatedBoxes(): VATReport['boxes'] {
const createBox = (id: VATBoxId | BasisBoxId): CalculatedVATBox => ({
...SKAT_VAT_BOXES[id],
amount: 0,
transactionCount: 0,
transactionIds: [],
});
return {
A: createBox('A'),
B: createBox('B'),
C: createBox('C'),
D: createBox('D'),
'1': createBox('1'),
'2': createBox('2'),
'3': createBox('3'),
'4': createBox('4'),
};
}
/**
* Calculate days until VAT deadline
*/
export function getDaysUntilDeadline(deadline: string): number {
const today = dayjs();
const deadlineDate = dayjs(deadline);
return deadlineDate.diff(today, 'day');
}
/**
* Check if VAT deadline is approaching (within 14 days)
*/
export function isDeadlineApproaching(deadline: string): boolean {
const days = getDaysUntilDeadline(deadline);
return days >= 0 && days <= 14;
}
/**
* Check if VAT deadline is overdue
*/
export function isDeadlineOverdue(deadline: string): boolean {
return getDaysUntilDeadline(deadline) < 0;
}

View file

@ -0,0 +1,378 @@
// VAT Code Configuration for Danish SKAT Compliance
import type {
VATCode,
VATCodeConfig,
VATPeriodicitetConfig,
SKATVATBox,
VATBoxId,
BasisBoxId,
} from '@/types/vat';
import type { VATPeriodicitet } from '@/types/periods';
/**
* Complete VAT code configuration for Danish bookkeeping
*/
export const VAT_CODE_CONFIG: Record<VATCode, VATCodeConfig> = {
S25: {
code: 'S25',
nameDanish: 'Udgaaende moms 25%',
nameEnglish: 'Output VAT 25%',
rate: 0.25,
type: 'output',
affectsBoxes: {
vatBox: 'A',
basisBox: '1',
},
reverseCharge: false,
deductible: false,
description: 'Moms paa salg af varer og ydelser i Danmark',
},
K25: {
code: 'K25',
nameDanish: 'Indgaaende moms 25%',
nameEnglish: 'Input VAT 25%',
rate: 0.25,
type: 'input',
affectsBoxes: {
vatBox: 'B',
},
reverseCharge: false,
deductible: true,
description: 'Fradragsberettiget moms paa koeb',
},
EU_VARE: {
code: 'EU_VARE',
nameDanish: 'EU-varekoeb (erhvervelsesmoms)',
nameEnglish: 'EU goods purchase (acquisition VAT)',
rate: 0.25,
type: 'reverse_charge',
affectsBoxes: {
vatBox: 'C',
basisBox: '3',
},
reverseCharge: true,
deductible: true, // Both output and input VAT
description: 'Koeb af varer fra andre EU-lande med omvendt betalingspligt',
},
EU_YDELSE: {
code: 'EU_YDELSE',
nameDanish: 'EU-ydelseskoeb (omvendt betalingspligt)',
nameEnglish: 'EU services purchase (reverse charge)',
rate: 0.25,
type: 'reverse_charge',
affectsBoxes: {
vatBox: 'D',
basisBox: '4',
},
reverseCharge: true,
deductible: true,
description: 'Koeb af ydelser fra udlandet med omvendt betalingspligt',
},
MOMSFRI: {
code: 'MOMSFRI',
nameDanish: 'Momsfritaget',
nameEnglish: 'VAT exempt',
rate: 0,
type: 'exempt',
affectsBoxes: {
basisBox: '2',
},
reverseCharge: false,
deductible: false,
description: 'Momsfritaget salg (sundhed, undervisning, mv.)',
},
EKSPORT: {
code: 'EKSPORT',
nameDanish: 'Eksport (0%)',
nameEnglish: 'Export (0%)',
rate: 0,
type: 'exempt',
affectsBoxes: {
basisBox: '2',
},
reverseCharge: false,
deductible: false,
description: 'Eksport til lande uden for EU',
},
NONE: {
code: 'NONE',
nameDanish: 'Ingen moms',
nameEnglish: 'No VAT',
rate: 0,
type: 'none',
affectsBoxes: {},
reverseCharge: false,
deductible: false,
description: 'Transaktioner uden momsrelevans',
},
};
/**
* VAT period configuration based on SKAT requirements
*/
export const VAT_PERIODICITET_CONFIG: Record<VATPeriodicitet, VATPeriodicitetConfig> = {
monthly: {
type: 'monthly',
nameDanish: 'Maanedlig',
nameEnglish: 'Monthly',
deadlineDaysAfterPeriod: 25, // 25th of following month
periodsPerYear: 12,
threshold: { min: 50000000 }, // >50M DKK/year
},
quarterly: {
type: 'quarterly',
nameDanish: 'Kvartalsvis',
nameEnglish: 'Quarterly',
deadlineDaysAfterPeriod: 40, // ~40 days after quarter end (1st of 2nd month)
periodsPerYear: 4,
threshold: { min: 1000000, max: 50000000 }, // 1M-50M DKK/year
},
'half-yearly': {
type: 'half-yearly',
nameDanish: 'Halvaarslig',
nameEnglish: 'Half-yearly',
deadlineDaysAfterPeriod: 60, // ~2 months after period
periodsPerYear: 2,
threshold: { min: 300000, max: 1000000 }, // 300K-1M DKK/year
},
yearly: {
type: 'yearly',
nameDanish: 'Aarslig',
nameEnglish: 'Yearly',
deadlineDaysAfterPeriod: 90, // March 1st for calendar year
periodsPerYear: 1,
threshold: { max: 300000 }, // <300K DKK/year
},
};
/**
* SKAT VAT box definitions
*/
export const SKAT_VAT_BOXES: Record<VATBoxId | BasisBoxId, SKATVATBox> = {
// VAT amounts (Momsbeloeb)
A: {
id: 'A',
type: 'vat',
nameDanish: 'Salgsmoms',
nameEnglish: 'Output VAT (sales)',
description: 'Moms af varer og ydelser solgt i Danmark (25%)',
skippable: false,
isDeductible: false,
},
B: {
id: 'B',
type: 'vat',
nameDanish: 'Koebsmoms',
nameEnglish: 'Input VAT (purchases)',
description: 'Fradragsberettiget moms af koeb',
skippable: false,
isDeductible: true,
},
C: {
id: 'C',
type: 'vat',
nameDanish: 'Moms af EU-varekoeb',
nameEnglish: 'VAT on EU goods purchases',
description: 'Erhvervelsesmoms ved koeb af varer fra andre EU-lande',
skippable: true,
isDeductible: false, // Listed as output, but can be deducted via B
},
D: {
id: 'D',
type: 'vat',
nameDanish: 'Moms af ydelseskoeb fra udland',
nameEnglish: 'VAT on foreign services',
description: 'Moms ved koeb af ydelser fra udlandet med omvendt betalingspligt',
skippable: true,
isDeductible: false,
},
// Basis/turnover amounts (Omsaetning)
'1': {
id: '1',
type: 'basis',
nameDanish: 'Salg med moms',
nameEnglish: 'Sales with VAT',
description: 'Vaerdi af varer og ydelser solgt med dansk moms (momsgrundlag)',
skippable: false,
isDeductible: false,
},
'2': {
id: '2',
type: 'basis',
nameDanish: 'Salg uden moms',
nameEnglish: 'Sales without VAT',
description: 'Momsfrit salg og eksport',
skippable: true,
isDeductible: false,
},
'3': {
id: '3',
type: 'basis',
nameDanish: 'EU-varekoeb',
nameEnglish: 'EU goods purchases',
description: 'Vaerdi af varer koebt fra andre EU-lande',
skippable: true,
isDeductible: false,
},
'4': {
id: '4',
type: 'basis',
nameDanish: 'Ydelseskoeb fra udland',
nameEnglish: 'Foreign services purchases',
description: 'Vaerdi af ydelser koebt fra udlandet',
skippable: true,
isDeductible: false,
},
};
/**
* Default VAT accounts for automatic double-entry
*/
export const VAT_ACCOUNTS = {
inputVAT: '5610', // Indgaaende moms (fradrag)
outputVAT: '5710', // Udgaaende moms (skyld)
euVAT: '5620', // EU-moms (erhvervelsesmoms)
} as const;
// =====================================================
// HELPER FUNCTIONS
// =====================================================
/**
* Valid VAT code values for validation
*/
export const VALID_VAT_CODES: readonly VATCode[] = [
'S25', 'K25', 'EU_VARE', 'EU_YDELSE', 'MOMSFRI', 'EKSPORT', 'NONE'
] as const;
/**
* Check if a value is a valid VAT code
*/
export function isValidVATCode(code: unknown): code is VATCode {
return typeof code === 'string' && VALID_VAT_CODES.includes(code as VATCode);
}
/**
* Safely convert a string to VATCode, returns 'NONE' if invalid
*/
export function toVATCode(code: unknown): VATCode {
return isValidVATCode(code) ? code : 'NONE';
}
/**
* Get VAT code configuration
*/
export function getVATCodeConfig(code: VATCode): VATCodeConfig {
return VAT_CODE_CONFIG[code];
}
/**
* Get all VAT codes for a dropdown
*/
export function getVATCodeOptions(): Array<{ value: VATCode; label: string; description: string }> {
return Object.values(VAT_CODE_CONFIG).map((config) => ({
value: config.code,
label: `${config.code} - ${config.nameDanish}`,
description: config.description,
}));
}
/**
* Get VAT codes for expense transactions (input VAT)
*/
export function getExpenseVATCodeOptions(): Array<{ value: VATCode; label: string }> {
return Object.values(VAT_CODE_CONFIG)
.filter((config) => config.type === 'input' || config.type === 'reverse_charge' || config.type === 'none')
.map((config) => ({
value: config.code,
label: `${config.code} - ${config.nameDanish}`,
}));
}
/**
* Get VAT codes for income transactions (output VAT)
*/
export function getIncomeVATCodeOptions(): Array<{ value: VATCode; label: string }> {
return Object.values(VAT_CODE_CONFIG)
.filter((config) => config.type === 'output' || config.type === 'exempt' || config.type === 'none')
.map((config) => ({
value: config.code,
label: `${config.code} - ${config.nameDanish}`,
}));
}
/**
* Get period options for a dropdown
*/
export function getPeriodicitetOptions(): Array<{ value: VATPeriodicitet; label: string; description: string }> {
return Object.values(VAT_PERIODICITET_CONFIG).map((config) => ({
value: config.type,
label: config.nameDanish,
description: config.threshold?.min
? `Omsaetning over ${(config.threshold.min / 1000000).toFixed(0)}M DKK`
: config.threshold?.max
? `Omsaetning under ${(config.threshold.max / 1000000).toFixed(1)}M DKK`
: 'Standard',
}));
}
/**
* Get SKAT box definition
*/
export function getSKATBox(boxId: VATBoxId | BasisBoxId): SKATVATBox {
return SKAT_VAT_BOXES[boxId];
}
/**
* Check if a VAT code is deductible (affects input VAT)
*/
export function isVATDeductible(code: VATCode): boolean {
return VAT_CODE_CONFIG[code].deductible;
}
/**
* Check if a VAT code is reverse charge
*/
export function isReverseCharge(code: VATCode): boolean {
return VAT_CODE_CONFIG[code].reverseCharge;
}
/**
* Get VAT rate for a code
*/
export function getVATRate(code: VATCode): number {
return VAT_CODE_CONFIG[code].rate;
}
/**
* Calculate VAT from gross amount
* Note: Rounds net first, then calculates VAT as gross - net
* This ensures net + vat = gross (no floating point drift)
*/
export function calculateVATFromGross(grossAmount: number, rate: number): { net: number; vat: number } {
if (rate === 0) {
return { net: grossAmount, vat: 0 };
}
const rawNet = grossAmount / (1 + rate);
const net = Math.round(rawNet * 100) / 100;
// Calculate VAT as difference to guarantee gross = net + vat
const vat = Math.round((grossAmount - net) * 100) / 100;
return { net, vat };
}
/**
* Calculate VAT from net amount
* Note: Rounds VAT first, then calculates gross as net + vat
* This ensures net + vat = gross (no floating point drift)
*/
export function calculateVATFromNet(netAmount: number, rate: number): { gross: number; vat: number } {
if (rate === 0) {
return { gross: netAmount, vat: 0 };
}
const rawVat = netAmount * rate;
const vat = Math.round(rawVat * 100) / 100;
// Calculate gross as sum to guarantee net + vat = gross
const gross = Math.round((netAmount + vat) * 100) / 100;
return { gross, vat };
}

25
frontend/src/main.tsx Normal file
View file

@ -0,0 +1,25 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { QueryClientProvider } from '@tanstack/react-query';
import { ConfigProvider } from 'antd';
import daDK from 'antd/locale/da_DK';
import dayjs from 'dayjs';
import 'dayjs/locale/da';
import App from './App';
import { queryClient } from './api/client';
import { theme } from './styles/theme';
import './styles/global.css';
// Set dayjs locale globally
dayjs.locale('da');
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<ConfigProvider theme={theme} locale={daDK}>
<App />
</ConfigProvider>
</QueryClientProvider>
</React.StrictMode>
);

Some files were not shown because too many files have changed in this diff Show more