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:
commit
66f6fa138d
126 changed files with 24741 additions and 0 deletions
204
.gitignore
vendored
Normal file
204
.gitignore
vendored
Normal 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
78
README.md
Normal 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.
|
||||
1602
account-suggestions/accounts-with-suggestions.json
Normal file
1602
account-suggestions/accounts-with-suggestions.json
Normal file
File diff suppressed because it is too large
Load diff
1
account-suggestions/accounts.json
Normal file
1
account-suggestions/accounts.json
Normal file
File diff suppressed because one or more lines are too long
122
account-suggestions/accounts.toon
Normal file
122
account-suggestions/accounts.toon
Normal 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
|
||||
6
account-suggestions/fetch-accounts.sh
Executable file
6
account-suggestions/fetch-accounts.sh
Executable file
File diff suppressed because one or more lines are too long
199
account-suggestions/process-accounts.js
Normal file
199
account-suggestions/process-accounts.js
Normal 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');
|
||||
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
Binary file not shown.
BIN
account-suggestions/test-invoices/Invoice-44534E99-0004.pdf
Normal file
BIN
account-suggestions/test-invoices/Invoice-44534E99-0004.pdf
Normal file
Binary file not shown.
BIN
account-suggestions/test-invoices/Invoice-MVPAIAKP-0002.pdf
Normal file
BIN
account-suggestions/test-invoices/Invoice-MVPAIAKP-0002.pdf
Normal file
Binary file not shown.
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
BIN
account-suggestions/test-invoices/Microsoft E1 - E0800QGJD1.pdf
Normal file
BIN
account-suggestions/test-invoices/Microsoft E1 - E0800QGJD1.pdf
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -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": []
|
||||
}
|
||||
BIN
account-suggestions/test-invoices/fibia_receipt_72147924.pdf
Normal file
BIN
account-suggestions/test-invoices/fibia_receipt_72147924.pdf
Normal file
Binary file not shown.
BIN
account-suggestions/test-invoices/invoice (4).pdf
Normal file
BIN
account-suggestions/test-invoices/invoice (4).pdf
Normal file
Binary file not shown.
BIN
account-suggestions/test-invoices/invoice-F175490.pdf
Normal file
BIN
account-suggestions/test-invoices/invoice-F175490.pdf
Normal file
Binary file not shown.
BIN
account-suggestions/test-invoices/invoice-F191023.pdf
Normal file
BIN
account-suggestions/test-invoices/invoice-F191023.pdf
Normal file
Binary file not shown.
BIN
account-suggestions/test-invoices/parking_receipt_2582441883.pdf
Normal file
BIN
account-suggestions/test-invoices/parking_receipt_2582441883.pdf
Normal file
Binary file not shown.
BIN
account-suggestions/test-invoices/receipt.png
Normal file
BIN
account-suggestions/test-invoices/receipt.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 497 KiB |
355
backend/BACKEND_REQUIREMENTS.md
Normal file
355
backend/BACKEND_REQUIREMENTS.md
Normal 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*
|
||||
37
backend/Books.Api.Tests/Books.Api.Tests.csproj
Normal file
37
backend/Books.Api.Tests/Books.Api.Tests.csproj
Normal 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>
|
||||
260
backend/Books.Api.Tests/GraphQL/CompanyGraphQLTests.cs
Normal file
260
backend/Books.Api.Tests/GraphQL/CompanyGraphQLTests.cs
Normal 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; }
|
||||
}
|
||||
}
|
||||
89
backend/Books.Api.Tests/Helpers/Eventually.cs
Normal file
89
backend/Books.Api.Tests/Helpers/Eventually.cs
Normal 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}.");
|
||||
}
|
||||
}
|
||||
125
backend/Books.Api.Tests/Helpers/GraphQLTestClient.cs
Normal file
125
backend/Books.Api.Tests/Helpers/GraphQLTestClient.cs
Normal 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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
121
backend/Books.Api.Tests/Infrastructure/TestDatabase.cs
Normal file
121
backend/Books.Api.Tests/Infrastructure/TestDatabase.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
10
backend/Books.Api.Tests/UnitTest1.cs
Normal file
10
backend/Books.Api.Tests/UnitTest1.cs
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
namespace Books.Api.Tests;
|
||||
|
||||
public class UnitTest1
|
||||
{
|
||||
[Fact]
|
||||
public void Test1()
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
5
backend/Books.Api.Tests/xunit.runner.json
Normal file
5
backend/Books.Api.Tests/xunit.runner.json
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"$schema": "https://xunit.net/schema/current/xunit.runner.schema.json",
|
||||
"parallelizeAssembly": false,
|
||||
"parallelizeTestCollections": false
|
||||
}
|
||||
46
backend/Books.Api/Books.Api.csproj
Normal file
46
backend/Books.Api/Books.Api.csproj
Normal 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>
|
||||
6
backend/Books.Api/Books.Api.http
Normal file
6
backend/Books.Api/Books.Api.http
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
@Books.Api_HostAddress = http://localhost:5142
|
||||
|
||||
GET {{Books.Api_HostAddress}}/weatherforecast/
|
||||
Accept: application/json
|
||||
|
||||
###
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
56
backend/Books.Api/Commands/Companies/CompanyCommands.cs
Normal file
56
backend/Books.Api/Commands/Companies/CompanyCommands.cs
Normal 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;
|
||||
}
|
||||
25
backend/Books.Api/Controllers/WeatherForecastController.cs
Normal file
25
backend/Books.Api/Controllers/WeatherForecastController.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
110
backend/Books.Api/Database/Migrations/001_Initial.sql
Normal file
110
backend/Books.Api/Database/Migrations/001_Initial.sql
Normal 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);
|
||||
81
backend/Books.Api/Domain/Companies/CompanyAggregate.cs
Normal file
81
backend/Books.Api/Domain/Companies/CompanyAggregate.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
8
backend/Books.Api/Domain/Companies/CompanyId.cs
Normal file
8
backend/Books.Api/Domain/Companies/CompanyId.cs
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
using EventFlow.Core;
|
||||
|
||||
namespace Books.Api.Domain.Companies;
|
||||
|
||||
public class CompanyId : Identity<CompanyId>
|
||||
{
|
||||
public CompanyId(string value) : base(value) { }
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
20
backend/Books.Api/Domain/DomainException.cs
Normal file
20
backend/Books.Api/Domain/DomainException.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
0
backend/Books.Api/Endpoints/ApiEndpoints.cs
Normal file
0
backend/Books.Api/Endpoints/ApiEndpoints.cs
Normal file
31
backend/Books.Api/EventFlow/Customs/Casing.cs
Normal file
31
backend/Books.Api/EventFlow/Customs/Casing.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
221
backend/Books.Api/EventFlow/Customs/ReadModelSqlGenerator.cs
Normal file
221
backend/Books.Api/EventFlow/Customs/ReadModelSqlGenerator.cs
Normal 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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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}\"");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
80
backend/Books.Api/EventFlow/ReadModels/CompanyReadModel.cs
Normal file
80
backend/Books.Api/EventFlow/ReadModels/CompanyReadModel.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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; }
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
14
backend/Books.Api/GraphQL/BooksSchema.cs
Normal file
14
backend/Books.Api/GraphQL/BooksSchema.cs
Normal 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>();
|
||||
}
|
||||
}
|
||||
|
|
@ -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; }
|
||||
}
|
||||
|
|
@ -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; }
|
||||
}
|
||||
82
backend/Books.Api/GraphQL/Mutations/BooksMutation.cs
Normal file
82
backend/Books.Api/GraphQL/Mutations/BooksMutation.cs
Normal 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
37
backend/Books.Api/GraphQL/Queries/BooksQuery.cs
Normal file
37
backend/Books.Api/GraphQL/Queries/BooksQuery.cs
Normal 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();
|
||||
});
|
||||
}
|
||||
}
|
||||
31
backend/Books.Api/GraphQL/Types/CompanyType.cs
Normal file
31
backend/Books.Api/GraphQL/Types/CompanyType.cs
Normal 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
|
||||
}
|
||||
}
|
||||
27
backend/Books.Api/Infrastructure/HangfireScheduler.cs
Normal file
27
backend/Books.Api/Infrastructure/HangfireScheduler.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
11
backend/Books.Api/Infrastructure/IScheduler.cs
Normal file
11
backend/Books.Api/Infrastructure/IScheduler.cs
Normal 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);
|
||||
}
|
||||
13
backend/Books.Api/Logging/ServiceCollectionExtensions.cs
Normal file
13
backend/Books.Api/Logging/ServiceCollectionExtensions.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
35
backend/Books.Api/Program.cs
Normal file
35
backend/Books.Api/Program.cs
Normal 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();
|
||||
23
backend/Books.Api/Properties/launchSettings.json
Normal file
23
backend/Books.Api/Properties/launchSettings.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
77
backend/Books.Api/Startup.cs
Normal file
77
backend/Books.Api/Startup.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
12
backend/Books.Api/WeatherForecast.cs
Normal file
12
backend/Books.Api/WeatherForecast.cs
Normal 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; }
|
||||
}
|
||||
14
backend/Books.Api/appsettings.json
Normal file
14
backend/Books.Api/appsettings.json
Normal 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
11
backend/Books.slnx
Normal 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
13
frontend/index.html
Normal 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
6459
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
40
frontend/package.json
Normal file
40
frontend/package.json
Normal 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
18
frontend/src/App.tsx
Normal 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;
|
||||
73
frontend/src/api/client.ts
Normal file
73
frontend/src/api/client.ts
Normal 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;
|
||||
}
|
||||
40
frontend/src/components/layout/AppLayout.tsx
Normal file
40
frontend/src/components/layout/AppLayout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
94
frontend/src/components/layout/CompanySwitcher.tsx
Normal file
94
frontend/src/components/layout/CompanySwitcher.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
242
frontend/src/components/layout/FiscalYearSelector.tsx
Normal file
242
frontend/src/components/layout/FiscalYearSelector.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
113
frontend/src/components/layout/Header.tsx
Normal file
113
frontend/src/components/layout/Header.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
135
frontend/src/components/layout/Sidebar.tsx
Normal file
135
frontend/src/components/layout/Sidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
521
frontend/src/components/modals/CloseFiscalYearWizard.tsx
Normal file
521
frontend/src/components/modals/CloseFiscalYearWizard.tsx
Normal 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 på <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 på <strong>{formatCurrency(summary.netResult)}</strong> vil blive
|
||||
bogført som {summary.netResult >= 0 ? 'kredit' : 'debet'} på 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>
|
||||
);
|
||||
}
|
||||
308
frontend/src/components/modals/CreateFiscalYearModal.tsx
Normal file
308
frontend/src/components/modals/CreateFiscalYearModal.tsx
Normal 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 på dette.
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
220
frontend/src/components/simple-booking/AccountQuickPicker.tsx
Normal file
220
frontend/src/components/simple-booking/AccountQuickPicker.tsx
Normal 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;
|
||||
165
frontend/src/components/simple-booking/BankTransactionCard.tsx
Normal file
165
frontend/src/components/simple-booking/BankTransactionCard.tsx
Normal 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;
|
||||
298
frontend/src/components/simple-booking/QuickBookModal.tsx
Normal file
298
frontend/src/components/simple-booking/QuickBookModal.tsx
Normal 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;
|
||||
472
frontend/src/components/simple-booking/SplitBookModal.tsx
Normal file
472
frontend/src/components/simple-booking/SplitBookModal.tsx
Normal 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;
|
||||
6
frontend/src/components/simple-booking/index.ts
Normal file
6
frontend/src/components/simple-booking/index.ts
Normal 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';
|
||||
302
frontend/src/components/tables/DataTable.tsx
Normal file
302
frontend/src/components/tables/DataTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
70
frontend/src/hooks/useCompany.ts
Normal file
70
frontend/src/hooks/useCompany.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
176
frontend/src/hooks/useDataTable.ts
Normal file
176
frontend/src/hooks/useDataTable.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
345
frontend/src/hooks/usePeriod.ts
Normal file
345
frontend/src/hooks/usePeriod.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
586
frontend/src/lib/accounting.ts
Normal file
586
frontend/src/lib/accounting.ts
Normal 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
|
||||
);
|
||||
}
|
||||
611
frontend/src/lib/fiscalYear.ts
Normal file
611
frontend/src/lib/fiscalYear.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
167
frontend/src/lib/formatters.ts
Normal file
167
frontend/src/lib/formatters.ts
Normal 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
541
frontend/src/lib/periods.ts
Normal 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'))
|
||||
);
|
||||
}
|
||||
419
frontend/src/lib/vatCalculation.ts
Normal file
419
frontend/src/lib/vatCalculation.ts
Normal 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;
|
||||
}
|
||||
378
frontend/src/lib/vatCodes.ts
Normal file
378
frontend/src/lib/vatCodes.ts
Normal 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
25
frontend/src/main.tsx
Normal 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
Loading…
Add table
Add a link
Reference in a new issue