feat: document inbox + fix MCP pending operations user_id (#172)
* fix: always use business PSU type for bank connections EF (sole trader) users connecting to Nordea got personal accounts because psu_type was set to 'personal' based on entity_type. Since gnubok is accounting software, all bank connections should use 'business' PSU type regardless of entity type. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: document inbox extension with AI classification Add invoice-inbox extension for email-based document processing with AI-powered classification, supplier matching, and inbox management. Includes MCP tools for document upload/listing, migration, and supporting changes across document service, API keys, and banking. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: disable invoice-inbox extension, use dynamic import in MCP server Keep invoice-inbox out of extensions.config.json until ready for production. MCP server now dynamically imports classifyDocument to avoid breaking when the extension is disabled. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: correct file size error message in invoice-inbox upload Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
1dcec370b6
commit
c4a6d16e94
@@ -0,0 +1,95 @@
|
||||
---
|
||||
name: swedish-accounting-compliance
|
||||
description: Swedish accounting law and compliance reference for developers building accounting software. Use this skill whenever working on features that touch Swedish bookkeeping rules, tax compliance, chart of accounts, financial reporting, or regulatory requirements. Triggers include any mention of BFL, BFNAR, BAS kontoplan, SIE4, K2/K3, momsdeklaration, Skatteverket integration, verifikationer, löpande bokföring, årsbokslut, årsredovisning, bokföringsskyldighet, or Swedish accounting compliance in general. Also trigger when the user is checking whether a feature, data model, or workflow complies with Swedish accounting law, or when implementing tax calculations, invoice requirements, or financial reporting for Swedish entities. Use this skill even if the user just asks "is this compliant?" or "what does the law say about X?" in a Swedish accounting context. This skill is a compliance oracle, not an end-user guide.
|
||||
---
|
||||
|
||||
# Swedish Accounting Compliance
|
||||
|
||||
Developer-facing compliance reference for building Swedish accounting software. This skill answers questions about what the law requires so you can verify your implementation is correct.
|
||||
|
||||
## How to use this skill
|
||||
|
||||
This skill has a router structure. The SKILL.md contains the most critical rules you need constantly. Detailed reference material lives in `references/`. Read the relevant reference file when you need depth on a specific area.
|
||||
|
||||
### Reference files
|
||||
|
||||
| File | When to read |
|
||||
|---|---|
|
||||
| `references/bfl-bfnar.md` | Questions about bokföringslagen (BFL), BFNAR, K1/K2/K3, ÅRL, bokföringsskyldighet, verifikationer, arkivering, räkenskapsår, systemdokumentation |
|
||||
| `references/skatteverket.md` | Questions about moms/VAT, arbetsgivaravgifter, skattedeklaration, F-skatt, skattekonto, Skatteverket API integration, AGI |
|
||||
| `references/bas-kontoplan.md` | Questions about BAS chart of accounts, account numbering, account classification, mapping transactions to accounts |
|
||||
| `references/sie4.md` | Questions about SIE file format, import/export, data exchange between systems |
|
||||
| `references/changes-2025-2026.md` | Questions about recent or upcoming regulatory changes, new rules, updated amounts/thresholds |
|
||||
|
||||
Read multiple reference files when a question spans domains (common).
|
||||
|
||||
## Core principles (always in context)
|
||||
|
||||
### Bokföringsskyldighet (BFL 2 kap)
|
||||
Every aktiebolag, handelsbolag, and ekonomisk förening is bokföringsskyldigt. Enskild firma with fysisk person is bokföringsskyldig. The obligation cannot be delegated: even if someone else does the bokföring, the företagare is legally responsible.
|
||||
|
||||
### Löpande bokföring (BFL 5 kap)
|
||||
- Affärshändelser shall be bokförda in both grundbok (journal) and huvudbok (ledger)
|
||||
- Kontanta in/utbetalningar: senast nästa arbetsdag
|
||||
- Övriga affärshändelser: so snart det kan ske, which in practice means within the calendar month following the month the event occurred
|
||||
- Every affärshändelse requires a verifikation
|
||||
|
||||
### Verifikationer (BFL 5 kap 6-7§)
|
||||
A verifikation must contain:
|
||||
1. Datum för affärshändelsen
|
||||
2. Datum för verifikationen (if different)
|
||||
3. Vad affärshändelsen avser (description)
|
||||
4. Belopp
|
||||
5. Motpart (when applicable)
|
||||
6. References to underlag (kvitto, faktura etc.)
|
||||
7. Verifikationsnummer (unique, in unbroken series per räkenskapsår)
|
||||
|
||||
Verifikationer must be numbered in a systematisk serie without gaps. If a verifikation is corrected, the original must be preserved and the correction linked.
|
||||
|
||||
### Rättelse (BFL 5 kap 5§)
|
||||
A rättelse of a bokföringspost must be documented so that both the original and the corrected post are visible. You cannot simply overwrite. Implement as: new correcting verifikation referencing the original.
|
||||
|
||||
### Arkivering (BFL 7 kap)
|
||||
- Räkenskapsinformation must be preserved for 7 years after the end of the calendar year the räkenskapsår ended
|
||||
- Since 1 July 2024: no requirement to keep paper originals after digitization (BFL 7 kap 6§ updated)
|
||||
- Digital storage must ensure the information cannot be altered (immutability requirement)
|
||||
- Must be accessible in Sweden (or within EU/EEA with Skatteverket notification)
|
||||
|
||||
### Momssatser (current as of 2026)
|
||||
- 25% - standard rate (most goods and services)
|
||||
- 12% - food/restaurants, hotels, some cultural events
|
||||
- 6% - books, newspapers, public transport, cultural/sports events, livsmedel (temporarily from 1 April 2026 to 31 Dec 2027)
|
||||
- 0% - certain financial services, healthcare, education, insurance
|
||||
|
||||
**IMPORTANT**: From 1 April 2026, livsmedel drops from 12% to 6% (tillfälligt, Prop. 2025/26:55). Restaurang/servering stays at 12%. Software must handle the transition date and the eventual reversion.
|
||||
|
||||
### Fakturakrav (ML 17 kap)
|
||||
A momsregistrerad seller's faktura must contain:
|
||||
1. Utfärdandedatum
|
||||
2. Löpnummer (unique, unbroken series)
|
||||
3. Säljarens momsregistreringsnummer
|
||||
4. Köparens momsregistreringsnummer (if reverse charge or EU)
|
||||
5. Säljarens och köparens namn och adress
|
||||
6. Varans/tjänstens art, omfattning, mängd
|
||||
7. Datum för leverans/tillhandahållande
|
||||
8. Beskattningsunderlag per skattesats
|
||||
9. Tillämpad skattesats
|
||||
10. Momsbelopp
|
||||
11. Eventuell hänvisning till undantag
|
||||
|
||||
Förenklad faktura (max 4000 SEK inkl moms) has reduced requirements.
|
||||
|
||||
### Key thresholds (2026)
|
||||
- Prisbasbelopp: 59 200 kr
|
||||
- Inkomstbasbelopp: 83 400 kr
|
||||
- Inventarier av mindre värde: halvt prisbasbelopp = 29 600 kr (exkl moms)
|
||||
- Förenklat årsbokslut: omsättning normalt < 3 MSEK
|
||||
- Kontantmetod: omsättning normalt < 3 MSEK
|
||||
- Revisionspliktig (AB): minst 2 av 3: >3 anställda, >1.5 MSEK balansomslutning, >3 MSEK nettoomsättning (two consecutive years)
|
||||
|
||||
### System documentation (BFNAR 2013:2 kap 8)
|
||||
Bokföringssystem must have:
|
||||
1. Systemdokumentation: describes the system, how it works, its controls
|
||||
2. Behandlingshistorik: log of changes, who did what, when
|
||||
|
||||
Your software must produce or support both. This is not optional.
|
||||
@@ -0,0 +1,233 @@
|
||||
# BAS Kontoplan Reference
|
||||
|
||||
The BAS kontoplan is the de facto standard chart of accounts for Swedish companies. Published by BAS-intressenternas förening. Not legally mandated, but universally used and expected by auditors, Skatteverket, and accounting systems.
|
||||
|
||||
## Table of Contents
|
||||
1. Structure overview
|
||||
2. Account classes (1-8)
|
||||
3. Common accounts for SMEs/startups
|
||||
4. Moms accounts
|
||||
5. Mapping rules and principles
|
||||
6. BAS account numbering conventions
|
||||
|
||||
---
|
||||
|
||||
## 1. Structure overview
|
||||
|
||||
BAS uses a 4-digit account numbering system organized into 8 classes:
|
||||
|
||||
| Class | Range | Name | Type |
|
||||
|---|---|---|---|
|
||||
| 1 | 1000-1999 | Tillgångar | Balance sheet (debit) |
|
||||
| 2 | 2000-2999 | Eget kapital och skulder | Balance sheet (credit) |
|
||||
| 3 | 3000-3999 | Rörelsens intäkter | Income statement (credit) |
|
||||
| 4 | 4000-4999 | Kostnader för varor och material | Income statement (debit) |
|
||||
| 5 | 5000-5999 | Övriga externa kostnader | Income statement (debit) |
|
||||
| 6 | 6000-6999 | Övriga externa kostnader (cont.) | Income statement (debit) |
|
||||
| 7 | 7000-7999 | Personal, avskrivningar, nedskrivningar | Income statement (debit) |
|
||||
| 8 | 8000-8999 | Finansiella poster, bokslutsdispositioner, skatt | Income statement |
|
||||
|
||||
## 2. Account classes detail
|
||||
|
||||
### Klass 1: Tillgångar
|
||||
- **10xx**: Immateriella anläggningstillgångar (patents, goodwill)
|
||||
- **11xx**: Byggnader och mark
|
||||
- **12xx**: Maskiner och inventarier
|
||||
- **13xx**: Finansiella anläggningstillgångar (aktier, långfristiga fordringar)
|
||||
- **14xx**: Varulager
|
||||
- **15xx**: Kundfordringar
|
||||
- **16xx**: Övriga kortfristiga fordringar (momsfordran, förskott)
|
||||
- **17xx**: Förutbetalda kostnader och upplupna intäkter
|
||||
- **18xx**: Kortfristiga placeringar
|
||||
- **19xx**: Kassa och bank
|
||||
|
||||
### Klass 2: Eget kapital och skulder
|
||||
- **20xx**: Eget kapital (aktiekapital, balanserat resultat, årets resultat)
|
||||
- **21xx**: Obeskattade reserver
|
||||
- **22xx**: Avsättningar
|
||||
- **23xx**: Långfristiga skulder (banklån)
|
||||
- **24xx**: Kortfristiga skulder till kreditinstitut
|
||||
- **25xx**: Skatteskulder
|
||||
- **26xx**: Momsskulder (utgående/ingående moms)
|
||||
- **27xx**: Personalens skatter och avgifter
|
||||
- **28xx**: Övriga kortfristiga skulder
|
||||
- **29xx**: Upplupna kostnader och förutbetalda intäkter
|
||||
|
||||
### Klass 3: Rörelsens intäkter
|
||||
- **30xx**: Huvudintäkter (försäljning varor/tjänster)
|
||||
- **31xx-34xx**: Further breakdown of sales by type
|
||||
- **35xx**: Fakturerade kostnader
|
||||
- **36xx**: Rörelsens sidointäkter
|
||||
- **37xx**: Intäktskorrigeringar (rabatter, returer, kursvinster)
|
||||
- **38xx**: Aktiverat arbete för egen räkning
|
||||
- **39xx**: Övriga rörelseintäkter
|
||||
|
||||
### Klass 4: Material och varor
|
||||
- **40xx**: Inköp av varor och material
|
||||
- **41xx-43xx**: Inköp by category
|
||||
- **44xx**: Förändring av varulager
|
||||
- **45xx**: Övriga inköpskostnader (frakt, tull)
|
||||
- **46xx**: Legoarbeten och underentreprenader
|
||||
- **47xx-49xx**: Reduktioner, rabatter
|
||||
|
||||
### Klass 5-6: Övriga externa kostnader
|
||||
- **50xx**: Lokalkostnader (hyra, el, värme)
|
||||
- **51xx**: Fastighetskostnader
|
||||
- **52xx**: Hyra av anläggningstillgångar
|
||||
- **54xx**: Förbrukningsinventarier
|
||||
- **55xx**: Reparation och underhåll
|
||||
- **56xx**: Transportkostnader
|
||||
- **57xx**: Frakt och transporter
|
||||
- **58xx**: Resekostnader
|
||||
- **59xx**: Reklam och PR
|
||||
- **60xx**: Övriga försäljningskostnader
|
||||
- **61xx**: Kontorsmaterial
|
||||
- **62xx**: Tele och post
|
||||
- **63xx**: Företagsförsäkringar
|
||||
- **64xx**: Förvaltningskostnader
|
||||
- **65xx**: Övriga externa tjänster
|
||||
- **68xx**: Inhyrd personal
|
||||
- **69xx**: Övriga externa kostnader
|
||||
|
||||
### Klass 7: Personal, avskrivningar
|
||||
- **70xx**: Löner till kollektivanställda
|
||||
- **71xx**: Fri lön?
|
||||
- **72xx**: Löner till tjänstemän och företagsledare
|
||||
- **73xx**: Kostnadsersättningar (traktamenten, bilersättning)
|
||||
- **74xx**: Pensionskostnader
|
||||
- **75xx**: Sociala avgifter (arbetsgivaravgifter)
|
||||
- **76xx**: Övriga personalkostnader (utbildning, friskvård)
|
||||
- **77xx**: Avskrivningar
|
||||
- **78xx**: Nedskrivningar
|
||||
- **79xx**: Poster av engångskaraktär
|
||||
|
||||
### Klass 8: Finansiella poster, bokslutsdispositioner
|
||||
- **80xx**: Resultat från andelar i koncernföretag
|
||||
- **81xx**: Resultat från andelar i intresseföretag
|
||||
- **82xx**: Resultat från övriga värdepapper
|
||||
- **83xx**: Ränteintäkter
|
||||
- **84xx**: Räntekostnader
|
||||
- **85xx-86xx**: Övriga finansiella poster
|
||||
- **87xx**: Extraordinära poster
|
||||
- **88xx**: Bokslutsdispositioner (överavskrivningar, periodiseringsfonder)
|
||||
- **89xx**: Skatter (inkomstskatt, årets skatt)
|
||||
- **8999**: Årets resultat
|
||||
|
||||
## 3. Common accounts for SMEs/startups
|
||||
|
||||
Most small AB/enskild firma need these accounts at minimum:
|
||||
|
||||
**Tillgångar:**
|
||||
- 1510 Kundfordringar
|
||||
- 1630 Skattekonto (avräkning Skatteverket)
|
||||
- 1710 Förutbetalda hyreskostnader
|
||||
- 1910 Kassa
|
||||
- 1920 PlusGiro
|
||||
- 1930 Företagskonto bank
|
||||
- 1940 Sparkonto bank
|
||||
|
||||
**Skulder & EK:**
|
||||
- 2010 Eget kapital (enskild firma) or 2081 Aktiekapital
|
||||
- 2091 Balanserad vinst/förlust
|
||||
- 2099 Årets resultat
|
||||
- 2440 Leverantörsskulder
|
||||
- 2610 Utgående moms 25%
|
||||
- 2611 Utgående moms 12%
|
||||
- 2612 Utgående moms 6%
|
||||
- 2640 Ingående moms
|
||||
- 2650 Redovisningskonto för moms
|
||||
- 2710 Personalskatt
|
||||
- 2730 Arbetsgivaravgifter skuld
|
||||
- 2920 Upplupna semesterlöner
|
||||
- 2990 Övriga upplupna kostnader
|
||||
|
||||
**Intäkter:**
|
||||
- 3000 or 3010 Försäljning tjänster (or varor)
|
||||
- 3740 Öres- och kronutjämning
|
||||
|
||||
**Kostnader:**
|
||||
- 4010 Inköp varor/material
|
||||
- 5010 Lokalhyra
|
||||
- 5410 Förbrukningsinventarier
|
||||
- 6110 Kontorsmaterial
|
||||
- 6212 Mobiltelefoni
|
||||
- 6230 Datakommunikation
|
||||
- 6250 Postbefordran
|
||||
- 6530 Redovisningstjänster
|
||||
- 6540 IT-tjänster
|
||||
- 6570 Bankkostnader
|
||||
- 7010 or 7210 Löner
|
||||
- 7510 Arbetsgivaravgifter
|
||||
- 7832 Avskrivning inventarier
|
||||
|
||||
**Finansiellt:**
|
||||
- 8310 Ränteintäkter
|
||||
- 8410 Räntekostnader
|
||||
- 8910 Skatt
|
||||
|
||||
## 4. Moms accounts
|
||||
|
||||
Standard moms account structure in BAS:
|
||||
|
||||
| Konto | Beskrivning |
|
||||
|---|---|
|
||||
| 2610 | Utgående moms 25% |
|
||||
| 2611 | Utgående moms 12% |
|
||||
| 2612 | Utgående moms 6% |
|
||||
| 2614 | Utgående moms omvänd skattskyldighet |
|
||||
| 2615 | Utgående moms import |
|
||||
| 2640 | Ingående moms |
|
||||
| 2645 | Beräknad ingående moms vid förvärv EU |
|
||||
| 2650 | Redovisningskonto för moms |
|
||||
|
||||
Workflow:
|
||||
1. During the period: book utgående on 2610/2611/2612 and ingående on 2640
|
||||
2. At declaration: netta 2610+2611+2612-2640 against 2650
|
||||
3. Payment to/from Skatteverket: 2650 <-> 1630 (skattekonto)
|
||||
|
||||
**From 1 Apr 2026**: livsmedel moves from 2611 (12%) to 2612 (6%). Your system must handle the transition correctly based on leveransdatum.
|
||||
|
||||
## 5. Mapping rules and principles
|
||||
|
||||
### Debit and credit conventions
|
||||
- Tillgångar (klass 1): increase = debit, decrease = credit
|
||||
- Skulder & EK (klass 2): increase = credit, decrease = debit
|
||||
- Intäkter (klass 3): increase = credit (booking revenue)
|
||||
- Kostnader (klass 4-7): increase = debit
|
||||
- Finansiella poster (klass 8): depends on type
|
||||
|
||||
### Standard transaction patterns
|
||||
|
||||
**Kundfaktura:**
|
||||
- Debit 1510 (kundfordringar) full amount inkl moms
|
||||
- Credit 30xx (intäkt) exkl moms
|
||||
- Credit 2610/2611/2612 (utgående moms)
|
||||
|
||||
**Leverantörsfaktura:**
|
||||
- Debit 4xxx/5xxx/6xxx (kostnad) exkl moms
|
||||
- Debit 2640 (ingående moms)
|
||||
- Credit 2440 (leverantörsskulder) full amount inkl moms
|
||||
|
||||
**Löneutbetalning:**
|
||||
- Debit 7210 (lön) brutto
|
||||
- Credit 2710 (personalskatt)
|
||||
- Credit 1930 (bank) nettolön
|
||||
Then separately:
|
||||
- Debit 7510 (arbetsgivaravgifter)
|
||||
- Credit 2730 (arbetsgivaravgifter skuld)
|
||||
|
||||
**Momsredovisning (monthly/quarterly):**
|
||||
- Debit 2610 (tömma utgående 25%)
|
||||
- Debit 2611 (tömma utgående 12%)
|
||||
- Debit 2612 (tömma utgående 6%)
|
||||
- Credit 2640 (tömma ingående)
|
||||
- Credit/Debit 2650 (netto: skuld if credit, fordran if debit)
|
||||
|
||||
## 6. BAS numbering conventions
|
||||
|
||||
- 4 digits is standard
|
||||
- Companies can add sub-accounts using 5+ digits for internal reporting (e.g., 3011 for product line A, 3012 for product line B)
|
||||
- The first digit determines the class
|
||||
- The second digit typically groups related accounts
|
||||
- Stay consistent with BAS standard numbering. Don't invent custom numbers where BAS already has a standard account
|
||||
- BAS publishes yearly updates. The structure is very stable but new accounts are added occasionally
|
||||
@@ -0,0 +1,232 @@
|
||||
# BFL, BFNAR, and ÅRL Reference
|
||||
|
||||
Detailed reference for Bokföringslagen (BFL 1999:1078), Bokföringsnämndens allmänna råd (BFNAR), and Årsredovisningslagen (ÅRL 1995:1554).
|
||||
|
||||
## Table of Contents
|
||||
1. BFL structure and key chapters
|
||||
2. Bokföringsskyldighet (BFL 2 kap)
|
||||
3. Löpande bokföring (BFL 5 kap)
|
||||
4. Verifikationer in depth
|
||||
5. Avslutning av bokföringen (BFL 6 kap)
|
||||
6. Arkivering (BFL 7 kap)
|
||||
7. BFNAR overview and K-regelverken
|
||||
8. K1 (BFNAR 2006:1)
|
||||
9. K2 (BFNAR 2016:10)
|
||||
10. K3 (BFNAR 2012:1)
|
||||
11. ÅRL key requirements
|
||||
12. Systemdokumentation and behandlingshistorik
|
||||
|
||||
---
|
||||
|
||||
## 1. BFL structure
|
||||
|
||||
BFL has 9 chapters:
|
||||
- 1 kap: Inledande bestämmelser (definitions)
|
||||
- 2 kap: Bokföringsskyldighet
|
||||
- 3 kap: Räkenskapsår
|
||||
- 4 kap: Bokföringsskyldighetens innebörd
|
||||
- 5 kap: Löpande bokföring och verifikationer
|
||||
- 6 kap: Avslutande av bokföringen (årsbokslut/årsredovisning)
|
||||
- 7 kap: Arkivering
|
||||
- 8 kap: Utvecklande av god redovisningssed (BFN:s uppdrag)
|
||||
- 9 kap: Överklagande
|
||||
|
||||
## 2. Bokföringsskyldighet (BFL 2 kap)
|
||||
|
||||
**Always bokföringsskyldiga:**
|
||||
- Aktiebolag (from registration, even before any activity)
|
||||
- Handelsbolag (including kommanditbolag)
|
||||
- Ekonomiska föreningar
|
||||
- Stiftelser (with some exceptions for small stiftelser)
|
||||
- Ideella föreningar that meet any of: tillgångar > 1.5 MSEK, bedriver näringsverksamhet, or is modersföretag
|
||||
|
||||
**Fysiska personer:**
|
||||
- Bokföringsskyldiga if they bedriver näringsverksamhet (enskild firma)
|
||||
- Exception: very small hobby-like activities may not qualify
|
||||
|
||||
**When does skyldigheten begin?**
|
||||
- AB: from Bolagsverket registration
|
||||
- Enskild firma: from first affärshändelse in the näringsverksamhet
|
||||
- Handelsbolag: from when bolaget begins verksamhet
|
||||
|
||||
## 3. Löpande bokföring (BFL 5 kap)
|
||||
|
||||
### Grundbok och huvudbok (5 kap 1§)
|
||||
All affärshändelser must be recorded in both:
|
||||
- **Grundbok** (journal): chronological order, each entry traceable to verifikation
|
||||
- **Huvudbok** (ledger): systematic order by account (kontoplan)
|
||||
|
||||
These can be in the same system but must be logically separate outputs.
|
||||
|
||||
### Timing requirements (5 kap 2§)
|
||||
- Kontanta transaktioner: bokföras senast påföljande arbetsdag
|
||||
- Övriga affärshändelser: "så snart det kan ske"
|
||||
- In practice (per BFNAR 2013:2): senast the month following the month of the affärshändelse
|
||||
- Exception: om den bokföringsskyldige har ordnade verifikationer that are easily accessible, bokföring kan dröja up to 50 dagar from the transaction
|
||||
|
||||
### Kontantmetod vs faktureringsmetod
|
||||
- **Kontantmetod**: bokföring vid betalningstillfället (only allowed if nettoomsättning normalt < 3 MSEK)
|
||||
- **Faktureringsmetod**: bokföring vid fakturadatum (required if nettoomsättning >= 3 MSEK, and for all momspliktig verksamhet above the threshold)
|
||||
- A company can choose faktureringsmetod even under 3 MSEK
|
||||
- Kontantmetod still requires that fordringar/skulder are booked at bokslut
|
||||
|
||||
### Gemensam verifikation (5 kap 6§ st 3)
|
||||
Multiple affärshändelser can share one verifikation if they are of the same slag and occurred the same dag. Example: daily kassarapport for retail.
|
||||
|
||||
## 4. Verifikationer in depth
|
||||
|
||||
### Required content (5 kap 7§)
|
||||
1. Datum then affärshändelsen occurred
|
||||
2. Datum when verifikationen upprättades (if different from #1)
|
||||
3. Vad affärshändelsen avser
|
||||
4. Belopp
|
||||
5. Motpart (where applicable and known)
|
||||
6. Verifikationsnummer (or equivalent identifier, systematiskt ordnade, löpande nummerordning, without gaps within a bokföringsserie)
|
||||
7. Other information needed to identify the affärshändelse
|
||||
|
||||
### Underlag
|
||||
Every verifikation must have an underlag (kvitto, faktura, bankutdrag, avtal, etc.). If no external underlag exists, the bokföringsskyldige must create an egen handling as underlag (e.g., for lönebokföring based on löneberäkning, or for avskrivningar).
|
||||
|
||||
### Multiple verification series
|
||||
BFL allows multiple verification series (e.g., "A" for supplier invoices, "B" for customer invoices, "K" for bank). Each series must have unbroken numbering within the räkenskapsår. This is common in practice and your software should support it.
|
||||
|
||||
### Rättelser (5 kap 5§)
|
||||
- An incorrect bokföringspost must be corrected with a new verifikation
|
||||
- The original post must remain visible (no overwriting)
|
||||
- The correcting post must reference the original
|
||||
- Implement as: rättelsepost with a link/reference to the original verifikation
|
||||
|
||||
## 5. Avslutning av bokföringen (BFL 6 kap)
|
||||
|
||||
### Årsbokslut
|
||||
- Required for enskild firma and handelsbolag with fysiska delägare
|
||||
- Consists of: resultaträkning + balansräkning
|
||||
- Must be upprättat within 6 months after räkenskapsårets slut
|
||||
- Förenklat årsbokslut: allowed if nettoomsättning normalt < 3 MSEK (BFNAR 2006:1 / K1)
|
||||
|
||||
### Årsredovisning
|
||||
- Required for: aktiebolag, ekonomiska föreningar, larger handelsbolag, stiftelser above thresholds
|
||||
- Consists of: förvaltningsberättelse, resultaträkning, balansräkning, noter
|
||||
- Larger companies also: kassaflödesanalys
|
||||
- Must be upprättat within: 6 months for AB, 7 months for ekonomisk förening
|
||||
- AB must file with Bolagsverket within 7 months, or face förseningsavgift
|
||||
|
||||
## 6. Arkivering (BFL 7 kap)
|
||||
|
||||
### Bevarandetid
|
||||
7 years after the end of the calendar year in which the räkenskapsår ended. Example: räkenskapsår ending 2026-06-30 -> save until at least 2033-12-31.
|
||||
|
||||
### Form
|
||||
- Must be in varaktigt läsbart skick (durable, readable form)
|
||||
- Digital storage: must ensure data integrity (immutability)
|
||||
- Since 1 July 2024 (SFS 2024:494): no requirement to keep paper originals after proper digitization
|
||||
- The digitized version must be a faithful reproduction
|
||||
- Metadata and structure must be preserved
|
||||
|
||||
### Location
|
||||
- Must be available in Sweden
|
||||
- Can be stored in EU/EEA if Skatteverket is notified
|
||||
- Must be producible to Skatteverket upon request within reasonable time
|
||||
|
||||
### What must be archived
|
||||
- Grundbok and huvudbok
|
||||
- All verifikationer with underlag
|
||||
- Årsredovisning/årsbokslut
|
||||
- Systemdokumentation and behandlingshistorik
|
||||
- Avtal, korrespondens, and other information important for understanding the bokföring
|
||||
|
||||
## 7. BFNAR overview and K-regelverken
|
||||
|
||||
BFN issues allmänna råd (BFNAR) that specify how BFL and ÅRL should be applied in practice. The K-system categorizes companies by size:
|
||||
|
||||
| Regelverk | Target | Årsredovisning? | Key characteristic |
|
||||
|---|---|---|---|
|
||||
| K1 (BFNAR 2006:1) | Enskild firma < 3 MSEK | No (förenklat årsbokslut) | Cash-based simplifications |
|
||||
| K2 (BFNAR 2016:10) | Smaller AB, HB, EF, ideella | Yes (simplified) | Schabloner, limited upprysningar |
|
||||
| K3 (BFNAR 2012:1) | Default for ÅRL-companies | Yes (full) | Component depreciation, full disclosures |
|
||||
| K4 (IFRS) | Listed companies | Yes (IFRS) | International standards |
|
||||
|
||||
**2026 change**: BRF:er must now use K3 (no longer K2). Companies where byggnader generate >= 75% of nettoomsättning must also K3.
|
||||
|
||||
## 8. K1 (BFNAR 2006:1 / 2010:1)
|
||||
|
||||
Simplified rules for enskild firma and HB with fysisk delägare, omsättning < 3 MSEK.
|
||||
|
||||
Key simplifications:
|
||||
- Kontantmetoden allowed
|
||||
- Förenklat årsbokslut (RR + BR only, no notes required beyond what law demands)
|
||||
- Inventarier can use skattemässiga avskrivningar directly
|
||||
- Varulager can be valued at anskaffningsvärde without individual assessment under threshold
|
||||
- No requirement for accruals below 5000 kr per post
|
||||
|
||||
## 9. K2 (BFNAR 2016:10)
|
||||
|
||||
For smaller companies that prepare årsredovisning.
|
||||
|
||||
Key characteristics:
|
||||
- Schablonmässiga avskrivningar (not component-based)
|
||||
- Limited tilläggsupplysningar compared to K3
|
||||
- Cannot capitalize egenupparbetade immateriella tillgångar
|
||||
- Byggnader: avskrivning as one unit (not component)
|
||||
- Avsättningar: only if legal obligation exists and can be reliably estimated
|
||||
- Eventualförpliktelser: disclosed in notes
|
||||
|
||||
**Restrictions from 2026**: BRF:er excluded from K2, companies with >= 75% revenue from buildings excluded. See changes-2025-2026.md.
|
||||
|
||||
## 10. K3 (BFNAR 2012:1)
|
||||
|
||||
Default regelverk for companies preparing årsredovisning.
|
||||
|
||||
Key requirements:
|
||||
- Component depreciation (komponentavskrivning) for materiella anläggningstillgångar
|
||||
- Full tilläggsupplysningar per ÅRL
|
||||
- Can capitalize egenupparbetade immateriella tillgångar (if criteria met)
|
||||
- Must assess nedskrivningsbehov
|
||||
- Revenue recognition per leverans/fullgjord prestation
|
||||
- Leasing classification (finansiell vs operationell)
|
||||
- Koncernredovisning if moderbolag (with exemptions for smaller koncerner)
|
||||
|
||||
## 11. ÅRL key requirements
|
||||
|
||||
### Grundläggande principer (2 kap)
|
||||
- Fortlevnadsprincipen (going concern)
|
||||
- Konsekvent tillämpning (consistency)
|
||||
- Försiktighetsprincipen (prudence)
|
||||
- Periodisering (accrual basis)
|
||||
- Individuell värdering (each item valued separately)
|
||||
- Bruttoredovisning (no netting of assets/liabilities or income/expenses)
|
||||
- Kontinuitet (opening balance = previous year's closing balance)
|
||||
|
||||
### Årsredovisningens delar (2 kap 1§)
|
||||
1. Förvaltningsberättelse
|
||||
2. Resultaträkning
|
||||
3. Balansräkning
|
||||
4. Noter
|
||||
5. Kassaflödesanalys (larger companies only)
|
||||
|
||||
### Larger vs smaller company (ÅRL 1 kap 3§)
|
||||
A company is "större" if it exceeds at least 2 of 3 for each of the last 2 years:
|
||||
- 50 anställda (average)
|
||||
- 40 MSEK balansomslutning
|
||||
- 80 MSEK nettoomsättning
|
||||
|
||||
## 12. Systemdokumentation and behandlingshistorik
|
||||
|
||||
### Systemdokumentation (BFNAR 2013:2 kap 8)
|
||||
Must describe:
|
||||
- The bokföringssystem and how it works
|
||||
- The kontoplan used
|
||||
- How verifikationer are numbered and organized
|
||||
- How the system ensures traceability from verifikation to bokslut
|
||||
- Access controls and authorization
|
||||
- Backup routines
|
||||
- Integration with other systems
|
||||
|
||||
### Behandlingshistorik
|
||||
Must log:
|
||||
- Automated processing (batch jobs, automated bokföring)
|
||||
- Changes to system settings that affect bokföring
|
||||
- User actions (who booked what, when)
|
||||
- Data migrations, imports, conversions
|
||||
|
||||
For a software developer: your system must produce these as exportable documentation. Consider generating systemdokumentation automatically from your system's configuration, and maintaining an audit trail as behandlingshistorik.
|
||||
@@ -0,0 +1,141 @@
|
||||
# Regulatory Changes 2024-2026
|
||||
|
||||
Recent and upcoming changes to Swedish accounting law, tax rules, and reporting requirements. Organized chronologically.
|
||||
|
||||
---
|
||||
|
||||
## Already in effect
|
||||
|
||||
### 1 July 2024: Pappersfritt bokföring (SFS 2024:494)
|
||||
**BFL 7 kap 6§ updated.**
|
||||
- No longer required to keep paper originals after digitization
|
||||
- Applies retroactively to material digitized before 1 July 2024
|
||||
- The digital copy must be a faithful reproduction
|
||||
- Must still preserve for 7 years
|
||||
- **Software implication**: receipt OCR/scanning is now the primary archival path, not supplementary. Your digitization workflow must ensure completeness and immutability.
|
||||
|
||||
### 1 January 2025: Momsregistreringsgräns höjd
|
||||
- Threshold raised from 30 000 to 80 000 kr per 12-month period
|
||||
- Companies with momspliktig omsättning under 80 000 kr need not register for moms
|
||||
- Can still register voluntarily
|
||||
- **Software implication**: update validation logic for momsregistrering recommendations.
|
||||
|
||||
### 1 January 2025: AGI föräldraledighet/VAB
|
||||
- Employers must report monthly to Skatteverket when employees take föräldraledighet or vård av sjukt barn (VAB)
|
||||
- Reported as part of arbetsgivardeklaration
|
||||
- **Software implication**: add föräldraledighet/VAB fields to AGI submission.
|
||||
|
||||
---
|
||||
|
||||
## Effective 1 January 2026
|
||||
|
||||
### 3:12-reglerna reformed
|
||||
Major overhaul of fåmansföretagsreglerna:
|
||||
|
||||
**New gränsbelopp calculation (3 components):**
|
||||
1. Grundbelopp: 4 IBB = 4 × 83 400 = 333 600 kr, divided equally among shares
|
||||
2. Lönebaserat utrymme: 50% × (andel av löneunderlag - 8 IBB per delägare). Max: 50 × egen/närstående lön
|
||||
3. Ränta på omkostnadsbelopp: statslåneränta + 9% on omkostnadsbelopp > 100 000 kr
|
||||
|
||||
**Key changes:**
|
||||
- Löneuttagskravet slopat (no minimum salary requirement for 2025)
|
||||
- Replaced by löneavdrag: 8 IBB (667 200 kr for 2026) per delägare subtracted from löneunderlag
|
||||
- 4-procentsspärren borta: all delägare can use löneunderlag regardless of ownership share
|
||||
- Karensti shortened from 5 to 4 years
|
||||
- Ränteuppräkning of sparat utdelningsutrymme abolished
|
||||
- Index- och kapitalunderlagsreglerna phased out (can still be used until 2029 for old shares)
|
||||
|
||||
**Software implication**: K10-blanketten for 2025 (filed spring 2027) uses new rules. If your software calculates utdelningsutrymme, the entire calculation engine needs rebuilding.
|
||||
|
||||
### K2/K3 changes (BFN decision June 2025)
|
||||
Applies to räkenskapsår starting after 31 December 2025 (i.e., 2026 calendar year onwards).
|
||||
|
||||
**Excluded from K2:**
|
||||
- Bostadsrättsföreningar and bostadsföreningar (must use K3)
|
||||
- Companies where byggnader generate >= 75% of nettoomsättning (must use K3)
|
||||
- Nystartade företag starting after 30 June 2025 with förlängt first räkenskapsår ending 31 Dec 2026 or later must apply new rules immediately
|
||||
|
||||
**K3 changes:**
|
||||
- Updated rules for aktierelaterade ersättningar (ch. 26)
|
||||
- Updated rules for eventualtillgångar (ch. 21.15): must now be "så gott som säkert" for recognition
|
||||
- New transitional provisions for applying changes
|
||||
|
||||
**Software implication**: if your system helps companies choose K-regelverk, update the decision logic. If you generate årsredovisning, BRF templates need K3 format.
|
||||
|
||||
### Belopp och gränser 2026
|
||||
| Item | 2025 | 2026 |
|
||||
|---|---|---|
|
||||
| Prisbasbelopp | 58 800 | 59 200 |
|
||||
| Förhöjt prisbasbelopp | 59 900 | 60 500 |
|
||||
| Inkomstbasbelopp | 80 600 | 83 400 |
|
||||
| Inventarier av mindre värde | 29 400 | 29 600 |
|
||||
| Traktamente heldag | 290 | 300 |
|
||||
| Traktamente halvdag | 145 | 150 |
|
||||
| Nattraktamente | 145 | 150 |
|
||||
|
||||
### Ålderspensionsavgift
|
||||
- Persons who are 67+ at year start (born 1959 or earlier): only ålderspensionsavgift 10.21%
|
||||
- Previous threshold was 66 years (born 1958 or earlier)
|
||||
|
||||
### F-skatt changes
|
||||
- Applicant can request tidsbegränsat godkännande
|
||||
- Skatteverket gains right to demand documentation proving eligibility
|
||||
- Intended to combat missbruk of F-skatt
|
||||
|
||||
### ROT-avdrag
|
||||
- Returns to 30% of arbetskostnad (was temporarily 35% during 2024)
|
||||
- Max 50 000 kr per person per year
|
||||
- Combined ROT+RUT max: 75 000 kr
|
||||
|
||||
### Reseavdrag
|
||||
- Threshold for avdrag for resor between bostad och arbetsplats raised from 11 000 to 15 000 kr
|
||||
|
||||
### CSRD hållbarhetsrapportering
|
||||
- From 2026 (for reporting year 2025): larger companies beyond listed must comply with CSRD
|
||||
- Criteria: 2 of 3: >250 employees, >40 MSEK balansomslutning, >80 MSEK nettoomsättning
|
||||
- Smaller listed companies from 2027 (reporting year 2026)
|
||||
- **Software implication**: not directly accounting, but some customers may need help with data collection
|
||||
|
||||
---
|
||||
|
||||
## Effective 1 April 2026
|
||||
|
||||
### Sänkt livsmedelsmoms (Prop. 2025/26:55)
|
||||
- Livsmedel: 12% -> 6% (tillfälligt)
|
||||
- Period: 1 April 2026 - 31 December 2027
|
||||
- Restaurang/servering: stays at 12%
|
||||
- Transition rule: rate based on leveransdatum, not fakturadatum
|
||||
- Take-away/avhämtning: 6%. Servering/förtäring på plats: 12%
|
||||
|
||||
**Critical implementation details:**
|
||||
- Invoices spanning the transition date: split by delivery date
|
||||
- Advance payments (förskott): apply the rate valid when the supply actually occurs
|
||||
- Credit notes: apply the rate that was valid for the original transaction
|
||||
- BAS accounts: livsmedel moves from 2611 (12%) to 2612 (6%)
|
||||
- Momsdeklaration: ruta 31 (12%) decreases, ruta 32 (6%) increases
|
||||
|
||||
**Software implication**: your system needs a date-aware momssats lookup. Hard-coding rates is not viable. Store momssatser with giltighetstid (valid_from, valid_to).
|
||||
|
||||
---
|
||||
|
||||
## Expected mid-2026
|
||||
|
||||
### Skatteverket digital granskning (Prop. 2025/26:107)
|
||||
- Proposed effective date: 1 July 2026 (pending riksdag vote spring 2026)
|
||||
- Skatteverket may access digital bokföring directly via internet during revision
|
||||
- Only when legal ground for kontroll already exists
|
||||
- Removes the ban on telenät-based granskning in skatteförfarandelagen
|
||||
|
||||
**Software implication**: your system should support read-only access for Skatteverket during revision. Event-sourced architecture (like gnubok's CQRS/Emmett setup) naturally supports this through immutable event logs. Consider building a dedicated API endpoint or export mechanism.
|
||||
|
||||
---
|
||||
|
||||
## On the horizon (2027+)
|
||||
|
||||
### Obligatorisk e-fakturering B2B
|
||||
EU directive ViDA (VAT in the Digital Age) will likely require structured e-invoicing for B2B transactions. Sweden expected to implement 2028-2030. Standards: Peppol BIS Billing 3.0, EN 16931.
|
||||
|
||||
**Software implication**: start supporting Peppol e-invoicing format now. It's already used by the public sector (offentlig sektor requires e-faktura since 2019). B2B mandate will make it universal.
|
||||
|
||||
### Livsmedelsmoms reversion
|
||||
Expected 1 January 2028: livsmedel reverts from 6% to 12%. Your system must handle the reversion date. Make sure momssats lookup is date-driven.
|
||||
@@ -0,0 +1,194 @@
|
||||
# SIE4 File Format Reference
|
||||
|
||||
SIE (Standard Import Export) is the Swedish standard for exchanging accounting data between systems. SIE4 is the current version used in practice.
|
||||
|
||||
## Table of Contents
|
||||
1. SIE versions overview
|
||||
2. SIE4 file structure
|
||||
3. Record types (poster)
|
||||
4. Mandatory vs optional fields
|
||||
5. Character encoding and formatting
|
||||
6. Implementation notes
|
||||
7. Validation rules
|
||||
|
||||
---
|
||||
|
||||
## 1. SIE versions overview
|
||||
|
||||
| Version | Content | Use case |
|
||||
|---|---|---|
|
||||
| SIE1 | Yearly balances | Simple balance transfer |
|
||||
| SIE2 | Periodic balances | Periodic balance transfer |
|
||||
| SIE3 | Object balances | Dimensional balances |
|
||||
| SIE4 | Full transaction data | Complete bokföring export/import |
|
||||
| SIE4E | SIE4 + dimensions | Extended with kostnadsställe etc. |
|
||||
|
||||
SIE4 is the workhorse. It contains the complete bokföring: kontoplan, verifikationer, and all transactions. Use SIE4 for import/export of full bokföring data.
|
||||
|
||||
## 2. SIE4 file structure
|
||||
|
||||
A SIE4 file is a plain text file with records, one per line. Each record starts with a # tag.
|
||||
|
||||
### File layout (order matters)
|
||||
```
|
||||
#FLAGGA 0
|
||||
#FORMAT PC8
|
||||
#SIETYP 4
|
||||
#PROGRAM "ProgramName" "Version"
|
||||
#GEN datum sign
|
||||
#FNR företagsnummer
|
||||
#ORGNR organisationsnummer
|
||||
#FNAMN "Företagsnamn"
|
||||
#RAR 0 start slut (current räkenskapsår)
|
||||
#RAR -1 start slut (previous räkenskapsår)
|
||||
#KPTYP BAS2024 (kontoplantyp)
|
||||
#KONTO kontonr "kontonamn"
|
||||
#SRU kontonr srukod
|
||||
#IB 0 kontonr belopp (ingående balans current year)
|
||||
#UB 0 kontonr belopp (utgående balans current year)
|
||||
#UB -1 kontonr belopp (utgående balans previous year)
|
||||
#RES 0 kontonr belopp (resultat current year)
|
||||
#VER serie vernr verdatum vertext regdatum sign
|
||||
{
|
||||
#TRANS kontonr {} belopp transdat transtext kvantitet sign
|
||||
#TRANS kontonr {} belopp transdat transtext kvantitet sign
|
||||
}
|
||||
```
|
||||
|
||||
## 3. Record types (poster)
|
||||
|
||||
### Header records
|
||||
| Tag | Description | Required |
|
||||
|---|---|---|
|
||||
| #FLAGGA | 0 = not read, 1 = read | Yes |
|
||||
| #FORMAT | Character encoding (PC8 = CP437) | Yes |
|
||||
| #SIETYP | SIE version (4 for SIE4) | Yes |
|
||||
| #PROGRAM | Software name and version | Yes |
|
||||
| #GEN | Generation date and user signature | Yes |
|
||||
| #FNR | Company number (internal) | No |
|
||||
| #ORGNR | Organisationsnummer | Yes |
|
||||
| #FNAMN | Company name | Yes |
|
||||
| #ADRESS | Company address | No |
|
||||
| #RAR | Räkenskapsår period (0 = current, -1 = previous) | Yes |
|
||||
| #TAXAR | Taxeringsår | No |
|
||||
| #KPTYP | Kontoplan type (e.g., BAS2024) | Yes |
|
||||
| #VALUTA | Currency (SEK default) | No |
|
||||
|
||||
### Account records
|
||||
| Tag | Description |
|
||||
|---|---|
|
||||
| #KONTO | Account number and name |
|
||||
| #KTYP | Account type (T=tillgång, S=skuld, K=kostnad, I=intäkt) |
|
||||
| #SRU | SRU mapping (for tax declaration) |
|
||||
| #ENHET | Unit for account |
|
||||
|
||||
### Balance records
|
||||
| Tag | Description |
|
||||
|---|---|
|
||||
| #IB | Ingående balans (opening balance) |
|
||||
| #UB | Utgående balans (closing balance) |
|
||||
| #OIB | Object ingående balans |
|
||||
| #OUB | Object utgående balans |
|
||||
| #RES | Resultat (P&L account balance) |
|
||||
|
||||
### Transaction records
|
||||
| Tag | Description |
|
||||
|---|---|
|
||||
| #VER | Verifikation header |
|
||||
| #TRANS | Transaction line within a verifikation |
|
||||
| #RTRANS | Reversed transaction (rättelse) |
|
||||
| #BTRANS | Added transaction (tillägg) |
|
||||
|
||||
### Dimension records (SIE4E)
|
||||
| Tag | Description |
|
||||
|---|---|
|
||||
| #DIM | Dimension definition |
|
||||
| #UNDERDIM | Sub-dimension |
|
||||
| #OBJEKT | Object within a dimension |
|
||||
|
||||
## 4. Mandatory vs optional
|
||||
|
||||
### Minimum valid SIE4 file must have:
|
||||
1. #FLAGGA
|
||||
2. #FORMAT
|
||||
3. #SIETYP
|
||||
4. #PROGRAM
|
||||
5. #GEN
|
||||
6. #ORGNR
|
||||
7. #FNAMN
|
||||
8. #RAR (at least current year)
|
||||
9. #KPTYP
|
||||
10. #KONTO (all used accounts)
|
||||
11. #VER + #TRANS (all verifikationer and transactions)
|
||||
|
||||
### Balance records
|
||||
- #IB and #UB are expected but some systems omit them
|
||||
- #RES records summarize resultaträkning accounts
|
||||
- Best practice: always include IB/UB for balansräkning accounts and RES for resultaträkning accounts
|
||||
|
||||
## 5. Character encoding and formatting
|
||||
|
||||
### Encoding
|
||||
- Traditional: PC8 (Code Page 437) declared with #FORMAT PC8
|
||||
- Modern alternative: UTF-8, no #FORMAT tag or custom declaration
|
||||
- Most Swedish systems still expect PC8. If you produce UTF-8, document it clearly.
|
||||
- When importing: detect encoding, handle both
|
||||
|
||||
### Number format
|
||||
- Decimal separator: period (.)
|
||||
- Negative numbers: minus sign prefix
|
||||
- No thousands separator
|
||||
- Amounts in full currency units (SEK, not öre)
|
||||
|
||||
### Date format
|
||||
- YYYYMMDD (no separators)
|
||||
- Example: 20260401
|
||||
|
||||
### String quoting
|
||||
- Strings containing spaces must be enclosed in double quotes
|
||||
- Empty strings: ""
|
||||
- Escape double quotes inside strings with backslash: \"
|
||||
|
||||
### Curly braces
|
||||
- {} denotes an empty object list (used in #TRANS when no dimension objects)
|
||||
- {1 "100" 2 "PROJ1"} for dimension objects
|
||||
|
||||
## 6. Implementation notes
|
||||
|
||||
### Producing SIE4
|
||||
1. Export all verifikationer for the räkenskapsår
|
||||
2. Include all accounts in the kontoplan that have been used
|
||||
3. Calculate IB (from previous year's UB or from opening balances)
|
||||
4. Calculate UB and RES from transactions
|
||||
5. Ensure verifikationer are complete (debits = credits within each #VER block)
|
||||
6. Set #FLAGGA to 0
|
||||
|
||||
### Consuming SIE4
|
||||
1. Parse header to get company info and period
|
||||
2. Build kontoplan from #KONTO records
|
||||
3. Import balances from #IB/#UB/#RES
|
||||
4. Import verifikationer and transactions
|
||||
5. Validate: sum of all #TRANS within each #VER must be 0 (balanced)
|
||||
6. Handle encoding conversion if needed
|
||||
|
||||
### Common pitfalls
|
||||
- Forgetting to handle PC8 encoding (Swedish characters å, ä, ö)
|
||||
- Not validating that verifikationer balance (sum of TRANS = 0)
|
||||
- Assuming date fields are always populated (some are optional)
|
||||
- Not handling #RTRANS and #BTRANS (corrections and additions to existing verifikationer)
|
||||
- Missing #SRU codes (needed for tax declaration mapping)
|
||||
|
||||
## 7. Validation rules
|
||||
|
||||
### Per verifikation (#VER block)
|
||||
- Sum of all #TRANS debit amounts must equal sum of credit amounts (i.e., total nets to 0)
|
||||
- Verifikationsnummer must be unique within its serie
|
||||
- Verifikationsdatum must fall within the declared #RAR period
|
||||
|
||||
### Per file
|
||||
- All account numbers in #TRANS must have a corresponding #KONTO record
|
||||
- #IB for year 0 should match #UB for year -1 (if both present)
|
||||
- #ORGNR should be a valid Swedish organisationsnummer (10 digits, Luhn check on last digit)
|
||||
|
||||
### SRU mapping
|
||||
SRU (Standardiserat RäkenskapsUtdrag) codes map BAS accounts to positions in the tax declaration (INK2, INK4, etc.). Your system should maintain SRU mappings and include them in SIE exports. This enables automatic population of tax forms.
|
||||
@@ -0,0 +1,250 @@
|
||||
# Skatteverket Reference
|
||||
|
||||
Tax compliance rules, reporting requirements, and API integration details relevant for Swedish accounting software.
|
||||
|
||||
## Table of Contents
|
||||
1. Moms (mervärdesskatt) - rules and rates
|
||||
2. Skattedeklaration
|
||||
3. Arbetsgivardeklaration på individnivå (AGI)
|
||||
4. F-skatt and preliminär skatt
|
||||
5. Skattekonto
|
||||
6. Skatteverket API integration
|
||||
7. Momsregistrering
|
||||
8. ROT and RUT
|
||||
9. Traktamente and representation
|
||||
10. Digital granskning (Prop. 2025/26:107)
|
||||
|
||||
---
|
||||
|
||||
## 1. Moms (mervärdesskatt)
|
||||
|
||||
### Rates (as of 2026)
|
||||
| Rate | Applies to |
|
||||
|---|---|
|
||||
| 25% | Standard: most goods and services |
|
||||
| 12% | Restaurang/servering, hotell, camping, certain cultural activities |
|
||||
| 6% | Books, newspapers, public transport, sport/cultural events. **From 1 Apr 2026: also livsmedel (tillfälligt till 31 Dec 2027)** |
|
||||
| 0% | Export, international transports, financial services, healthcare, dental, education, insurance, social care |
|
||||
|
||||
### Livsmedel transition (Prop. 2025/26:55)
|
||||
- Before 1 Apr 2026: 12%
|
||||
- 1 Apr 2026 - 31 Dec 2027: 6% (tillfälligt)
|
||||
- After 31 Dec 2027: expected reversion to 12%
|
||||
- Transition rule: the rate applies based on when the beskattningsgrundande händelse (taxable event) occurs, typically leveransdatum, NOT fakturadatum
|
||||
- Restaurang/servering stays at 12% throughout. The distinction livsmedel vs restaurangtjänst becomes critical. Take-away/avhämtning = 6%, servering/förtäring på plats = 12%
|
||||
|
||||
### Reporting periods for moms
|
||||
| Nettoomsättning | Period | Deadline |
|
||||
|---|---|---|
|
||||
| > 40 MSEK | Monthly | 26th of following month (12th for Jan and Aug) |
|
||||
| 1-40 MSEK | Monthly or quarterly (employer's choice, application to SKV) | Monthly: 26th. Quarterly: 12th of second month after quarter end |
|
||||
| < 1 MSEK | Quarterly, or annually | Annual: latest in the inkomstdeklaration |
|
||||
|
||||
### Omvänd skattskyldighet (reverse charge)
|
||||
Applies in certain B2B scenarios:
|
||||
- Byggtjänster (construction services) between companies in byggsektorn
|
||||
- EU purchases of goods (EU-förvärv)
|
||||
- EU purchases of services (huvudregel: köparens land)
|
||||
- Certain precious metals and investment gold
|
||||
|
||||
Software must support reverse charge entries: debit ingående moms, credit utgående moms, no net cash effect but must appear on momsdeklaration.
|
||||
|
||||
### Jämkning av ingående moms
|
||||
For investeringsvaror (fastighetsinvesteringar, inventarier > 200 000 kr, fastighetstjänster > 100 000 kr): if the use of the asset changes (e.g., from momspliktig to momsfri verksamhet), the previously avdragen ingående moms must be jämkad (adjusted) over a period (10 years for fastigheter, 5 years for inventarier).
|
||||
|
||||
### EU-handel
|
||||
- EU-försäljning av varor: momsfri if buyer has valid VAT number (verify via VIES) and goods are transported to another EU country
|
||||
- EU-förvärv: reverse charge, reported in both ruta 20 (inköp) and ruta 30/31/32 (utgående moms) + ruta 48 (ingående moms)
|
||||
- Periodisk sammanställning: reported monthly or quarterly to Skatteverket for EU sales
|
||||
|
||||
## 2. Skattedeklaration
|
||||
|
||||
### Content
|
||||
The skattedeklaration covers:
|
||||
- Moms (utgående and ingående, per rate)
|
||||
- Arbetsgivaravgifter
|
||||
- Avdragen preliminär skatt (PAYE)
|
||||
- Särskild löneskatt on pensionskostnader
|
||||
|
||||
### Filing
|
||||
- Monthly filers: due the 12th (Jan, Aug) or 26th (other months) of the following month
|
||||
- Paper deadline: 12th of following month regardless
|
||||
- Electronic filing via Skatteverkets e-tjänst or via API (filöverföring)
|
||||
|
||||
### Key moms rutor (boxes)
|
||||
The momsdeklaration has numbered rutor:
|
||||
- 05: Momspliktig försäljning (ej export)
|
||||
- 06: Momspliktiga uttag
|
||||
- 07: Beskattningsunderlag vid vinstmarginalbeskattning
|
||||
- 08: Hyresinkomst frivillig skattskyldighet
|
||||
- 20-24: EU-related acquisitions and purchases
|
||||
- 30: Utgående moms 25%
|
||||
- 31: Utgående moms 12%
|
||||
- 32: Utgående moms 6%
|
||||
- 35: Utgående moms reverse charge
|
||||
- 40: Inköp med avdragsrätt
|
||||
- 41: Inköp utan avdragsrätt
|
||||
- 48: Ingående moms (total avdrag)
|
||||
- 49: Moms att betala eller få tillbaka
|
||||
- 50: Momspliktigt belopp export
|
||||
|
||||
## 3. Arbetsgivardeklaration på individnivå (AGI)
|
||||
|
||||
Since 2019, employers must report per individual each month.
|
||||
|
||||
### Per employee, report:
|
||||
- Kontant bruttolön
|
||||
- Förmåner (bil, bostad, etc.)
|
||||
- Avdragen preliminär skatt
|
||||
- Underlag for arbetsgivaravgifter
|
||||
- Kostnadsersättningar (traktamente, bilersättning)
|
||||
|
||||
### Arbetsgivaravgifter (2026)
|
||||
Standard rate: 31.42% on total ersättning
|
||||
Breakdown:
|
||||
- Ålderspensionsavgift: 10.21%
|
||||
- Sjukförsäkringsavgift: 3.55%
|
||||
- Föräldraförsäkringsavgift: 2.60%
|
||||
- Arbetsskadeavgift: 0.20%
|
||||
- Arbetsmarknadsavgift: 2.64%
|
||||
- Allmän löneavgift: 11.62%
|
||||
- Efterlevandepensionsavgift: 0.60%
|
||||
|
||||
**Age-based reductions (2026):**
|
||||
- Born 1959 or earlier (67+ at year start): only ålderspensionsavgift = 10.21%
|
||||
- Born 2001-2007 (18-24): full rate 31.42% (the previous ungdomsrabatt expired 2023)
|
||||
|
||||
### Filing
|
||||
- Monthly, together with skattedeklaration
|
||||
- Deadline: same as skattedeklaration (12th or 26th)
|
||||
|
||||
### New 2025/2026: föräldraledighet/VAB reporting
|
||||
Employers must now report monthly when employees take föräldraledighet or VAB to Skatteverket.
|
||||
|
||||
## 4. F-skatt and preliminär skatt
|
||||
|
||||
### F-skatt
|
||||
- Required for näringsverksamhet
|
||||
- Applied for via Skatteverket
|
||||
- Shows buyer that they are NOT responsible for paying arbetsgivaravgifter on the payment
|
||||
- **2026 change**: applicant can request tidsbegränsat godkännande. Skatteverket may now require documentation proving eligibility
|
||||
|
||||
### FA-skatt
|
||||
Combined F-skatt and A-skatt. For people who both run a business and are employed.
|
||||
|
||||
### Preliminär skatt (F-skattsedel)
|
||||
- Debiterad preliminär skatt based on Skatteverket's estimate or the företagare's own uppgift
|
||||
- Paid monthly to skattekontot
|
||||
- Can be adjusted (jämkning) during the year if income differs from forecast
|
||||
- Slutlig skatt beräknas vid inkomstdeklaration
|
||||
|
||||
## 5. Skattekonto
|
||||
|
||||
Every company/person with Swedish tax obligations has a skattekonto.
|
||||
|
||||
### How it works
|
||||
- All tax payments credited (inbetalningar)
|
||||
- All tax debits charged (arbetsgivaravgifter, moms, preliminärskatt, slutlig skatt)
|
||||
- Interest on positive balance (intäktsränta, currently very low)
|
||||
- Kostnadsränta on negative balance (higher, see Skatteverket current rates)
|
||||
- Booked on the 12th or 26th each month
|
||||
|
||||
### For software
|
||||
- Track expected debits/credits per period
|
||||
- Reconcile against skattekontoutdrag from Skatteverket
|
||||
- Flag underpayments to avoid kostnadsränta
|
||||
|
||||
## 6. Skatteverket API integration
|
||||
|
||||
### Momsdeklaration via API
|
||||
Skatteverket offers electronic filing:
|
||||
- Filöverföring: submit XML-based declarations
|
||||
- OAuth2/BankID authentication flows for machine-to-machine and user-delegated access
|
||||
- AGI (arbetsgivardeklaration): electronic submission required for most filers
|
||||
|
||||
### Authentication patterns
|
||||
- BankID for user-facing authentication
|
||||
- OAuth2 Authorization Code Grant (ACG) flow for delegated access
|
||||
- Certificates for system-to-system (larger volumes)
|
||||
|
||||
### Data formats
|
||||
- Skattedeklaration: XML schema defined by Skatteverket
|
||||
- SIE4: for bokföring export (see sie4.md)
|
||||
- Periodisk sammanställning: separate XML format for EU trade reporting
|
||||
|
||||
### Key endpoints (conceptual, verify current docs)
|
||||
- Inkomstdeklaration
|
||||
- Skattedeklaration (moms + AGI)
|
||||
- Periodisk sammanställning (EU trade)
|
||||
- Skattekontoutdrag
|
||||
|
||||
Always check Skatteverket's current technical documentation. Their APIs change. The developer portal is at skatteverket.se/utvecklare.
|
||||
|
||||
## 7. Momsregistrering
|
||||
|
||||
### When required
|
||||
- Momspliktig verksamhet > 80 000 kr per 12-month period (threshold from 2025)
|
||||
- Below threshold: can choose to register voluntarily
|
||||
- EU-handel: registration required regardless of threshold
|
||||
|
||||
### Registration process
|
||||
- Apply via Skatteverket (blankett SKV 4620 or digitally)
|
||||
- Receive momsregistreringsnummer (SE + org.nr + 01)
|
||||
- Software should validate format: SE followed by 10 digits followed by 01
|
||||
|
||||
## 8. ROT and RUT
|
||||
|
||||
### ROT-avdrag (2026)
|
||||
- 30% of arbetskostnad (not material)
|
||||
- Max 50 000 kr per person per year
|
||||
- Only for privatpersoner who own the bostad
|
||||
- Applies to: reparation, underhåll, om- och tillbyggnad
|
||||
- Filing: via Skatteverket's system, contractor submits begäran
|
||||
|
||||
### RUT-avdrag (2026)
|
||||
- 50% of arbetskostnad
|
||||
- Max 75 000 kr per person per year
|
||||
- Applies to: hushållsnära tjänster (städning, trädgård, barnpassning, etc.)
|
||||
- Combined ROT+RUT: max 75 000 kr, of which max 50 000 kr ROT
|
||||
|
||||
### For software
|
||||
If you handle ROT/RUT, your invoices must separate arbetskostnad from materialkostnad. The ROT/RUT amount is claimed by the utförare (contractor) via Skatteverket's API, and reduces the customer's payment. You need to track: begärt belopp, godkänt belopp, utbetalt belopp.
|
||||
|
||||
## 9. Traktamente and representation
|
||||
|
||||
### Traktamente (2026)
|
||||
- Heldag (minst en övernattning): 300 kr
|
||||
- Halvdag: 150 kr
|
||||
- Nattraktamente: 150 kr
|
||||
- These are skattefria amounts per day. Amounts above are löneförmån.
|
||||
|
||||
### Representation (2026)
|
||||
- Extern representation: avdragsgillt for enklare förtäring up to viss nivå
|
||||
- Intern representation: two tillfällen per year (julfest, sommarfest etc.)
|
||||
- Momsavdrag on representation: limited
|
||||
|
||||
## 10. Digital granskning (Prop. 2025/26:107)
|
||||
|
||||
### Background
|
||||
Proposed law to allow Skatteverket to access digital bokföring directly via internet during revision/kontroll. Currently (spring 2026) in riksdag processing.
|
||||
|
||||
### What it means for software developers
|
||||
- Skatteverket may connect to your system and access bokföring directly
|
||||
- NOT unlimited access: only when legal grund for kontroll/revision already exists
|
||||
- You need: proper access controls, audit logging, ability to grant read-only access
|
||||
- Data must be complete, correct, and accessible in real-time
|
||||
- Consider implementing a "revisionsläge" or read-only API endpoint
|
||||
|
||||
### Timeline
|
||||
- Lagrådsremiss: November 2025
|
||||
- Proposition: February 2026
|
||||
- Expected riksdag decision: Spring 2026
|
||||
- Proposed effective date: 1 July 2026
|
||||
|
||||
### Implications for Luka/gnubok
|
||||
Your system stores bokföring in the cloud. Under the new rules, Skatteverket could request access to a customer's data directly in your system. You should:
|
||||
1. Have granular access controls (per-company read access)
|
||||
2. Maintain complete audit trails
|
||||
3. Ensure data immutability (event-sourced architecture helps here)
|
||||
4. Be able to produce standardized exports (SIE4, PDF reports) on demand
|
||||
5. Document your system's compliance in the systemdokumentation
|
||||
@@ -1,150 +0,0 @@
|
||||
---
|
||||
name: swedish-bookkeeping
|
||||
description: "Swedish double-entry bookkeeping domain knowledge for gnubok: BAS account codes, VAT treatments, journal entry patterns, entity type differences (enskild firma vs aktiebolag), and legal constraints (BFL/BFN). Use when creating journal entry generators, modifying bookkeeping logic, adding VAT handling, working with reports, or any accounting-related code. Prevents illegal accounting operations and ensures correct account/VAT mappings."
|
||||
---
|
||||
|
||||
# Swedish Bookkeeping Reference
|
||||
|
||||
## Critical Rules (Legally Enforced)
|
||||
|
||||
1. **Committed entries are immutable** — never edit, use storno reversal
|
||||
2. **Every entry must balance** — `sum(debits) === sum(credits)`, both `> 0`
|
||||
3. **Monetary math**: `Math.round(x * 100) / 100` — NEVER `toFixed()`
|
||||
4. **Account numbers are strings** — `'1930'`, never `1930`
|
||||
5. **Always use engine** — `createJournalEntry()` from `lib/bookkeeping/engine.ts`, never direct DB inserts
|
||||
6. **Voucher numbers** — assigned by DB RPC `next_voucher_number`, never manually
|
||||
|
||||
## Entry Generator Skeleton
|
||||
|
||||
```typescript
|
||||
export async function createXxxEntry(userId: string, entity: Entity): Promise<JournalEntry | null> {
|
||||
const fiscalPeriodId = await findFiscalPeriod(userId, entity.date)
|
||||
if (!fiscalPeriodId) {
|
||||
console.warn('No open fiscal period for date:', entity.date)
|
||||
return null // Caller handles null
|
||||
}
|
||||
|
||||
const lines: CreateJournalEntryLineInput[] = [
|
||||
{ account_number: '1930', debit_amount: amount, credit_amount: 0, line_description: '...' },
|
||||
{ account_number: '3001', debit_amount: 0, credit_amount: amount, line_description: '...' },
|
||||
]
|
||||
|
||||
return createJournalEntry(userId, {
|
||||
fiscal_period_id: fiscalPeriodId,
|
||||
entry_date: entity.date,
|
||||
description: 'Swedish description here',
|
||||
source_type: 'xxx', // Must exist in DB CHECK constraint
|
||||
source_id: entity.id,
|
||||
lines,
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
## VAT Treatments & Accounts
|
||||
|
||||
| Treatment | Rate | Output VAT Account | Revenue Account |
|
||||
|-----------|------|--------------------|-----------------|
|
||||
| `standard_25` | 25% | `2611` | `3001` |
|
||||
| `reduced_12` | 12% | `2621` | `3002` |
|
||||
| `reduced_6` | 6% | `2631` | `3003` |
|
||||
| `reverse_charge` | 0% | — | `3308` (EU service) |
|
||||
| `export` | 0% | — | `3305` |
|
||||
| `exempt` | 0% | — | `3004` (AB) / `3100` (EF) |
|
||||
|
||||
Input VAT (purchases): `2641` (Debiterad ingående moms)
|
||||
|
||||
## EU Reverse Charge (Fiktiv Moms)
|
||||
|
||||
Creates offsetting entries that net to zero:
|
||||
```
|
||||
Debit 2645 Beräknad ingående moms [vat_amount]
|
||||
Credit 2614 Utgående moms omvänd skattsk. [vat_amount]
|
||||
```
|
||||
|
||||
## VAT From Gross Amount
|
||||
|
||||
```typescript
|
||||
const vatAmount = Math.round((grossAmount * vatRate / (1 + vatRate)) * 100) / 100
|
||||
const netAmount = Math.round((grossAmount / (1 + vatRate)) * 100) / 100
|
||||
```
|
||||
|
||||
## Key Account Quick Reference
|
||||
|
||||
For full BAS chart, see `references/bas-accounts.md`.
|
||||
|
||||
| Account | Name | Usage |
|
||||
|---------|------|-------|
|
||||
| `1510` | Kundfordringar | Accounts receivable |
|
||||
| `1930` | Företagskonto | Bank account |
|
||||
| `2013` | Övriga egna uttag | Private withdrawals (EF) |
|
||||
| `2440` | Leverantörsskulder | Accounts payable |
|
||||
| `2893` | Skuld till aktieägare | Shareholder loan (AB) |
|
||||
|
||||
## Entity Type Differences
|
||||
|
||||
| Context | Enskild Firma | Aktiebolag |
|
||||
|---------|--------------|------------|
|
||||
| Private transactions | `2013` | `2893` |
|
||||
| Exempt revenue | `3100` | `3004` |
|
||||
| Education expense | `6991` | `7610` |
|
||||
|
||||
## Common Journal Entry Patterns
|
||||
|
||||
**Sales invoice (accrual)**:
|
||||
```
|
||||
Debit 1510 [total] Kundfordringar
|
||||
Credit 30xx [subtotal] Försäljning
|
||||
Credit 26xx [vat] Utgående moms
|
||||
```
|
||||
|
||||
**Invoice payment**:
|
||||
```
|
||||
Debit 1930 [total] Företagskonto
|
||||
Credit 1510 [total] Kundfordringar
|
||||
```
|
||||
|
||||
**Supplier invoice registration**:
|
||||
```
|
||||
Debit 4xxx/5xxx/6xxx [net] Expense account
|
||||
Debit 2641 [vat] Ingående moms
|
||||
Credit 2440 [total] Leverantörsskulder
|
||||
```
|
||||
|
||||
**Supplier invoice payment**:
|
||||
```
|
||||
Debit 2440 [total] Leverantörsskulder
|
||||
Credit 1930 [total] Företagskonto
|
||||
```
|
||||
|
||||
## Swedish Description Conventions
|
||||
|
||||
- `Faktura {invoice_number}` — sales invoice
|
||||
- `Betalning faktura {invoice_number}` — payment
|
||||
- `Kreditfaktura {invoice_number}` — credit note
|
||||
- `Lev.faktura {supplier_invoice_number} (ankomst {arrival_number})` — supplier invoice
|
||||
- `Makulering: {original_description}` — storno reversal
|
||||
|
||||
The `ankomstnummer` (arrival number) is a BFL requirement on supplier invoices.
|
||||
|
||||
## Momsdeklaration Boxes (Rutor)
|
||||
|
||||
| Ruta | Description | Maps from |
|
||||
|------|-------------|-----------|
|
||||
| 05 | Utgående moms 25% | Account 2611 |
|
||||
| 06 | Utgående moms 12% | Account 2621 |
|
||||
| 07 | Utgående moms 6% | Account 2631 |
|
||||
| 10 | Underlag 25% | Revenue at 25% |
|
||||
| 11 | Underlag 12% | Revenue at 12% |
|
||||
| 12 | Underlag 6% | Revenue at 6% |
|
||||
| 39 | EU tjänsteförsäljning | Account 3308 |
|
||||
| 40 | Export | Account 3305 |
|
||||
| 48 | Ingående moms | Account 2641 |
|
||||
| 49 | Moms att betala/återfå | Sum 05+06+07 - 48 |
|
||||
|
||||
## EU VAT Rule
|
||||
|
||||
EU business customers MUST have a validated VAT number to qualify for reverse charge. Without validation, charge standard 25% Swedish VAT.
|
||||
|
||||
## source_type Values
|
||||
|
||||
Adding a new generator with a new source_type requires a DB migration to expand the CHECK constraint. Current values: `manual`, `bank_transaction`, `invoice_created`, `invoice_paid`, `invoice_cash_payment`, `credit_note`, `salary_payment`, `opening_balance`, `year_end`, `storno`, `correction`, `import`, `system`, `supplier_invoice_registered`, `supplier_invoice_paid`, `supplier_invoice_cash_payment`, `supplier_credit_note`.
|
||||
@@ -1,71 +0,0 @@
|
||||
# BAS Account Quick Reference — gnubok
|
||||
|
||||
Accounts used in codebase. Search `dev_docs/BASKONTOPLAN.md` for full chart.
|
||||
|
||||
## Class 1 — Assets
|
||||
|
||||
| Account | Name | Usage |
|
||||
|---------|------|-------|
|
||||
| `1510` | Kundfordringar | Accounts receivable (sales invoices) |
|
||||
| `1930` | Företagskonto/checkkonto | Primary bank account |
|
||||
|
||||
## Class 2 — Equity, Liabilities & VAT
|
||||
|
||||
| Account | Name | Usage |
|
||||
|---------|------|-------|
|
||||
| `2013` | Övriga egna uttag | Private withdrawals (enskild firma only) |
|
||||
| `2440` | Leverantörsskulder | Accounts payable |
|
||||
| `2611` | Utg. moms 25% | Output VAT standard rate |
|
||||
| `2614` | Utg. moms omvänd skattskyldighet | Reverse charge output |
|
||||
| `2621` | Utg. moms 12% | Output VAT reduced |
|
||||
| `2631` | Utg. moms 6% | Output VAT reduced |
|
||||
| `2641` | Debiterad ingående moms | Input VAT (deductible) |
|
||||
| `2645` | Beräknad ingående moms utlandet | Calculated input VAT (EU reverse charge) |
|
||||
| `2893` | Skuld till aktieägare | Shareholder loan (aktiebolag only) |
|
||||
|
||||
## Class 3 — Revenue
|
||||
|
||||
| Account | Name | Usage |
|
||||
|---------|------|-------|
|
||||
| `3001` | Försäljning 25% | Revenue at standard VAT |
|
||||
| `3002` | Försäljning 12% | Revenue at reduced 12% |
|
||||
| `3003` | Försäljning 6% | Revenue at reduced 6% |
|
||||
| `3004` | Försäljning momsfri (AB) | Exempt revenue, aktiebolag |
|
||||
| `3100` | Försäljning momsfri (EF) | Exempt revenue, enskild firma |
|
||||
| `3305` | Försäljning tjänst export | Non-EU export |
|
||||
| `3308` | Försäljning tjänst EU | EU service (reverse charge) |
|
||||
| `3900` | Övriga rörelseintäkter | Other operating income |
|
||||
| `3960` | Valutakursvinster | FX gains |
|
||||
|
||||
## Class 4-6 — Expenses
|
||||
|
||||
| Account | Name | Category mapping |
|
||||
|---------|------|-----------------|
|
||||
| `5010` | Lokalhyra | `expense_office` |
|
||||
| `5410` | Förbrukningsinventarier | `expense_equipment` |
|
||||
| `5420` | Programvaror | `expense_software` |
|
||||
| `5800` | Resekostnader | `expense_travel` |
|
||||
| `5910` | Annonsering | `expense_marketing` |
|
||||
| `6530` | Redovisningstjänster | `expense_professional_services` |
|
||||
| `6570` | Bankavgifter | `expense_bank_fees` / `expense_card_fees` |
|
||||
| `6900` | Övriga kostnader | Default fallback for uncategorized |
|
||||
| `6991` | Övriga avdragsgilla kostnader | `expense_other` / `expense_education` (EF) |
|
||||
|
||||
## Class 7 — Personnel & FX
|
||||
|
||||
| Account | Name | Usage |
|
||||
|---------|------|-------|
|
||||
| `7510` | Arbetsgivaravgifter | Employer contributions 31.42% |
|
||||
| `7610` | Utbildning | Education (aktiebolag only) |
|
||||
| `7960` | Valutakursförluster | FX losses / `expense_currency_exchange` |
|
||||
|
||||
## VAT-Exempt Expense Categories
|
||||
|
||||
These categories never get input VAT deduction (`2641`):
|
||||
- `expense_bank_fees`
|
||||
- `expense_card_fees`
|
||||
- `expense_currency_exchange`
|
||||
|
||||
## Capitalization Threshold
|
||||
|
||||
Equipment above 29,400 SEK uses `capitalized_debit_account` instead of normal expense. Half-year rule for 2024.
|
||||
@@ -0,0 +1,479 @@
|
||||
import type { Extension, ExtensionContext } from '@/lib/extensions/types'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
import { uploadDocument } from '@/lib/core/documents/document-service'
|
||||
import { classifyDocument } from './lib/classify-document'
|
||||
import { encryptState, decryptState, encryptToken, decryptToken } from './lib/gmail-helpers'
|
||||
import { scanGmailConnection } from './lib/gmail-scanner'
|
||||
import type { InvoiceExtractionResult } from '@/types'
|
||||
|
||||
const MAX_FILE_SIZE = 10 * 1024 * 1024 // Match MAX_DOCUMENT_SIZE from document-service
|
||||
|
||||
const UPLOAD_ALLOWED_MIME_TYPES = new Set([
|
||||
'application/pdf',
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/heic',
|
||||
'image/heif',
|
||||
'image/webp',
|
||||
])
|
||||
|
||||
const STATE_TTL_MS = 10 * 60 * 1000
|
||||
|
||||
// ── Shared helper: upload + classify + create inbox item ─────
|
||||
|
||||
async function uploadAndClassify(
|
||||
supabase: import('@supabase/supabase-js').SupabaseClient,
|
||||
userId: string,
|
||||
companyId: string,
|
||||
file: { name: string; buffer: ArrayBuffer; type: string },
|
||||
source: 'upload' | 'email',
|
||||
emailMeta?: { from?: string | null; subject?: string | null; receivedAt?: string | null; messageId?: string }
|
||||
) {
|
||||
// Store in WORM archive
|
||||
const doc = await uploadDocument(supabase, userId, companyId, {
|
||||
name: file.name,
|
||||
buffer: file.buffer,
|
||||
type: file.type,
|
||||
}, {
|
||||
upload_source: source === 'email' ? 'email' : 'file_upload',
|
||||
})
|
||||
|
||||
// Classify with AI
|
||||
let classificationResult
|
||||
let classificationError: string | null = null
|
||||
try {
|
||||
classificationResult = await classifyDocument({
|
||||
fileBuffer: Buffer.from(file.buffer),
|
||||
mimeType: file.type,
|
||||
fileName: file.name,
|
||||
})
|
||||
} catch (err) {
|
||||
classificationError = err instanceof Error ? err.message : 'Classification failed'
|
||||
}
|
||||
|
||||
// Supplier matching
|
||||
let matchedSupplierId: string | null = null
|
||||
if (classificationResult?.documentType === 'supplier_invoice' && classificationResult.extractedData) {
|
||||
const extractedData = classificationResult.extractedData as InvoiceExtractionResult
|
||||
const orgNumber = extractedData.supplier?.orgNumber
|
||||
const supplierName = extractedData.supplier?.name
|
||||
|
||||
if (orgNumber) {
|
||||
const normalized = orgNumber.replace(/\D/g, '')
|
||||
const { data: s } = await supabase
|
||||
.from('suppliers')
|
||||
.select('id')
|
||||
.eq('company_id', companyId)
|
||||
.eq('org_number', normalized)
|
||||
.limit(1)
|
||||
.maybeSingle()
|
||||
if (s) matchedSupplierId = s.id
|
||||
}
|
||||
if (!matchedSupplierId && supplierName) {
|
||||
const { data: s } = await supabase
|
||||
.from('suppliers')
|
||||
.select('id')
|
||||
.eq('company_id', companyId)
|
||||
.ilike('name', supplierName)
|
||||
.limit(1)
|
||||
.maybeSingle()
|
||||
if (s) matchedSupplierId = s.id
|
||||
}
|
||||
}
|
||||
|
||||
// Create inbox item
|
||||
const { data: inbox, error: inboxError } = await supabase
|
||||
.from('invoice_inbox_items')
|
||||
.insert({
|
||||
company_id: companyId,
|
||||
user_id: userId,
|
||||
status: classificationError ? 'error' : 'ready',
|
||||
source,
|
||||
document_id: doc.id,
|
||||
document_type: classificationResult?.documentType || 'unknown',
|
||||
extracted_data: classificationResult?.extractedData || null,
|
||||
raw_llm_response: classificationResult?.rawResponse || null,
|
||||
confidence: classificationResult?.confidence
|
||||
? classificationResult.confidence / 100
|
||||
: null,
|
||||
matched_supplier_id: matchedSupplierId,
|
||||
email_from: emailMeta?.from || null,
|
||||
email_subject: emailMeta?.subject || null,
|
||||
email_received_at: emailMeta?.receivedAt || null,
|
||||
raw_email_payload: emailMeta?.messageId
|
||||
? { messageId: emailMeta.messageId, filename: file.name }
|
||||
: null,
|
||||
error_message: classificationError,
|
||||
})
|
||||
.select('id, status, document_type, confidence, matched_supplier_id, error_message')
|
||||
.single()
|
||||
|
||||
if (inboxError) throw new Error(`Failed to create inbox item: ${inboxError.message}`)
|
||||
|
||||
return {
|
||||
document_id: doc.id,
|
||||
inbox_item_id: inbox.id,
|
||||
status: inbox.status,
|
||||
document_type: inbox.document_type,
|
||||
extracted_data: classificationResult?.extractedData || null,
|
||||
confidence: inbox.confidence,
|
||||
matched_supplier_id: inbox.matched_supplier_id,
|
||||
error_message: inbox.error_message,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Extension definition ─────────────────────────────────────
|
||||
|
||||
export const invoiceInboxExtension: Extension = {
|
||||
id: 'invoice-inbox',
|
||||
name: 'Dokumentinkorg',
|
||||
version: '1.0.0',
|
||||
|
||||
apiRoutes: [
|
||||
// ── Upload ──────────────────────────────────────────────
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/upload',
|
||||
handler: async (request: Request, ctx?: ExtensionContext) => {
|
||||
if (!ctx) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
|
||||
const formData = await request.formData()
|
||||
const file = formData.get('file') as File | null
|
||||
|
||||
if (!file) {
|
||||
return NextResponse.json({ error: 'No file provided' }, { status: 400 })
|
||||
}
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
return NextResponse.json({ error: `File too large (max ${MAX_FILE_SIZE / 1024 / 1024} MB)` }, { status: 400 })
|
||||
}
|
||||
if (!UPLOAD_ALLOWED_MIME_TYPES.has(file.type)) {
|
||||
return NextResponse.json(
|
||||
{ error: `Unsupported file type: ${file.type}. Allowed: PDF, JPEG, PNG, HEIC, WebP` },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const buffer = await file.arrayBuffer()
|
||||
const result = await uploadAndClassify(
|
||||
ctx.supabase,
|
||||
ctx.userId,
|
||||
ctx.companyId,
|
||||
{ name: file.name, buffer, type: file.type },
|
||||
'upload'
|
||||
)
|
||||
return NextResponse.json({ data: result })
|
||||
} catch (error) {
|
||||
console.error('[invoice-inbox/upload] Failed:', error)
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Upload failed' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
// ── List inbox items ────────────────────────────────────
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/items',
|
||||
handler: async (request: Request, ctx?: ExtensionContext) => {
|
||||
if (!ctx) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
|
||||
const url = new URL(request.url)
|
||||
const status = url.searchParams.get('status')
|
||||
const documentType = url.searchParams.get('document_type')
|
||||
const limit = Math.min(Math.max(1, Number(url.searchParams.get('limit')) || 20), 50)
|
||||
|
||||
let query = ctx.supabase
|
||||
.from('invoice_inbox_items')
|
||||
.select('id, status, document_type, confidence, source, created_at, extracted_data, matched_supplier_id, email_from, email_subject, error_message')
|
||||
.eq('company_id', ctx.companyId)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(limit)
|
||||
|
||||
if (status) query = query.eq('status', status)
|
||||
if (documentType) query = query.eq('document_type', documentType)
|
||||
|
||||
const { data, error } = await query
|
||||
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
|
||||
return NextResponse.json({ data: { items: data, count: data?.length ?? 0 } })
|
||||
},
|
||||
},
|
||||
|
||||
// ── Get single inbox item ───────────────────────────────
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/items/:id',
|
||||
handler: async (request: Request, ctx?: ExtensionContext) => {
|
||||
if (!ctx) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
|
||||
const url = new URL(request.url)
|
||||
const id = url.searchParams.get('_id')
|
||||
if (!id) return NextResponse.json({ error: 'Missing id' }, { status: 400 })
|
||||
|
||||
const { data, error } = await ctx.supabase
|
||||
.from('invoice_inbox_items')
|
||||
.select('*')
|
||||
.eq('id', id)
|
||||
.eq('company_id', ctx.companyId)
|
||||
.single()
|
||||
|
||||
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
if (!data) return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
||||
|
||||
return NextResponse.json({ data })
|
||||
},
|
||||
},
|
||||
|
||||
// ── Gmail OAuth: get auth URL ───────────────────────────
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/gmail/auth',
|
||||
handler: async (_request: Request, ctx?: ExtensionContext) => {
|
||||
if (!ctx) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
|
||||
const clientId = process.env.GOOGLE_CLIENT_ID
|
||||
if (!clientId) {
|
||||
return NextResponse.json({ error: 'Google OAuth not configured' }, { status: 500 })
|
||||
}
|
||||
if (!process.env.GMAIL_TOKEN_ENCRYPTION_KEY) {
|
||||
return NextResponse.json({ error: 'GMAIL_TOKEN_ENCRYPTION_KEY is required' }, { status: 500 })
|
||||
}
|
||||
|
||||
const appUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'
|
||||
const redirectUri = `${appUrl}/api/extensions/ext/invoice-inbox/gmail/callback`
|
||||
|
||||
const state = encryptState({
|
||||
companyId: ctx.companyId,
|
||||
userId: ctx.userId,
|
||||
exp: Date.now() + STATE_TTL_MS,
|
||||
})
|
||||
|
||||
const params = new URLSearchParams({
|
||||
client_id: clientId,
|
||||
redirect_uri: redirectUri,
|
||||
response_type: 'code',
|
||||
scope: 'https://www.googleapis.com/auth/gmail.readonly https://www.googleapis.com/auth/gmail.labels',
|
||||
access_type: 'offline',
|
||||
prompt: 'consent',
|
||||
state,
|
||||
})
|
||||
|
||||
return NextResponse.json({ data: { authUrl: `https://accounts.google.com/o/oauth2/v2/auth?${params}` } })
|
||||
},
|
||||
},
|
||||
|
||||
// ── Gmail OAuth: callback (skipAuth — redirect from Google) ─
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/gmail/callback',
|
||||
skipAuth: true,
|
||||
handler: async (request: Request) => {
|
||||
const url = new URL(request.url)
|
||||
const code = url.searchParams.get('code')
|
||||
const stateParam = url.searchParams.get('state')
|
||||
const error = url.searchParams.get('error')
|
||||
|
||||
const appUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'
|
||||
|
||||
if (error) {
|
||||
console.error('[gmail/callback] OAuth error:', error)
|
||||
return NextResponse.redirect(`${appUrl}/settings/banking?error=gmail_auth_denied`)
|
||||
}
|
||||
if (!code || !stateParam) {
|
||||
return NextResponse.redirect(`${appUrl}/settings/banking?error=gmail_missing_params`)
|
||||
}
|
||||
if (!process.env.GMAIL_TOKEN_ENCRYPTION_KEY) {
|
||||
return NextResponse.redirect(`${appUrl}/settings/banking?error=gmail_config_error`)
|
||||
}
|
||||
|
||||
const state = decryptState(stateParam) as { companyId: string; userId: string; exp: number } | null
|
||||
if (!state || Date.now() > state.exp) {
|
||||
return NextResponse.redirect(`${appUrl}/settings/banking?error=gmail_invalid_state`)
|
||||
}
|
||||
|
||||
const { companyId, userId } = state
|
||||
const redirectUri = `${appUrl}/api/extensions/ext/invoice-inbox/gmail/callback`
|
||||
|
||||
try {
|
||||
// Exchange code for tokens
|
||||
const tokenResponse = await fetch('https://oauth2.googleapis.com/token', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
code,
|
||||
client_id: process.env.GOOGLE_CLIENT_ID!,
|
||||
client_secret: process.env.GOOGLE_CLIENT_SECRET!,
|
||||
redirect_uri: redirectUri,
|
||||
grant_type: 'authorization_code',
|
||||
}),
|
||||
})
|
||||
|
||||
if (!tokenResponse.ok) {
|
||||
console.error('[gmail/callback] Token exchange failed:', await tokenResponse.text())
|
||||
return NextResponse.redirect(`${appUrl}/settings/banking?error=gmail_token_exchange`)
|
||||
}
|
||||
|
||||
const tokens = await tokenResponse.json() as {
|
||||
access_token: string; refresh_token?: string
|
||||
}
|
||||
if (!tokens.refresh_token) {
|
||||
return NextResponse.redirect(`${appUrl}/settings/banking?error=gmail_no_refresh_token`)
|
||||
}
|
||||
|
||||
// Get user email
|
||||
const profileResponse = await fetch('https://gmail.googleapis.com/gmail/v1/users/me/profile', {
|
||||
headers: { Authorization: `Bearer ${tokens.access_token}` },
|
||||
})
|
||||
if (!profileResponse.ok) {
|
||||
return NextResponse.redirect(`${appUrl}/settings/banking?error=gmail_profile_error`)
|
||||
}
|
||||
const profile = await profileResponse.json() as { emailAddress: string }
|
||||
|
||||
// Create gnubok-processed label
|
||||
let gmailLabelId: string | null = null
|
||||
try {
|
||||
const labelsResponse = await fetch('https://gmail.googleapis.com/gmail/v1/users/me/labels', {
|
||||
headers: { Authorization: `Bearer ${tokens.access_token}` },
|
||||
})
|
||||
const labelsData = await labelsResponse.json() as { labels: { id: string; name: string }[] }
|
||||
const existing = labelsData.labels?.find((l) => l.name === 'gnubok-processed')
|
||||
|
||||
if (existing) {
|
||||
gmailLabelId = existing.id
|
||||
} else {
|
||||
const createLabelResponse = await fetch('https://gmail.googleapis.com/gmail/v1/users/me/labels', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens.access_token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: 'gnubok-processed',
|
||||
labelListVisibility: 'labelShow',
|
||||
messageListVisibility: 'show',
|
||||
}),
|
||||
})
|
||||
if (createLabelResponse.ok) {
|
||||
const label = await createLabelResponse.json() as { id: string }
|
||||
gmailLabelId = label.id
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[gmail/callback] Failed to create Gmail label:', err)
|
||||
}
|
||||
|
||||
// Store connection (service-role — no auth cookie in callback)
|
||||
const supabase = createClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.SUPABASE_SERVICE_ROLE_KEY!
|
||||
)
|
||||
|
||||
const { error: dbError } = await supabase
|
||||
.from('email_connections')
|
||||
.upsert(
|
||||
{
|
||||
company_id: companyId,
|
||||
user_id: userId,
|
||||
provider: 'gmail',
|
||||
email_address: profile.emailAddress,
|
||||
encrypted_token: encryptToken(tokens.refresh_token),
|
||||
gmail_label_id: gmailLabelId,
|
||||
status: 'active',
|
||||
error_message: null,
|
||||
},
|
||||
{ onConflict: 'company_id,email_address' }
|
||||
)
|
||||
|
||||
if (dbError) {
|
||||
console.error('[gmail/callback] DB insert failed:', dbError)
|
||||
return NextResponse.redirect(`${appUrl}/settings/banking?error=gmail_db_error`)
|
||||
}
|
||||
|
||||
console.log(`[gmail/callback] Gmail connected for ${profile.emailAddress} (company ${companyId})`)
|
||||
return NextResponse.redirect(`${appUrl}/settings/banking?gmail=connected`)
|
||||
} catch (err) {
|
||||
console.error('[gmail/callback] Unexpected error:', err)
|
||||
return NextResponse.redirect(`${appUrl}/settings/banking?error=gmail_unexpected`)
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
// ── Gmail: disconnect ───────────────────────────────────
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/gmail/disconnect',
|
||||
handler: async (_request: Request, ctx?: ExtensionContext) => {
|
||||
if (!ctx) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
|
||||
const { error } = await ctx.supabase
|
||||
.from('email_connections')
|
||||
.delete()
|
||||
.eq('company_id', ctx.companyId)
|
||||
.eq('provider', 'gmail')
|
||||
|
||||
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
return NextResponse.json({ data: { disconnected: true } })
|
||||
},
|
||||
},
|
||||
|
||||
// ── Gmail: connection status ────────────────────────────
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/gmail/status',
|
||||
handler: async (_request: Request, ctx?: ExtensionContext) => {
|
||||
if (!ctx) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
|
||||
const { data, error } = await ctx.supabase
|
||||
.from('email_connections')
|
||||
.select('id, email_address, status, last_sync_at, error_message, created_at')
|
||||
.eq('company_id', ctx.companyId)
|
||||
.eq('provider', 'gmail')
|
||||
|
||||
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
return NextResponse.json({ data: { connections: data || [] } })
|
||||
},
|
||||
},
|
||||
|
||||
// ── Gmail: manual scan trigger ──────────────────────────
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/gmail/scan',
|
||||
handler: async (_request: Request, ctx?: ExtensionContext) => {
|
||||
if (!ctx) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
|
||||
if (!process.env.GMAIL_TOKEN_ENCRYPTION_KEY) {
|
||||
return NextResponse.json({ error: 'GMAIL_TOKEN_ENCRYPTION_KEY is required' }, { status: 500 })
|
||||
}
|
||||
|
||||
const { data: connections, error: connError } = await ctx.supabase
|
||||
.from('email_connections')
|
||||
.select('*')
|
||||
.eq('company_id', ctx.companyId)
|
||||
.eq('status', 'active')
|
||||
|
||||
if (connError) return NextResponse.json({ error: 'Failed to fetch connections' }, { status: 500 })
|
||||
if (!connections?.length) {
|
||||
return NextResponse.json({ data: { message: 'No active Gmail connections', scanned: 0 } })
|
||||
}
|
||||
|
||||
let totalScanned = 0, totalClassified = 0, totalSkipped = 0, totalErrors = 0
|
||||
|
||||
for (const connection of connections) {
|
||||
const result = await scanGmailConnection(ctx.supabase, connection, ctx.userId, ctx.companyId)
|
||||
totalScanned += result.scanned
|
||||
totalClassified += result.classified
|
||||
totalSkipped += result.skipped
|
||||
totalErrors += result.errors
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
data: { scanned: totalScanned, classified: totalClassified, skipped: totalSkipped, errors: totalErrors },
|
||||
})
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,558 @@
|
||||
/**
|
||||
* Document Classification Pipeline
|
||||
*
|
||||
* Pure function: file buffer + mime type → structured classification result.
|
||||
* No database, no side effects. Uses AWS Bedrock (Claude Sonnet) for vision-based
|
||||
* extraction of Swedish financial documents.
|
||||
*/
|
||||
|
||||
import {
|
||||
BedrockRuntimeClient,
|
||||
ConverseCommand,
|
||||
type ContentBlock,
|
||||
type ToolConfiguration,
|
||||
type Message,
|
||||
} from '@aws-sdk/client-bedrock-runtime'
|
||||
import sharp from 'sharp'
|
||||
import type {
|
||||
InvoiceExtractionResult,
|
||||
ReceiptExtractionResult,
|
||||
ExtractedLineItem,
|
||||
ExtractedInvoiceLineItem,
|
||||
VatBreakdownItem,
|
||||
} from '@/types'
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────
|
||||
|
||||
export type DocumentClassificationType = 'supplier_invoice' | 'receipt' | 'government_letter' | 'unknown'
|
||||
|
||||
export interface ClassificationInput {
|
||||
fileBuffer: Buffer
|
||||
mimeType: string
|
||||
fileName: string
|
||||
}
|
||||
|
||||
export interface ClassificationResult {
|
||||
documentType: DocumentClassificationType
|
||||
extractedData: InvoiceExtractionResult | ReceiptExtractionResult | null
|
||||
confidence: number
|
||||
rawResponse: Record<string, unknown>
|
||||
usage: { inputTokens: number; outputTokens: number }
|
||||
}
|
||||
|
||||
// ── Bedrock client (lazy singleton) ──────────────────────────
|
||||
|
||||
let _client: BedrockRuntimeClient | null = null
|
||||
|
||||
function getClient(): BedrockRuntimeClient {
|
||||
if (!_client) {
|
||||
_client = new BedrockRuntimeClient({
|
||||
region: process.env.AWS_REGION || 'eu-north-1',
|
||||
credentials: {
|
||||
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
|
||||
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
|
||||
},
|
||||
})
|
||||
}
|
||||
return _client
|
||||
}
|
||||
|
||||
// ── Constants ────────────────────────────────────────────────
|
||||
|
||||
const VALID_VAT_RATES = [0, 6, 12, 25]
|
||||
|
||||
const MIME_TO_IMAGE_FORMAT: Record<string, string> = {
|
||||
'image/jpeg': 'jpeg',
|
||||
'image/png': 'png',
|
||||
'image/webp': 'webp',
|
||||
'image/gif': 'gif',
|
||||
}
|
||||
|
||||
// ── System prompt ────────────────────────────────────────────
|
||||
|
||||
const SYSTEM_PROMPT = `Du är en svensk bokföringsdokumentklassificerare och dataextraktor.
|
||||
|
||||
Du analyserar bilder och PDF:er av finansiella dokument (leverantörsfakturor, kvitton, skattedokument) och extraherar strukturerad data.
|
||||
|
||||
Kontext:
|
||||
- Svensk bokföring enligt Bokföringslagen (BFL) och BFNAR
|
||||
- Giltiga momssatser: 0%, 6%, 12%, 25%
|
||||
- Organisationsnummer: 10 siffror, format XXXXXX-XXXX
|
||||
- Vanliga betalningsmetoder: bankgiro, plusgiro, Swish, banköverföring, kort
|
||||
- Valutor: SEK är standard, men EUR, USD, GBP etc förekommer
|
||||
|
||||
Instruktioner:
|
||||
- Klassificera dokumenttypen
|
||||
- Extrahera alla synliga fält — returnera null för fält som inte kan utläsas
|
||||
- Belopp ska vara positiva tal med max 2 decimaler
|
||||
- Datum i ISO-format (YYYY-MM-DD)
|
||||
- Momssats som heltal (0, 6, 12, eller 25)
|
||||
- Ge en confidence-poäng 0-100 för hur säker du är på klassificeringen och extraktionen
|
||||
|
||||
Anropa ALLTID verktyget classify_document med resultatet.`
|
||||
|
||||
// ── Tool schema for structured output ────────────────────────
|
||||
|
||||
const CLASSIFICATION_TOOL: ToolConfiguration = {
|
||||
tools: [
|
||||
{
|
||||
toolSpec: {
|
||||
name: 'classify_document',
|
||||
description: 'Klassificera och extrahera data från ett svenskt finansiellt dokument',
|
||||
inputSchema: {
|
||||
json: {
|
||||
type: 'object',
|
||||
required: ['document_type', 'confidence'],
|
||||
properties: {
|
||||
document_type: {
|
||||
type: 'string',
|
||||
enum: ['supplier_invoice', 'receipt', 'government_letter', 'unknown'],
|
||||
description: 'Typ av dokument',
|
||||
},
|
||||
confidence: {
|
||||
type: 'integer',
|
||||
minimum: 0,
|
||||
maximum: 100,
|
||||
description: 'Säkerhet i klassificeringen (0-100)',
|
||||
},
|
||||
// Supplier invoice fields
|
||||
supplier_name: { type: ['string', 'null'] },
|
||||
supplier_org_number: { type: ['string', 'null'] },
|
||||
supplier_vat_number: { type: ['string', 'null'] },
|
||||
supplier_address: { type: ['string', 'null'] },
|
||||
supplier_bankgiro: { type: ['string', 'null'] },
|
||||
supplier_plusgiro: { type: ['string', 'null'] },
|
||||
invoice_number: { type: ['string', 'null'] },
|
||||
invoice_date: { type: ['string', 'null'], description: 'YYYY-MM-DD' },
|
||||
due_date: { type: ['string', 'null'], description: 'YYYY-MM-DD' },
|
||||
payment_reference: { type: ['string', 'null'], description: 'OCR-nummer eller betalningsreferens' },
|
||||
currency: { type: ['string', 'null'], description: 'ISO 4217 (t.ex. SEK, EUR)' },
|
||||
// Receipt fields
|
||||
merchant_name: { type: ['string', 'null'] },
|
||||
merchant_org_number: { type: ['string', 'null'] },
|
||||
merchant_vat_number: { type: ['string', 'null'] },
|
||||
merchant_is_foreign: { type: ['boolean', 'null'] },
|
||||
receipt_date: { type: ['string', 'null'], description: 'YYYY-MM-DD' },
|
||||
receipt_time: { type: ['string', 'null'], description: 'HH:MM' },
|
||||
is_restaurant: { type: ['boolean', 'null'] },
|
||||
is_systembolaget: { type: ['boolean', 'null'] },
|
||||
// Shared amount fields
|
||||
amount_excl_vat: { type: ['number', 'null'] },
|
||||
amount_incl_vat: { type: ['number', 'null'] },
|
||||
vat_amount: { type: ['number', 'null'] },
|
||||
// Line items
|
||||
line_items: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
description: { type: 'string' },
|
||||
quantity: { type: ['number', 'null'] },
|
||||
unit_price: { type: ['number', 'null'] },
|
||||
amount: { type: ['number', 'null'] },
|
||||
vat_rate: { type: ['integer', 'null'], description: '0, 6, 12, or 25' },
|
||||
},
|
||||
required: ['description'],
|
||||
},
|
||||
},
|
||||
// VAT breakdown (for invoices)
|
||||
vat_breakdown: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
rate: { type: 'integer' },
|
||||
base: { type: 'number' },
|
||||
amount: { type: 'number' },
|
||||
},
|
||||
required: ['rate', 'base', 'amount'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
toolChoice: { any: {} },
|
||||
}
|
||||
|
||||
// ── Core classification function ─────────────────────────────
|
||||
|
||||
export async function classifyDocument(input: ClassificationInput): Promise<ClassificationResult> {
|
||||
const contentBlock = await buildContentBlock(input)
|
||||
|
||||
const messages: Message[] = [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
contentBlock,
|
||||
{ text: 'Analysera detta dokument. Klassificera typ och extrahera all strukturerad data.' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const modelId = process.env.BEDROCK_MODEL_ID || 'eu.anthropic.claude-sonnet-4-6'
|
||||
const maxTokens = parseInt(process.env.BEDROCK_MAX_TOKENS || '8192', 10)
|
||||
|
||||
const command = new ConverseCommand({
|
||||
modelId,
|
||||
messages,
|
||||
system: [{ text: SYSTEM_PROMPT }],
|
||||
toolConfig: CLASSIFICATION_TOOL,
|
||||
inferenceConfig: { maxTokens },
|
||||
})
|
||||
|
||||
const response = await getClient().send(command)
|
||||
|
||||
// Extract tool use result from response
|
||||
const outputMessage = response.output?.message
|
||||
if (!outputMessage?.content) {
|
||||
throw new Error('No content in Bedrock response')
|
||||
}
|
||||
|
||||
const toolUseBlock = outputMessage.content.find(
|
||||
(block): block is ContentBlock.ToolUseMember => 'toolUse' in block && block.toolUse !== undefined
|
||||
)
|
||||
|
||||
if (!toolUseBlock?.toolUse?.input) {
|
||||
throw new Error('No tool use result in Bedrock response')
|
||||
}
|
||||
|
||||
const rawData = toolUseBlock.toolUse.input as Record<string, unknown>
|
||||
const usage = {
|
||||
inputTokens: response.usage?.inputTokens ?? 0,
|
||||
outputTokens: response.usage?.outputTokens ?? 0,
|
||||
}
|
||||
|
||||
// Validate and map to typed result
|
||||
const result = mapToClassificationResult(rawData, usage)
|
||||
|
||||
// If validation found issues, retry once with correction
|
||||
if (!result) {
|
||||
return retryWithCorrection(input, rawData, usage)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// ── Content block builder ────────────────────────────────────
|
||||
|
||||
async function buildContentBlock(input: ClassificationInput): Promise<ContentBlock> {
|
||||
const { fileBuffer, mimeType } = input
|
||||
|
||||
// PDF → document block
|
||||
if (mimeType === 'application/pdf') {
|
||||
return {
|
||||
document: {
|
||||
format: 'pdf',
|
||||
name: sanitizeDocName(input.fileName),
|
||||
source: {
|
||||
bytes: new Uint8Array(fileBuffer),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// HEIC → convert to JPEG via sharp
|
||||
if (mimeType === 'image/heic' || mimeType === 'image/heif') {
|
||||
const jpegBuffer = await sharp(fileBuffer).jpeg({ quality: 90 }).toBuffer()
|
||||
return {
|
||||
image: {
|
||||
format: 'jpeg',
|
||||
source: { bytes: new Uint8Array(jpegBuffer) },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Standard image formats
|
||||
const imageFormat = MIME_TO_IMAGE_FORMAT[mimeType]
|
||||
if (imageFormat) {
|
||||
return {
|
||||
image: {
|
||||
format: imageFormat as 'jpeg' | 'png' | 'webp' | 'gif',
|
||||
source: { bytes: new Uint8Array(fileBuffer) },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Unsupported MIME type: ${mimeType}`)
|
||||
}
|
||||
|
||||
/** Sanitize filename for Bedrock document name (alphanumeric, spaces, hyphens, brackets only) */
|
||||
function sanitizeDocName(fileName: string): string {
|
||||
const name = fileName.replace(/\.[^.]+$/, '') // strip extension
|
||||
return name.replace(/[^a-zA-Z0-9\s\-\[\]\(\)åäöÅÄÖ]/g, '').trim() || 'document'
|
||||
}
|
||||
|
||||
// ── Response mapping + validation ────────────────────────────
|
||||
|
||||
function mapToClassificationResult(
|
||||
raw: Record<string, unknown>,
|
||||
usage: { inputTokens: number; outputTokens: number }
|
||||
): ClassificationResult | null {
|
||||
const documentType = raw.document_type as DocumentClassificationType
|
||||
const confidence = Math.min(100, Math.max(0, Number(raw.confidence) || 0))
|
||||
|
||||
if (!['supplier_invoice', 'receipt', 'government_letter', 'unknown'].includes(documentType)) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (documentType === 'government_letter' || documentType === 'unknown') {
|
||||
return {
|
||||
documentType,
|
||||
extractedData: null,
|
||||
confidence,
|
||||
rawResponse: raw,
|
||||
usage,
|
||||
}
|
||||
}
|
||||
|
||||
if (documentType === 'supplier_invoice') {
|
||||
const extractedData = mapToInvoiceExtraction(raw)
|
||||
if (!extractedData) return null
|
||||
return { documentType, extractedData, confidence, rawResponse: raw, usage }
|
||||
}
|
||||
|
||||
if (documentType === 'receipt') {
|
||||
const extractedData = mapToReceiptExtraction(raw)
|
||||
if (!extractedData) return null
|
||||
return { documentType, extractedData, confidence, rawResponse: raw, usage }
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function mapToInvoiceExtraction(raw: Record<string, unknown>): InvoiceExtractionResult | null {
|
||||
const lineItems = mapInvoiceLineItems(raw.line_items)
|
||||
const vatBreakdown = mapVatBreakdown(raw.vat_breakdown)
|
||||
|
||||
const result: InvoiceExtractionResult = {
|
||||
supplier: {
|
||||
name: strOrNull(raw.supplier_name),
|
||||
orgNumber: strOrNull(raw.supplier_org_number),
|
||||
vatNumber: strOrNull(raw.supplier_vat_number),
|
||||
address: strOrNull(raw.supplier_address),
|
||||
bankgiro: strOrNull(raw.supplier_bankgiro),
|
||||
plusgiro: strOrNull(raw.supplier_plusgiro),
|
||||
},
|
||||
invoice: {
|
||||
invoiceNumber: strOrNull(raw.invoice_number),
|
||||
invoiceDate: dateOrNull(raw.invoice_date),
|
||||
dueDate: dateOrNull(raw.due_date),
|
||||
paymentReference: strOrNull(raw.payment_reference),
|
||||
currency: strOrNull(raw.currency) || 'SEK',
|
||||
},
|
||||
lineItems,
|
||||
totals: {
|
||||
subtotal: roundAmount(raw.amount_excl_vat),
|
||||
vatAmount: roundAmount(raw.vat_amount),
|
||||
total: roundAmount(raw.amount_incl_vat),
|
||||
},
|
||||
vatBreakdown,
|
||||
confidence: Math.min(1, Math.max(0, Number(raw.confidence) / 100 || 0)),
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
function mapToReceiptExtraction(raw: Record<string, unknown>): ReceiptExtractionResult | null {
|
||||
const lineItems = mapReceiptLineItems(raw.line_items)
|
||||
|
||||
const result: ReceiptExtractionResult = {
|
||||
merchant: {
|
||||
name: strOrNull(raw.merchant_name),
|
||||
orgNumber: strOrNull(raw.merchant_org_number),
|
||||
vatNumber: strOrNull(raw.merchant_vat_number),
|
||||
isForeign: Boolean(raw.merchant_is_foreign),
|
||||
},
|
||||
receipt: {
|
||||
date: dateOrNull(raw.receipt_date),
|
||||
time: strOrNull(raw.receipt_time),
|
||||
currency: strOrNull(raw.currency) || 'SEK',
|
||||
},
|
||||
lineItems,
|
||||
totals: {
|
||||
subtotal: roundAmount(raw.amount_excl_vat),
|
||||
vatAmount: roundAmount(raw.vat_amount),
|
||||
total: roundAmount(raw.amount_incl_vat),
|
||||
},
|
||||
flags: {
|
||||
isRestaurant: Boolean(raw.is_restaurant),
|
||||
isSystembolaget: Boolean(raw.is_systembolaget),
|
||||
isForeignMerchant: Boolean(raw.merchant_is_foreign),
|
||||
},
|
||||
confidence: Math.min(1, Math.max(0, Number(raw.confidence) / 100 || 0)),
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// ── Line item mappers ────────────────────────────────────────
|
||||
|
||||
function mapInvoiceLineItems(items: unknown): ExtractedInvoiceLineItem[] {
|
||||
if (!Array.isArray(items)) return []
|
||||
return items
|
||||
.filter((item): item is Record<string, unknown> => typeof item === 'object' && item !== null)
|
||||
.map((item) => ({
|
||||
description: String(item.description || ''),
|
||||
quantity: typeof item.quantity === 'number' ? item.quantity : 1,
|
||||
unitPrice: roundAmount(item.unit_price),
|
||||
lineTotal: roundAmount(item.amount) ?? 0,
|
||||
vatRate: validateVatRate(item.vat_rate),
|
||||
accountSuggestion: null,
|
||||
}))
|
||||
}
|
||||
|
||||
function mapReceiptLineItems(items: unknown): ExtractedLineItem[] {
|
||||
if (!Array.isArray(items)) return []
|
||||
return items
|
||||
.filter((item): item is Record<string, unknown> => typeof item === 'object' && item !== null)
|
||||
.map((item) => ({
|
||||
description: String(item.description || ''),
|
||||
quantity: typeof item.quantity === 'number' ? item.quantity : 1,
|
||||
unitPrice: roundAmount(item.unit_price),
|
||||
lineTotal: roundAmount(item.amount) ?? 0,
|
||||
vatRate: validateVatRate(item.vat_rate),
|
||||
suggestedCategory: null,
|
||||
}))
|
||||
}
|
||||
|
||||
function mapVatBreakdown(items: unknown): VatBreakdownItem[] {
|
||||
if (!Array.isArray(items)) return []
|
||||
return items
|
||||
.filter((item): item is Record<string, unknown> => typeof item === 'object' && item !== null)
|
||||
.filter((item) => VALID_VAT_RATES.includes(Number(item.rate)))
|
||||
.map((item) => ({
|
||||
rate: Number(item.rate),
|
||||
base: Math.round(Number(item.base || 0) * 100) / 100,
|
||||
amount: Math.round(Number(item.amount || 0) * 100) / 100,
|
||||
}))
|
||||
}
|
||||
|
||||
// ── Retry with correction ────────────────────────────────────
|
||||
|
||||
async function retryWithCorrection(
|
||||
input: ClassificationInput,
|
||||
previousResult: Record<string, unknown>,
|
||||
previousUsage: { inputTokens: number; outputTokens: number }
|
||||
): Promise<ClassificationResult> {
|
||||
const contentBlock = await buildContentBlock(input)
|
||||
|
||||
const messages: Message[] = [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
contentBlock,
|
||||
{ text: 'Analysera detta dokument. Klassificera typ och extrahera all strukturerad data.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
toolUse: {
|
||||
toolUseId: 'retry_1',
|
||||
name: 'classify_document',
|
||||
input: previousResult as Record<string, unknown>,
|
||||
} as ContentBlock.ToolUseMember['toolUse'],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{
|
||||
toolResult: {
|
||||
toolUseId: 'retry_1',
|
||||
status: 'error',
|
||||
content: [
|
||||
{
|
||||
text: `Valideringen misslyckades. Kontrollera:
|
||||
- document_type måste vara ett av: supplier_invoice, receipt, government_letter, unknown
|
||||
- Datum i format YYYY-MM-DD
|
||||
- Momssatser måste vara 0, 6, 12, eller 25
|
||||
- Belopp ska vara positiva tal
|
||||
Försök igen med korrigerad data.`,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const modelId = process.env.BEDROCK_MODEL_ID || 'eu.anthropic.claude-sonnet-4-6'
|
||||
const maxTokens = parseInt(process.env.BEDROCK_MAX_TOKENS || '8192', 10)
|
||||
|
||||
try {
|
||||
const command = new ConverseCommand({
|
||||
modelId,
|
||||
messages,
|
||||
system: [{ text: SYSTEM_PROMPT }],
|
||||
toolConfig: CLASSIFICATION_TOOL,
|
||||
inferenceConfig: { maxTokens },
|
||||
})
|
||||
|
||||
const response = await getClient().send(command)
|
||||
const outputMessage = response.output?.message
|
||||
const toolUseBlock = outputMessage?.content?.find(
|
||||
(block): block is ContentBlock.ToolUseMember => 'toolUse' in block && block.toolUse !== undefined
|
||||
)
|
||||
|
||||
if (!toolUseBlock?.toolUse?.input) {
|
||||
throw new Error('No tool use in retry response')
|
||||
}
|
||||
|
||||
const rawData = toolUseBlock.toolUse.input as Record<string, unknown>
|
||||
const retryUsage = {
|
||||
inputTokens: previousUsage.inputTokens + (response.usage?.inputTokens ?? 0),
|
||||
outputTokens: previousUsage.outputTokens + (response.usage?.outputTokens ?? 0),
|
||||
}
|
||||
|
||||
const result = mapToClassificationResult(rawData, retryUsage)
|
||||
if (result) return result
|
||||
} catch {
|
||||
// Retry failed — fall through to error return
|
||||
}
|
||||
|
||||
// Both attempts failed — return error result with raw data
|
||||
return {
|
||||
documentType: 'unknown',
|
||||
extractedData: null,
|
||||
confidence: 0,
|
||||
rawResponse: previousResult,
|
||||
usage: previousUsage,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Utility helpers ──────────────────────────────────────────
|
||||
|
||||
function strOrNull(val: unknown): string | null {
|
||||
if (typeof val === 'string' && val.trim().length > 0) return val.trim()
|
||||
return null
|
||||
}
|
||||
|
||||
function dateOrNull(val: unknown): string | null {
|
||||
if (typeof val !== 'string') return null
|
||||
// Validate ISO date format YYYY-MM-DD
|
||||
const match = val.match(/^\d{4}-\d{2}-\d{2}$/)
|
||||
if (!match) return null
|
||||
const d = new Date(val)
|
||||
if (isNaN(d.getTime())) return null
|
||||
return val
|
||||
}
|
||||
|
||||
function roundAmount(val: unknown): number | null {
|
||||
if (val === null || val === undefined) return null
|
||||
const n = Number(val)
|
||||
if (isNaN(n)) return null
|
||||
return Math.round(n * 100) / 100
|
||||
}
|
||||
|
||||
function validateVatRate(val: unknown): number | null {
|
||||
if (val === null || val === undefined) return null
|
||||
const n = Number(val)
|
||||
if (VALID_VAT_RATES.includes(n)) return n
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import crypto from 'crypto'
|
||||
|
||||
const ALGORITHM = 'aes-256-gcm'
|
||||
|
||||
export function getGmailEncryptionKey(): Buffer {
|
||||
const secret = process.env.GMAIL_TOKEN_ENCRYPTION_KEY
|
||||
if (!secret) throw new Error('GMAIL_TOKEN_ENCRYPTION_KEY is required')
|
||||
return crypto.createHash('sha256').update(secret).digest()
|
||||
}
|
||||
|
||||
export function encryptState(payload: Record<string, unknown>): string {
|
||||
const key = getGmailEncryptionKey()
|
||||
const iv = crypto.randomBytes(12)
|
||||
const cipher = crypto.createCipheriv(ALGORITHM, key, iv)
|
||||
const json = JSON.stringify(payload)
|
||||
const encrypted = Buffer.concat([cipher.update(json, 'utf8'), cipher.final()])
|
||||
const tag = cipher.getAuthTag()
|
||||
return Buffer.concat([iv, tag, encrypted]).toString('base64url')
|
||||
}
|
||||
|
||||
export function decryptState(encoded: string): Record<string, unknown> | null {
|
||||
try {
|
||||
const key = getGmailEncryptionKey()
|
||||
const combined = Buffer.from(encoded, 'base64url')
|
||||
const iv = combined.subarray(0, 12)
|
||||
const tag = combined.subarray(12, 28)
|
||||
const encrypted = combined.subarray(28)
|
||||
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv)
|
||||
decipher.setAuthTag(tag)
|
||||
const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()])
|
||||
return JSON.parse(decrypted.toString('utf8'))
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function encryptToken(plaintext: string): string {
|
||||
const key = getGmailEncryptionKey()
|
||||
const iv = crypto.randomBytes(12)
|
||||
const cipher = crypto.createCipheriv(ALGORITHM, key, iv)
|
||||
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()])
|
||||
const tag = cipher.getAuthTag()
|
||||
return Buffer.concat([iv, tag, encrypted]).toString('base64url')
|
||||
}
|
||||
|
||||
export function decryptToken(encoded: string): string | null {
|
||||
try {
|
||||
const key = getGmailEncryptionKey()
|
||||
const combined = Buffer.from(encoded, 'base64url')
|
||||
const iv = combined.subarray(0, 12)
|
||||
const tag = combined.subarray(12, 28)
|
||||
const encrypted = combined.subarray(28)
|
||||
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv)
|
||||
decipher.setAuthTag(tag)
|
||||
const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()])
|
||||
return decrypted.toString('utf8')
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function refreshAccessToken(refreshToken: string): Promise<string | null> {
|
||||
const response = await fetch('https://oauth2.googleapis.com/token', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
refresh_token: refreshToken,
|
||||
client_id: process.env.GOOGLE_CLIENT_ID!,
|
||||
client_secret: process.env.GOOGLE_CLIENT_SECRET!,
|
||||
grant_type: 'refresh_token',
|
||||
}),
|
||||
})
|
||||
if (!response.ok) return null
|
||||
const data = await response.json() as { access_token: string }
|
||||
return data.access_token
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { uploadDocument, computeSHA256 } from '@/lib/core/documents/document-service'
|
||||
import { classifyDocument } from './classify-document'
|
||||
import { decryptToken, refreshAccessToken } from './gmail-helpers'
|
||||
import type { InvoiceExtractionResult } from '@/types'
|
||||
|
||||
const MIN_ATTACHMENT_SIZE = 3_000
|
||||
const MAX_MESSAGES = 30
|
||||
|
||||
const ALLOWED_MIME_TYPES = new Set([
|
||||
'application/pdf',
|
||||
'application/octet-stream',
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/heic',
|
||||
'image/heif',
|
||||
'image/webp',
|
||||
])
|
||||
|
||||
const SKIP_EXTENSIONS = new Set([
|
||||
'ics', 'vcf', 'html', 'htm', 'zip', 'rar', 'gz',
|
||||
'csv', 'json', 'xml', 'txt', 'eml', 'msg',
|
||||
'mp3', 'mp4', 'mov', 'avi', 'wav',
|
||||
])
|
||||
|
||||
interface GmailMessage {
|
||||
id: string
|
||||
payload: {
|
||||
headers: { name: string; value: string }[]
|
||||
parts?: GmailPart[]
|
||||
mimeType: string
|
||||
body?: { attachmentId?: string; size?: number; data?: string }
|
||||
}
|
||||
internalDate: string
|
||||
}
|
||||
|
||||
interface GmailPart {
|
||||
mimeType: string
|
||||
filename: string
|
||||
body: { attachmentId?: string; size?: number; data?: string }
|
||||
parts?: GmailPart[]
|
||||
}
|
||||
|
||||
function getHeader(message: GmailMessage, name: string): string | null {
|
||||
return message.payload.headers.find(
|
||||
(h) => h.name.toLowerCase() === name.toLowerCase()
|
||||
)?.value ?? null
|
||||
}
|
||||
|
||||
function collectAttachments(parts: GmailPart[] | undefined): GmailPart[] {
|
||||
if (!parts) return []
|
||||
const result: GmailPart[] = []
|
||||
for (const part of parts) {
|
||||
if (part.filename && part.body?.attachmentId) {
|
||||
result.push(part)
|
||||
}
|
||||
if (part.parts) {
|
||||
result.push(...collectAttachments(part.parts))
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function resolveActualMimeType(mimeType: string, filename: string): string | null {
|
||||
const ext = filename.split('.').pop()?.toLowerCase()
|
||||
if (ext && SKIP_EXTENSIONS.has(ext)) return null
|
||||
|
||||
if (mimeType === 'application/octet-stream') {
|
||||
const extMap: Record<string, string> = {
|
||||
pdf: 'application/pdf',
|
||||
jpg: 'image/jpeg',
|
||||
jpeg: 'image/jpeg',
|
||||
png: 'image/png',
|
||||
webp: 'image/webp',
|
||||
}
|
||||
return ext && extMap[ext] ? extMap[ext] : null
|
||||
}
|
||||
|
||||
return mimeType
|
||||
}
|
||||
|
||||
export interface ScanResult {
|
||||
scanned: number
|
||||
classified: number
|
||||
skipped: number
|
||||
errors: number
|
||||
}
|
||||
|
||||
interface EmailConnection {
|
||||
id: string
|
||||
company_id: string
|
||||
encrypted_token: string
|
||||
last_sync_at: string | null
|
||||
gmail_label_id: string | null
|
||||
}
|
||||
|
||||
export async function scanGmailConnection(
|
||||
supabase: SupabaseClient,
|
||||
connection: EmailConnection,
|
||||
userId: string,
|
||||
companyId: string
|
||||
): Promise<ScanResult> {
|
||||
const result: ScanResult = { scanned: 0, classified: 0, skipped: 0, errors: 0 }
|
||||
const seenFileHashes = new Set<string>()
|
||||
|
||||
const refreshToken = decryptToken(connection.encrypted_token)
|
||||
if (!refreshToken) {
|
||||
await supabase
|
||||
.from('email_connections')
|
||||
.update({ status: 'error', error_message: 'Failed to decrypt refresh token' })
|
||||
.eq('id', connection.id)
|
||||
result.errors++
|
||||
return result
|
||||
}
|
||||
|
||||
const accessToken = await refreshAccessToken(refreshToken)
|
||||
if (!accessToken) {
|
||||
await supabase
|
||||
.from('email_connections')
|
||||
.update({ status: 'revoked', error_message: 'Token refresh failed — user may have revoked access' })
|
||||
.eq('id', connection.id)
|
||||
result.errors++
|
||||
return result
|
||||
}
|
||||
|
||||
// Build Gmail search query
|
||||
let afterDate: string
|
||||
if (connection.last_sync_at) {
|
||||
const d = new Date(connection.last_sync_at)
|
||||
afterDate = `${d.getFullYear()}/${String(d.getMonth() + 1).padStart(2, '0')}/${String(d.getDate()).padStart(2, '0')}`
|
||||
} else {
|
||||
const d = new Date(Date.now() - 14 * 24 * 60 * 60 * 1000)
|
||||
afterDate = `${d.getFullYear()}/${String(d.getMonth() + 1).padStart(2, '0')}/${String(d.getDate()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
let query = `has:attachment after:${afterDate}`
|
||||
if (connection.gmail_label_id) {
|
||||
query += ' -label:gnubok-processed'
|
||||
}
|
||||
|
||||
const listUrl = `https://gmail.googleapis.com/gmail/v1/users/me/messages?q=${encodeURIComponent(query)}&maxResults=${MAX_MESSAGES}`
|
||||
const listResponse = await fetch(listUrl, {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
})
|
||||
|
||||
if (!listResponse.ok) {
|
||||
console.error('[gmail/scan] Failed to list messages:', await listResponse.text())
|
||||
result.errors++
|
||||
return result
|
||||
}
|
||||
|
||||
const listData = await listResponse.json() as { messages?: { id: string }[] }
|
||||
const messageIds = listData.messages || []
|
||||
|
||||
for (const { id: messageId } of messageIds) {
|
||||
try {
|
||||
const msgResponse = await fetch(
|
||||
`https://gmail.googleapis.com/gmail/v1/users/me/messages/${messageId}?format=full`,
|
||||
{ headers: { Authorization: `Bearer ${accessToken}` } }
|
||||
)
|
||||
if (!msgResponse.ok) continue
|
||||
const message = await msgResponse.json() as GmailMessage
|
||||
|
||||
const emailFrom = getHeader(message, 'From')
|
||||
const emailSubject = getHeader(message, 'Subject')
|
||||
const emailDate = message.internalDate
|
||||
? new Date(parseInt(message.internalDate)).toISOString()
|
||||
: null
|
||||
|
||||
const attachments = collectAttachments(message.payload.parts)
|
||||
|
||||
for (const attachment of attachments) {
|
||||
if (!attachment.body.attachmentId) continue
|
||||
if ((attachment.body.size ?? 0) < MIN_ATTACHMENT_SIZE) continue
|
||||
if (!ALLOWED_MIME_TYPES.has(attachment.mimeType)) continue
|
||||
|
||||
const resolvedMimeType = resolveActualMimeType(attachment.mimeType, attachment.filename)
|
||||
if (!resolvedMimeType) continue
|
||||
|
||||
// Deduplicate by message ID + filename
|
||||
const { data: existing } = await supabase
|
||||
.from('invoice_inbox_items')
|
||||
.select('id')
|
||||
.eq('company_id', companyId)
|
||||
.eq('source', 'email')
|
||||
.filter('raw_email_payload->>messageId', 'eq', messageId)
|
||||
.filter('raw_email_payload->>filename', 'eq', attachment.filename)
|
||||
.limit(1)
|
||||
.maybeSingle()
|
||||
|
||||
if (existing) {
|
||||
result.skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
// Download attachment
|
||||
const attResponse = await fetch(
|
||||
`https://gmail.googleapis.com/gmail/v1/users/me/messages/${messageId}/attachments/${attachment.body.attachmentId}`,
|
||||
{ headers: { Authorization: `Bearer ${accessToken}` } }
|
||||
)
|
||||
if (!attResponse.ok) {
|
||||
result.errors++
|
||||
continue
|
||||
}
|
||||
|
||||
const attData = await attResponse.json() as { data: string }
|
||||
const fileBuffer = Buffer.from(attData.data, 'base64url')
|
||||
|
||||
// Deduplicate by file content hash
|
||||
const fileHash = await computeSHA256(fileBuffer.buffer.slice(
|
||||
fileBuffer.byteOffset,
|
||||
fileBuffer.byteOffset + fileBuffer.byteLength
|
||||
))
|
||||
if (seenFileHashes.has(fileHash)) {
|
||||
result.skipped++
|
||||
continue
|
||||
}
|
||||
const { data: existingByHash } = await supabase
|
||||
.from('document_attachments')
|
||||
.select('id')
|
||||
.eq('company_id', companyId)
|
||||
.eq('sha256_hash', fileHash)
|
||||
.limit(1)
|
||||
.maybeSingle()
|
||||
if (existingByHash) {
|
||||
result.skipped++
|
||||
seenFileHashes.add(fileHash)
|
||||
continue
|
||||
}
|
||||
seenFileHashes.add(fileHash)
|
||||
|
||||
// Store in WORM archive
|
||||
const doc = await uploadDocument(supabase, userId, companyId, {
|
||||
name: attachment.filename,
|
||||
buffer: fileBuffer.buffer.slice(
|
||||
fileBuffer.byteOffset,
|
||||
fileBuffer.byteOffset + fileBuffer.byteLength
|
||||
),
|
||||
type: resolvedMimeType,
|
||||
}, {
|
||||
upload_source: 'email',
|
||||
})
|
||||
|
||||
// Classify
|
||||
let classificationResult
|
||||
let classificationError: string | null = null
|
||||
try {
|
||||
classificationResult = await classifyDocument({
|
||||
fileBuffer,
|
||||
mimeType: resolvedMimeType,
|
||||
fileName: attachment.filename,
|
||||
})
|
||||
} catch (err) {
|
||||
classificationError = err instanceof Error ? err.message : 'Classification failed'
|
||||
console.error('[gmail/scan] Classification failed:', err)
|
||||
}
|
||||
|
||||
// Find matching supplier
|
||||
let matchedSupplierId: string | null = null
|
||||
if (classificationResult?.documentType === 'supplier_invoice' && classificationResult.extractedData) {
|
||||
const extractedData = classificationResult.extractedData as InvoiceExtractionResult
|
||||
const orgNumber = extractedData.supplier?.orgNumber
|
||||
if (orgNumber) {
|
||||
const normalized = orgNumber.replace(/\D/g, '')
|
||||
const { data: supplierByOrg } = await supabase
|
||||
.from('suppliers')
|
||||
.select('id')
|
||||
.eq('company_id', companyId)
|
||||
.eq('org_number', normalized)
|
||||
.limit(1)
|
||||
.maybeSingle()
|
||||
if (supplierByOrg) matchedSupplierId = supplierByOrg.id
|
||||
}
|
||||
}
|
||||
|
||||
// Create inbox item
|
||||
await supabase.from('invoice_inbox_items').insert({
|
||||
company_id: companyId,
|
||||
user_id: userId,
|
||||
status: classificationError ? 'error' : 'ready',
|
||||
source: 'email',
|
||||
document_id: doc.id,
|
||||
document_type: classificationResult?.documentType || 'unknown',
|
||||
extracted_data: classificationResult?.extractedData || null,
|
||||
raw_llm_response: classificationResult?.rawResponse || null,
|
||||
confidence: classificationResult?.confidence
|
||||
? classificationResult.confidence / 100
|
||||
: null,
|
||||
matched_supplier_id: matchedSupplierId,
|
||||
email_from: emailFrom,
|
||||
email_subject: emailSubject,
|
||||
email_received_at: emailDate,
|
||||
raw_email_payload: { messageId, filename: attachment.filename },
|
||||
error_message: classificationError,
|
||||
})
|
||||
|
||||
if (classificationError) {
|
||||
result.errors++
|
||||
} else {
|
||||
result.classified++
|
||||
}
|
||||
result.scanned++
|
||||
}
|
||||
|
||||
// Label message as processed
|
||||
if (connection.gmail_label_id) {
|
||||
try {
|
||||
await fetch(
|
||||
`https://gmail.googleapis.com/gmail/v1/users/me/messages/${messageId}/modify`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ addLabelIds: [connection.gmail_label_id] }),
|
||||
}
|
||||
)
|
||||
} catch {
|
||||
// Non-blocking
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[gmail/scan] Error processing message:', err)
|
||||
result.errors++
|
||||
}
|
||||
}
|
||||
|
||||
// Update last_sync_at
|
||||
await supabase
|
||||
.from('email_connections')
|
||||
.update({ last_sync_at: new Date().toISOString(), error_message: null })
|
||||
.eq('id', connection.id)
|
||||
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"id": "invoice-inbox",
|
||||
"sector": "general",
|
||||
"exportName": "invoiceInboxExtension",
|
||||
"entryPoint": "@/extensions/general/invoice-inbox",
|
||||
"workspace": null,
|
||||
"requiredEnvVars": ["AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_REGION"],
|
||||
"optionalEnvVars": ["BEDROCK_MODEL_ID", "BEDROCK_MAX_TOKENS", "GOOGLE_CLIENT_ID", "GOOGLE_CLIENT_SECRET", "GMAIL_TOKEN_ENCRYPTION_KEY"],
|
||||
"npmDependencies": ["@aws-sdk/client-bedrock-runtime"],
|
||||
"definition": {
|
||||
"name": "Dokumentinkorg",
|
||||
"category": "import",
|
||||
"icon": "Inbox",
|
||||
"dataPattern": "both",
|
||||
"hasOwnData": true,
|
||||
"readsCoreTables": ["document_attachments", "suppliers", "transactions"],
|
||||
"description": "AI-klassificering och extraktion av leverantörsfakturor och kvitton",
|
||||
"longDescription": "Ladda upp eller ta emot dokument via Gmail. AI klassificerar dokumenttyp, extraherar strukturerad data (leverantör, belopp, moms) och matchar mot transaktioner. Kräver AWS Bedrock-åtkomst."
|
||||
}
|
||||
}
|
||||
@@ -39,7 +39,8 @@ import {
|
||||
generateInvoiceEmailText,
|
||||
generateInvoiceEmailSubject,
|
||||
} from '@/lib/email/invoice-templates'
|
||||
import { uploadDocument } from '@/lib/core/documents/document-service'
|
||||
import { uploadDocument, MAX_DOCUMENT_SIZE } from '@/lib/core/documents/document-service'
|
||||
// classifyDocument is dynamically imported from invoice-inbox (may not be enabled)
|
||||
// ensureInitialized() is called by the extension router (ext/[...path]/route.ts)
|
||||
// which dispatches to this handler — no duplicate call needed here.
|
||||
import type { Transaction, TransactionCategory, EntityType, VatTreatment, Invoice, Currency, CompanySettings, Customer, InvoiceItem } from '@/types'
|
||||
@@ -77,6 +78,7 @@ interface McpTool {
|
||||
_meta?: { ui: { resourceUri: string } }
|
||||
execute: (
|
||||
args: Record<string, unknown>,
|
||||
companyId: string,
|
||||
userId: string,
|
||||
supabase: SupabaseClient
|
||||
) => Promise<unknown>
|
||||
@@ -102,6 +104,7 @@ const VALID_VAT_TREATMENTS = [
|
||||
async function stagePendingOperation(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
userId: string,
|
||||
operationType: string,
|
||||
title: string,
|
||||
params: Record<string, unknown>,
|
||||
@@ -111,6 +114,7 @@ async function stagePendingOperation(
|
||||
.from('pending_operations')
|
||||
.insert({
|
||||
company_id: companyId,
|
||||
user_id: userId,
|
||||
operation_type: operationType,
|
||||
title,
|
||||
params,
|
||||
@@ -174,7 +178,7 @@ async function categorizeTransactionCore(
|
||||
.from('transactions')
|
||||
.select('*')
|
||||
.eq('id', txId)
|
||||
.eq('company_id', userId)
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
|
||||
if (fetchError || !transaction) {
|
||||
@@ -200,7 +204,7 @@ async function categorizeTransactionCore(
|
||||
const { data: settings } = await supabase
|
||||
.from('company_settings')
|
||||
.select('entity_type, fiscal_year_start_month')
|
||||
.eq('company_id', userId)
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
|
||||
const entityType: EntityType = (settings?.entity_type as EntityType) || 'enskild_firma'
|
||||
@@ -376,7 +380,7 @@ const tools: McpTool[] = [
|
||||
idempotentHint: true,
|
||||
openWorldHint: false,
|
||||
},
|
||||
async execute(args, userId, supabase) {
|
||||
async execute(args, companyId, userId, supabase) {
|
||||
const limit = Math.min(Math.max(1, Number(args.limit) || 20), 100)
|
||||
const offset = Math.max(0, Number(args.offset) || 0)
|
||||
|
||||
@@ -384,7 +388,7 @@ const tools: McpTool[] = [
|
||||
const { count: totalCount, error: countError } = await supabase
|
||||
.from('transactions')
|
||||
.select('id', { count: 'exact', head: true })
|
||||
.eq('company_id', userId)
|
||||
.eq('company_id', companyId)
|
||||
.is('journal_entry_id', null)
|
||||
|
||||
if (countError) throw new Error(`Database error: ${countError.message}`)
|
||||
@@ -394,7 +398,7 @@ const tools: McpTool[] = [
|
||||
.select(
|
||||
'id, date, description, amount, currency, merchant_name, reference, is_business, category'
|
||||
)
|
||||
.eq('company_id', userId)
|
||||
.eq('company_id', companyId)
|
||||
.is('journal_entry_id', null)
|
||||
.order('date', { ascending: false })
|
||||
.range(offset, offset + limit - 1)
|
||||
@@ -461,14 +465,14 @@ const tools: McpTool[] = [
|
||||
idempotentHint: false,
|
||||
openWorldHint: false,
|
||||
},
|
||||
async execute(args, userId, supabase) {
|
||||
async execute(args, companyId, userId, supabase) {
|
||||
// Compute the preview (accounts, amounts, VAT lines)
|
||||
const result = await categorizeTransactionCore(
|
||||
args.transaction_id as string,
|
||||
args.category as TransactionCategory,
|
||||
args.vat_treatment as VatTreatment | undefined,
|
||||
userId,
|
||||
userId,
|
||||
companyId,
|
||||
supabase,
|
||||
false // preview mode — execution happens via web UI commit
|
||||
)
|
||||
@@ -484,7 +488,7 @@ const tools: McpTool[] = [
|
||||
.from('transactions')
|
||||
.select('description, merchant_name, amount, currency')
|
||||
.eq('id', args.transaction_id as string)
|
||||
.eq('company_id', userId)
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
|
||||
const txDesc = tx
|
||||
@@ -492,7 +496,7 @@ const tools: McpTool[] = [
|
||||
: String(args.transaction_id)
|
||||
|
||||
// Stage for user approval
|
||||
return stagePendingOperation(supabase, userId, 'categorize_transaction',
|
||||
return stagePendingOperation(supabase, companyId, userId, 'categorize_transaction',
|
||||
`Kategorisera: ${txDesc}`,
|
||||
{
|
||||
transaction_id: args.transaction_id,
|
||||
@@ -541,7 +545,7 @@ const tools: McpTool[] = [
|
||||
openWorldHint: false,
|
||||
},
|
||||
_meta: { ui: { resourceUri: 'ui://receipt-matcher/app.html' } },
|
||||
async execute(args, userId, supabase) {
|
||||
async execute(args, companyId, userId, supabase) {
|
||||
const limit = Math.min(Math.max(1, Number(args.limit) || 20), 50)
|
||||
|
||||
const { data, error } = await supabase
|
||||
@@ -549,7 +553,7 @@ const tools: McpTool[] = [
|
||||
.select(
|
||||
'id, date, description, amount, currency, merchant_name, reference, is_business, category'
|
||||
)
|
||||
.eq('company_id', userId)
|
||||
.eq('company_id', companyId)
|
||||
.is('journal_entry_id', null)
|
||||
.order('date', { ascending: false })
|
||||
.limit(limit)
|
||||
@@ -581,11 +585,11 @@ const tools: McpTool[] = [
|
||||
idempotentHint: true,
|
||||
openWorldHint: false,
|
||||
},
|
||||
async execute(_args, userId, supabase) {
|
||||
async execute(_args, companyId, userId, supabase) {
|
||||
const { data, error } = await supabase
|
||||
.from('customers')
|
||||
.select('id, name, customer_type, email, org_number, vat_number, default_payment_terms, city, country')
|
||||
.eq('company_id', userId)
|
||||
.eq('company_id', companyId)
|
||||
.order('name')
|
||||
|
||||
if (error) throw new Error(`Database error: ${error.message}`)
|
||||
@@ -641,7 +645,7 @@ const tools: McpTool[] = [
|
||||
idempotentHint: false,
|
||||
openWorldHint: false,
|
||||
},
|
||||
async execute(args, userId, supabase) {
|
||||
async execute(args, companyId, userId, supabase) {
|
||||
const name = args.name as string
|
||||
const customerType = args.customer_type as string
|
||||
|
||||
@@ -663,7 +667,7 @@ const tools: McpTool[] = [
|
||||
country: (args.country as string) || 'Sweden',
|
||||
}
|
||||
|
||||
return stagePendingOperation(supabase, userId, 'create_customer',
|
||||
return stagePendingOperation(supabase, companyId, userId, 'create_customer',
|
||||
`Ny kund: ${params.name}`,
|
||||
params,
|
||||
params // params ARE the preview for customers
|
||||
@@ -703,14 +707,14 @@ const tools: McpTool[] = [
|
||||
idempotentHint: true,
|
||||
openWorldHint: false,
|
||||
},
|
||||
async execute(args, userId, supabase) {
|
||||
async execute(args, companyId, userId, supabase) {
|
||||
const limit = Math.min(Math.max(1, Number(args.limit) || 50), 100)
|
||||
const status = args.status as string | undefined
|
||||
|
||||
let query = supabase
|
||||
.from('invoices')
|
||||
.select('id, invoice_number, status, customer_id, total, currency, invoice_date, due_date, document_type, customers(name)', { count: 'exact' })
|
||||
.eq('company_id', userId)
|
||||
.eq('company_id', companyId)
|
||||
|
||||
if (status) {
|
||||
query = query.eq('status', status)
|
||||
@@ -800,7 +804,7 @@ const tools: McpTool[] = [
|
||||
idempotentHint: false,
|
||||
openWorldHint: false,
|
||||
},
|
||||
async execute(args, userId, supabase) {
|
||||
async execute(args, companyId, userId, supabase) {
|
||||
const customerId = args.customer_id as string
|
||||
const items = args.items as Array<{
|
||||
description: string
|
||||
@@ -829,7 +833,7 @@ const tools: McpTool[] = [
|
||||
.from('customers')
|
||||
.select('*')
|
||||
.eq('id', customerId)
|
||||
.eq('company_id', userId)
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
|
||||
if (custError || !customer) {
|
||||
@@ -866,7 +870,7 @@ const tools: McpTool[] = [
|
||||
}
|
||||
|
||||
// Stage for user approval instead of creating directly
|
||||
return stagePendingOperation(supabase, userId, 'create_invoice',
|
||||
return stagePendingOperation(supabase, companyId, userId, 'create_invoice',
|
||||
`Ny faktura: ${customer.name} ${Math.round(total * 100) / 100} ${currency}`,
|
||||
{
|
||||
customer_id: customerId,
|
||||
@@ -924,7 +928,7 @@ const tools: McpTool[] = [
|
||||
idempotentHint: true,
|
||||
openWorldHint: false,
|
||||
},
|
||||
async execute(args, userId, supabase) {
|
||||
async execute(args, companyId, userId, supabase) {
|
||||
let periodId = args.period_id as string | undefined
|
||||
|
||||
// If no period specified, find the most recent one
|
||||
@@ -932,7 +936,7 @@ const tools: McpTool[] = [
|
||||
const { data: periods } = await supabase
|
||||
.from('fiscal_periods')
|
||||
.select('id, name')
|
||||
.eq('company_id', userId)
|
||||
.eq('company_id', companyId)
|
||||
.order('period_start', { ascending: false })
|
||||
.limit(1)
|
||||
.single()
|
||||
@@ -948,7 +952,7 @@ const tools: McpTool[] = [
|
||||
.from('fiscal_periods')
|
||||
.select('id, name, period_start, period_end')
|
||||
.eq('id', periodId)
|
||||
.eq('company_id', userId)
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
|
||||
if (!period) throw new Error('Fiscal period not found.')
|
||||
@@ -957,7 +961,7 @@ const tools: McpTool[] = [
|
||||
const { data: lines, error } = await supabase
|
||||
.from('journal_entry_lines')
|
||||
.select('account_number, debit_amount, credit_amount, journal_entries!inner(status, user_id, fiscal_period_id)')
|
||||
.eq('journal_entries.company_id', userId)
|
||||
.eq('journal_entries.company_id', companyId)
|
||||
.eq('journal_entries.fiscal_period_id', periodId)
|
||||
.in('journal_entries.status', ['posted', 'reversed'])
|
||||
|
||||
@@ -967,7 +971,7 @@ const tools: McpTool[] = [
|
||||
const { data: accounts } = await supabase
|
||||
.from('chart_of_accounts')
|
||||
.select('account_number, account_name')
|
||||
.eq('company_id', userId)
|
||||
.eq('company_id', companyId)
|
||||
|
||||
const accountMap = new Map((accounts ?? []).map((a: { account_number: string; account_name: string }) => [a.account_number, a.account_name]))
|
||||
|
||||
@@ -1043,7 +1047,7 @@ const tools: McpTool[] = [
|
||||
idempotentHint: true,
|
||||
openWorldHint: false,
|
||||
},
|
||||
async execute(args, userId, supabase) {
|
||||
async execute(args, companyId, userId, supabase) {
|
||||
const periodType = args.period_type as string
|
||||
const year = Number(args.year)
|
||||
const period = Number(args.period)
|
||||
@@ -1078,7 +1082,7 @@ const tools: McpTool[] = [
|
||||
const { data: lines, error } = await supabase
|
||||
.from('journal_entry_lines')
|
||||
.select('account_number, debit_amount, credit_amount, journal_entries!inner(entry_date, status, user_id)')
|
||||
.eq('journal_entries.company_id', userId)
|
||||
.eq('journal_entries.company_id', companyId)
|
||||
.in('journal_entries.status', ['posted', 'reversed'])
|
||||
.gte('journal_entries.entry_date', startDate)
|
||||
.lte('journal_entries.entry_date', endDate)
|
||||
@@ -1175,14 +1179,14 @@ const tools: McpTool[] = [
|
||||
idempotentHint: true,
|
||||
openWorldHint: false,
|
||||
},
|
||||
async execute(args, userId, supabase) {
|
||||
async execute(args, companyId, userId, supabase) {
|
||||
let periodId = args.period_id as string | undefined
|
||||
|
||||
if (!periodId) {
|
||||
const { data: periods } = await supabase
|
||||
.from('fiscal_periods')
|
||||
.select('id')
|
||||
.eq('company_id', userId)
|
||||
.eq('company_id', companyId)
|
||||
.order('period_start', { ascending: false })
|
||||
.limit(1)
|
||||
.single()
|
||||
@@ -1198,7 +1202,7 @@ const tools: McpTool[] = [
|
||||
.from('fiscal_periods')
|
||||
.select('id, name, period_start, period_end')
|
||||
.eq('id', periodId)
|
||||
.eq('company_id', userId)
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
|
||||
if (!period) throw new Error('Fiscal period not found.')
|
||||
@@ -1206,14 +1210,14 @@ const tools: McpTool[] = [
|
||||
// Run queries in parallel (same as the KPI API route)
|
||||
const [incomeStatement, trialBalance, arLedger, monthlyBreakdown, paidInvoices] =
|
||||
await Promise.all([
|
||||
generateIncomeStatement(supabase, userId, periodId!),
|
||||
generateTrialBalance(supabase, userId, periodId!),
|
||||
generateARLedger(supabase, userId),
|
||||
generateMonthlyBreakdown(supabase, userId, periodId!),
|
||||
generateIncomeStatement(supabase, companyId, periodId!),
|
||||
generateTrialBalance(supabase, companyId, periodId!),
|
||||
generateARLedger(supabase, companyId),
|
||||
generateMonthlyBreakdown(supabase, companyId, periodId!),
|
||||
supabase
|
||||
.from('invoices')
|
||||
.select('invoice_date, paid_at')
|
||||
.eq('company_id', userId)
|
||||
.eq('company_id', companyId)
|
||||
.eq('status', 'paid')
|
||||
.not('paid_at', 'is', null),
|
||||
])
|
||||
@@ -1285,14 +1289,14 @@ const tools: McpTool[] = [
|
||||
idempotentHint: true,
|
||||
openWorldHint: false,
|
||||
},
|
||||
async execute(args, userId, supabase) {
|
||||
async execute(args, companyId, userId, supabase) {
|
||||
let periodId = args.period_id as string | undefined
|
||||
|
||||
if (!periodId) {
|
||||
const { data: periods } = await supabase
|
||||
.from('fiscal_periods')
|
||||
.select('id')
|
||||
.eq('company_id', userId)
|
||||
.eq('company_id', companyId)
|
||||
.order('period_start', { ascending: false })
|
||||
.limit(1)
|
||||
.single()
|
||||
@@ -1307,12 +1311,12 @@ const tools: McpTool[] = [
|
||||
.from('fiscal_periods')
|
||||
.select('id, name, period_start, period_end')
|
||||
.eq('id', periodId)
|
||||
.eq('company_id', userId)
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
|
||||
if (!period) throw new Error('Fiscal period not found.')
|
||||
|
||||
const result = await generateIncomeStatement(supabase, userId, periodId!)
|
||||
const result = await generateIncomeStatement(supabase, companyId, periodId!)
|
||||
result.period = { start: period.period_start, end: period.period_end }
|
||||
|
||||
return {
|
||||
@@ -1353,7 +1357,7 @@ const tools: McpTool[] = [
|
||||
idempotentHint: false,
|
||||
openWorldHint: false,
|
||||
},
|
||||
async execute(args, userId, supabase) {
|
||||
async execute(args, companyId, userId, supabase) {
|
||||
const invoiceId = args.invoice_id as string
|
||||
if (!invoiceId) throw new Error('invoice_id is required')
|
||||
|
||||
@@ -1361,7 +1365,7 @@ const tools: McpTool[] = [
|
||||
.from('invoices')
|
||||
.select('*, customer:customers(*)')
|
||||
.eq('id', invoiceId)
|
||||
.eq('company_id', userId)
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
|
||||
if (invoiceError || !invoice) throw new Error('Invoice not found')
|
||||
@@ -1371,7 +1375,7 @@ const tools: McpTool[] = [
|
||||
|
||||
const paymentDate = (args.payment_date as string) || new Date().toISOString().split('T')[0]
|
||||
|
||||
return stagePendingOperation(supabase, userId, 'mark_invoice_paid',
|
||||
return stagePendingOperation(supabase, companyId, userId, 'mark_invoice_paid',
|
||||
`Betald: ${invoice.invoice_number} ${invoice.customer?.name || ''} ${invoice.total} ${invoice.currency}`,
|
||||
{ invoice_id: invoiceId, payment_date: paymentDate },
|
||||
{
|
||||
@@ -1415,7 +1419,7 @@ const tools: McpTool[] = [
|
||||
idempotentHint: false,
|
||||
openWorldHint: true,
|
||||
},
|
||||
async execute(args, userId, supabase) {
|
||||
async execute(args, companyId, userId, supabase) {
|
||||
const invoiceId = args.invoice_id as string
|
||||
if (!invoiceId) throw new Error('invoice_id is required')
|
||||
|
||||
@@ -1428,7 +1432,7 @@ const tools: McpTool[] = [
|
||||
.from('invoices')
|
||||
.select('*, customer:customers(*)')
|
||||
.eq('id', invoiceId)
|
||||
.eq('company_id', userId)
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
|
||||
if (invoiceError || !invoice) throw new Error('Invoice not found')
|
||||
@@ -1436,7 +1440,7 @@ const tools: McpTool[] = [
|
||||
const customer = invoice.customer as Customer
|
||||
if (!customer.email) throw new Error('Customer has no email address. Update customer details first.')
|
||||
|
||||
return stagePendingOperation(supabase, userId, 'send_invoice',
|
||||
return stagePendingOperation(supabase, companyId, userId, 'send_invoice',
|
||||
`Skicka: ${invoice.invoice_number} till ${customer.email}`,
|
||||
{ invoice_id: invoiceId },
|
||||
{
|
||||
@@ -1476,7 +1480,7 @@ const tools: McpTool[] = [
|
||||
idempotentHint: false,
|
||||
openWorldHint: false,
|
||||
},
|
||||
async execute(args, userId, supabase) {
|
||||
async execute(args, companyId, userId, supabase) {
|
||||
const invoiceId = args.invoice_id as string
|
||||
if (!invoiceId) throw new Error('invoice_id is required')
|
||||
|
||||
@@ -1484,13 +1488,13 @@ const tools: McpTool[] = [
|
||||
.from('invoices')
|
||||
.select('*, customer:customers(*)')
|
||||
.eq('id', invoiceId)
|
||||
.eq('company_id', userId)
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
|
||||
if (invoiceError || !invoice) throw new Error('Invoice not found')
|
||||
if (invoice.status !== 'draft') throw new Error('Only draft invoices can be marked as sent')
|
||||
|
||||
return stagePendingOperation(supabase, userId, 'mark_invoice_sent',
|
||||
return stagePendingOperation(supabase, companyId, userId, 'mark_invoice_sent',
|
||||
`Markera skickad: ${invoice.invoice_number} ${invoice.customer?.name || ''}`,
|
||||
{ invoice_id: invoiceId },
|
||||
{
|
||||
@@ -1520,11 +1524,11 @@ const tools: McpTool[] = [
|
||||
idempotentHint: true,
|
||||
openWorldHint: false,
|
||||
},
|
||||
async execute(_args, userId, supabase) {
|
||||
async execute(_args, companyId, userId, supabase) {
|
||||
const { data, error } = await supabase
|
||||
.from('suppliers')
|
||||
.select('id, name, supplier_type, email, phone, org_number, vat_number, default_expense_account, default_payment_terms, default_currency, city, country')
|
||||
.eq('company_id', userId)
|
||||
.eq('company_id', companyId)
|
||||
.order('name', { ascending: true })
|
||||
|
||||
if (error) throw new Error(`Database error: ${error.message}`)
|
||||
@@ -1562,14 +1566,14 @@ const tools: McpTool[] = [
|
||||
idempotentHint: true,
|
||||
openWorldHint: false,
|
||||
},
|
||||
async execute(args, userId, supabase) {
|
||||
async execute(args, companyId, userId, supabase) {
|
||||
const limit = Math.min(Math.max(1, Number(args.limit) || 50), 100)
|
||||
const status = (args.status as string) || 'all'
|
||||
|
||||
let query = supabase
|
||||
.from('supplier_invoices')
|
||||
.select('id, supplier_invoice_number, invoice_date, due_date, status, total, total_sek, currency, vat_treatment, remaining_amount, supplier:suppliers(id, name)')
|
||||
.eq('company_id', userId)
|
||||
.eq('company_id', companyId)
|
||||
|
||||
if (status !== 'all') {
|
||||
if (status === 'to_pay') {
|
||||
@@ -1612,13 +1616,13 @@ const tools: McpTool[] = [
|
||||
idempotentHint: true,
|
||||
openWorldHint: false,
|
||||
},
|
||||
async execute(args, userId, supabase) {
|
||||
async execute(args, companyId, userId, supabase) {
|
||||
const limit = Math.min(Math.max(1, Number(args.limit) || 100), 200)
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('categorization_templates')
|
||||
.select('id, counterparty_name, counterparty_aliases, debit_account, credit_account, vat_treatment, vat_account, category, line_pattern, occurrence_count, confidence, last_seen_date, source')
|
||||
.eq('company_id', userId)
|
||||
.eq('company_id', companyId)
|
||||
.eq('is_active', true)
|
||||
.order('occurrence_count', { ascending: false })
|
||||
.limit(limit)
|
||||
@@ -1664,7 +1668,7 @@ const tools: McpTool[] = [
|
||||
idempotentHint: true,
|
||||
openWorldHint: false,
|
||||
},
|
||||
async execute(args, userId, supabase) {
|
||||
async execute(args, companyId, userId, supabase) {
|
||||
const ids = args.transaction_ids as string[]
|
||||
if (!ids || ids.length === 0) throw new Error('transaction_ids is required (non-empty array)')
|
||||
const limitedIds = ids.slice(0, 20)
|
||||
@@ -1673,7 +1677,7 @@ const tools: McpTool[] = [
|
||||
const { data: transactions, error: txError } = await supabase
|
||||
.from('transactions')
|
||||
.select('*')
|
||||
.eq('company_id', userId)
|
||||
.eq('company_id', companyId)
|
||||
.in('id', limitedIds)
|
||||
|
||||
if (txError) throw new Error(`Database error: ${txError.message}`)
|
||||
@@ -1683,7 +1687,7 @@ const tools: McpTool[] = [
|
||||
const { data: mappingRules } = await supabase
|
||||
.from('mapping_rules')
|
||||
.select('*')
|
||||
.or(`company_id.eq.${userId},company_id.is.null`)
|
||||
.or(`company_id.eq.${companyId},company_id.is.null`)
|
||||
.eq('is_active', true)
|
||||
.order('priority', { ascending: false })
|
||||
|
||||
@@ -1691,7 +1695,7 @@ const tools: McpTool[] = [
|
||||
const { data: historicalTxns } = await supabase
|
||||
.from('transactions')
|
||||
.select('category')
|
||||
.eq('company_id', userId)
|
||||
.eq('company_id', companyId)
|
||||
.not('is_business', 'is', null)
|
||||
.neq('category', 'uncategorized')
|
||||
.neq('category', 'private')
|
||||
@@ -1704,7 +1708,7 @@ const tools: McpTool[] = [
|
||||
|
||||
// Batch counterparty template matching
|
||||
const counterpartyMatches = await findCounterpartyTemplatesBatch(
|
||||
supabase, userId, transactions as Transaction[]
|
||||
supabase, companyId, transactions as Transaction[]
|
||||
)
|
||||
|
||||
// Generate suggestions per transaction
|
||||
@@ -1760,14 +1764,14 @@ const tools: McpTool[] = [
|
||||
idempotentHint: true,
|
||||
openWorldHint: false,
|
||||
},
|
||||
async execute(args, userId, supabase) {
|
||||
async execute(args, companyId, userId, supabase) {
|
||||
const activeOnly = args.active_only !== false
|
||||
const accountClass = args.account_class as number | undefined
|
||||
|
||||
let query = supabase
|
||||
.from('chart_of_accounts')
|
||||
.select('account_number, account_name, account_class, account_group, account_type, normal_balance, is_active, description')
|
||||
.eq('company_id', userId)
|
||||
.eq('company_id', companyId)
|
||||
.order('sort_order')
|
||||
|
||||
if (activeOnly) query = query.eq('is_active', true)
|
||||
@@ -1804,14 +1808,14 @@ const tools: McpTool[] = [
|
||||
idempotentHint: true,
|
||||
openWorldHint: false,
|
||||
},
|
||||
async execute(args, userId, supabase) {
|
||||
async execute(args, companyId, userId, supabase) {
|
||||
let periodId = args.period_id as string | undefined
|
||||
|
||||
if (!periodId) {
|
||||
const { data: periods } = await supabase
|
||||
.from('fiscal_periods')
|
||||
.select('id')
|
||||
.eq('company_id', userId)
|
||||
.eq('company_id', companyId)
|
||||
.order('period_start', { ascending: false })
|
||||
.limit(1)
|
||||
.single()
|
||||
@@ -1824,12 +1828,12 @@ const tools: McpTool[] = [
|
||||
.from('fiscal_periods')
|
||||
.select('id, name, period_start, period_end')
|
||||
.eq('id', periodId)
|
||||
.eq('company_id', userId)
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
|
||||
if (!period) throw new Error('Fiscal period not found.')
|
||||
|
||||
const result = await generateBalanceSheet(supabase, userId, periodId!)
|
||||
const result = await generateBalanceSheet(supabase, companyId, periodId!)
|
||||
|
||||
return {
|
||||
period_name: period.name,
|
||||
@@ -1865,14 +1869,14 @@ const tools: McpTool[] = [
|
||||
idempotentHint: true,
|
||||
openWorldHint: false,
|
||||
},
|
||||
async execute(args, userId, supabase) {
|
||||
async execute(args, companyId, userId, supabase) {
|
||||
let periodId = args.period_id as string | undefined
|
||||
|
||||
if (!periodId) {
|
||||
const { data: periods } = await supabase
|
||||
.from('fiscal_periods')
|
||||
.select('id')
|
||||
.eq('company_id', userId)
|
||||
.eq('company_id', companyId)
|
||||
.order('period_start', { ascending: false })
|
||||
.limit(1)
|
||||
.single()
|
||||
@@ -1884,7 +1888,7 @@ const tools: McpTool[] = [
|
||||
const accountFrom = args.account_from as string | undefined
|
||||
const accountTo = args.account_to as string | undefined
|
||||
|
||||
return await generateGeneralLedger(supabase, userId, periodId!, accountFrom, accountTo)
|
||||
return await generateGeneralLedger(supabase, companyId, periodId!, accountFrom, accountTo)
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1911,9 +1915,9 @@ const tools: McpTool[] = [
|
||||
idempotentHint: true,
|
||||
openWorldHint: false,
|
||||
},
|
||||
async execute(args, userId, supabase) {
|
||||
async execute(args, companyId, userId, supabase) {
|
||||
const asOfDate = args.as_of_date as string | undefined
|
||||
return await generateARLedger(supabase, userId, asOfDate)
|
||||
return await generateARLedger(supabase, companyId, asOfDate)
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1940,9 +1944,9 @@ const tools: McpTool[] = [
|
||||
idempotentHint: true,
|
||||
openWorldHint: false,
|
||||
},
|
||||
async execute(args, userId, supabase) {
|
||||
async execute(args, companyId, userId, supabase) {
|
||||
const asOfDate = args.as_of_date as string | undefined
|
||||
return await generateSupplierLedger(supabase, userId, asOfDate)
|
||||
return await generateSupplierLedger(supabase, companyId, asOfDate)
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1978,7 +1982,7 @@ const tools: McpTool[] = [
|
||||
idempotentHint: false,
|
||||
openWorldHint: false,
|
||||
},
|
||||
async execute(args, userId, supabase) {
|
||||
async execute(args, companyId, userId, supabase) {
|
||||
const transactionId = args.transaction_id as string
|
||||
const invoiceId = args.invoice_id as string
|
||||
if (!transactionId || !invoiceId) throw new Error('transaction_id and invoice_id are required')
|
||||
@@ -1988,7 +1992,7 @@ const tools: McpTool[] = [
|
||||
.from('transactions')
|
||||
.select('id, description, merchant_name, amount, currency, invoice_id')
|
||||
.eq('id', transactionId)
|
||||
.eq('company_id', userId)
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
|
||||
if (txError || !transaction) throw new Error('Transaction not found')
|
||||
@@ -1999,7 +2003,7 @@ const tools: McpTool[] = [
|
||||
.from('invoices')
|
||||
.select('*, customer:customers(*)')
|
||||
.eq('id', invoiceId)
|
||||
.eq('company_id', userId)
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
|
||||
if (invError || !invoice) throw new Error('Invoice not found')
|
||||
@@ -2009,7 +2013,7 @@ const tools: McpTool[] = [
|
||||
|
||||
const txDesc = transaction.merchant_name || transaction.description || transactionId
|
||||
|
||||
return stagePendingOperation(supabase, userId, 'match_transaction_invoice',
|
||||
return stagePendingOperation(supabase, companyId, userId, 'match_transaction_invoice',
|
||||
`Matcha: ${txDesc} → ${invoice.invoice_number}`,
|
||||
{ transaction_id: transactionId, invoice_id: invoiceId },
|
||||
{
|
||||
@@ -2042,11 +2046,11 @@ const tools: McpTool[] = [
|
||||
idempotentHint: true,
|
||||
openWorldHint: false,
|
||||
},
|
||||
async execute(_args, userId, supabase) {
|
||||
async execute(_args, companyId, userId, supabase) {
|
||||
const { data, error } = await supabase
|
||||
.from('fiscal_periods')
|
||||
.select('id, name, period_start, period_end, status')
|
||||
.eq('company_id', userId)
|
||||
.eq('company_id', companyId)
|
||||
.order('period_start', { ascending: false })
|
||||
|
||||
if (error) throw new Error(`Database error: ${error.message}`)
|
||||
@@ -2081,10 +2085,277 @@ const tools: McpTool[] = [
|
||||
idempotentHint: true,
|
||||
openWorldHint: false,
|
||||
},
|
||||
async execute(args, userId, supabase) {
|
||||
async execute(args, companyId, userId, supabase) {
|
||||
const dateFrom = args.date_from as string | undefined
|
||||
const dateTo = args.date_to as string | undefined
|
||||
return await getReconciliationStatus(supabase, userId, dateFrom, dateTo)
|
||||
return await getReconciliationStatus(supabase, companyId, dateFrom, dateTo)
|
||||
},
|
||||
},
|
||||
|
||||
// ── Document Inbox Tools ────────────────────────────────────
|
||||
|
||||
{
|
||||
name: 'gnubok_upload_document',
|
||||
description:
|
||||
'Upload a document (invoice, receipt) to the inbox for AI classification.\n\n' +
|
||||
'Args:\n' +
|
||||
' - file_name (string, required): File name with extension (e.g. "faktura.pdf")\n' +
|
||||
' - file_content_base64 (string, required): Base64-encoded file content\n' +
|
||||
' - mime_type (string, optional): MIME type. Inferred from extension if omitted.\n\n' +
|
||||
'Returns JSON:\n' +
|
||||
' { document_id, inbox_item_id, status, document_type, extracted_data, confidence }\n\n' +
|
||||
'Supported types: PDF, JPEG, PNG, HEIC, WebP. Max 20 MB.\n' +
|
||||
'Classification runs synchronously (~2-5 seconds).',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
file_name: { type: 'string', description: 'File name with extension (e.g. "faktura.pdf")' },
|
||||
file_content_base64: { type: 'string', description: 'Base64-encoded file content' },
|
||||
mime_type: { type: 'string', description: 'MIME type (optional, inferred from extension)' },
|
||||
},
|
||||
required: ['file_name', 'file_content_base64'],
|
||||
},
|
||||
annotations: {
|
||||
readOnlyHint: false,
|
||||
destructiveHint: false,
|
||||
idempotentHint: false,
|
||||
openWorldHint: false,
|
||||
},
|
||||
async execute(args, companyId, userId, supabase) {
|
||||
const fileName = args.file_name as string
|
||||
const base64Content = args.file_content_base64 as string
|
||||
let mimeType = args.mime_type as string | undefined
|
||||
|
||||
if (!mimeType) {
|
||||
const ext = fileName.split('.').pop()?.toLowerCase()
|
||||
const mimeMap: Record<string, string> = {
|
||||
pdf: 'application/pdf',
|
||||
jpg: 'image/jpeg',
|
||||
jpeg: 'image/jpeg',
|
||||
png: 'image/png',
|
||||
heic: 'image/heic',
|
||||
webp: 'image/webp',
|
||||
}
|
||||
mimeType = ext ? mimeMap[ext] : undefined
|
||||
if (!mimeType) throw new Error(`Cannot infer MIME type from extension: .${ext}`)
|
||||
}
|
||||
|
||||
const allowedMimeTypes = new Set([
|
||||
'application/pdf', 'image/jpeg', 'image/png', 'image/heic', 'image/webp',
|
||||
])
|
||||
if (!allowedMimeTypes.has(mimeType)) {
|
||||
throw new Error(`Unsupported file type: ${mimeType}. Allowed: PDF, JPEG, PNG, HEIC, WebP`)
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(base64Content, 'base64')
|
||||
if (buffer.byteLength > MAX_DOCUMENT_SIZE) {
|
||||
throw new Error(`File too large (max ${MAX_DOCUMENT_SIZE / 1024 / 1024} MB)`)
|
||||
}
|
||||
|
||||
// Store in WORM archive
|
||||
const doc = await uploadDocument(supabase, userId, companyId, {
|
||||
name: fileName,
|
||||
buffer: buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength),
|
||||
type: mimeType,
|
||||
}, { upload_source: 'api' })
|
||||
|
||||
// Classify (invoice-inbox extension may not be enabled)
|
||||
let classificationResult
|
||||
let classificationError: string | null = null
|
||||
try {
|
||||
const { classifyDocument } = await import('@/extensions/general/invoice-inbox/lib/classify-document')
|
||||
classificationResult = await classifyDocument({
|
||||
fileBuffer: buffer,
|
||||
mimeType,
|
||||
fileName,
|
||||
})
|
||||
} catch (err) {
|
||||
classificationError = err instanceof Error ? err.message : 'Classification failed'
|
||||
}
|
||||
|
||||
// Supplier matching
|
||||
let matchedSupplierId: string | null = null
|
||||
if (classificationResult?.documentType === 'supplier_invoice' && classificationResult.extractedData) {
|
||||
const extractedData = classificationResult.extractedData as { supplier?: { orgNumber?: string | null } }
|
||||
const orgNumber = extractedData.supplier?.orgNumber
|
||||
if (orgNumber) {
|
||||
const { data: s } = await supabase
|
||||
.from('suppliers')
|
||||
.select('id')
|
||||
.eq('company_id', companyId)
|
||||
.eq('org_number', orgNumber.replace(/\D/g, ''))
|
||||
.limit(1)
|
||||
.maybeSingle()
|
||||
if (s) matchedSupplierId = s.id
|
||||
}
|
||||
}
|
||||
|
||||
// Create inbox item
|
||||
const { data: inbox, error: inboxError } = await supabase
|
||||
.from('invoice_inbox_items')
|
||||
.insert({
|
||||
company_id: companyId,
|
||||
user_id: userId,
|
||||
status: classificationError ? 'error' : 'ready',
|
||||
source: 'upload',
|
||||
document_id: doc.id,
|
||||
document_type: classificationResult?.documentType || 'unknown',
|
||||
extracted_data: classificationResult?.extractedData || null,
|
||||
raw_llm_response: classificationResult?.rawResponse || null,
|
||||
confidence: classificationResult?.confidence ? classificationResult.confidence / 100 : null,
|
||||
matched_supplier_id: matchedSupplierId,
|
||||
error_message: classificationError,
|
||||
})
|
||||
.select('id, status, document_type, confidence')
|
||||
.single()
|
||||
|
||||
if (inboxError) throw new Error(`Failed to create inbox item: ${inboxError.message}`)
|
||||
|
||||
return {
|
||||
document_id: doc.id,
|
||||
inbox_item_id: inbox.id,
|
||||
status: inbox.status,
|
||||
document_type: inbox.document_type,
|
||||
extracted_data: classificationResult?.extractedData || null,
|
||||
confidence: inbox.confidence,
|
||||
error_message: classificationError,
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: 'gnubok_list_inbox_items',
|
||||
description:
|
||||
'List document inbox items (classified invoices, receipts, etc.).\n\n' +
|
||||
'Args:\n' +
|
||||
' - status (string, optional): Filter by status (pending, processing, ready, confirmed, rejected, error)\n' +
|
||||
' - document_type (string, optional): Filter by type (supplier_invoice, receipt, government_letter, unknown)\n' +
|
||||
' - limit (number, optional): Max results, 1–50 (default 20)\n\n' +
|
||||
'Returns JSON:\n' +
|
||||
' { items: [{ id, status, document_type, confidence, source, created_at,\n' +
|
||||
' vendor_name, amount, invoice_date, matched_supplier_id }],\n' +
|
||||
' count: number }',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
status: {
|
||||
type: 'string',
|
||||
enum: ['pending', 'processing', 'ready', 'confirmed', 'rejected', 'error'],
|
||||
description: 'Filter by status',
|
||||
},
|
||||
document_type: {
|
||||
type: 'string',
|
||||
enum: ['supplier_invoice', 'receipt', 'government_letter', 'unknown'],
|
||||
description: 'Filter by document type',
|
||||
},
|
||||
limit: {
|
||||
type: 'number',
|
||||
description: 'Max results (default 20, max 50)',
|
||||
},
|
||||
},
|
||||
},
|
||||
annotations: {
|
||||
readOnlyHint: true,
|
||||
destructiveHint: false,
|
||||
idempotentHint: true,
|
||||
openWorldHint: false,
|
||||
},
|
||||
async execute(args, companyId, userId, supabase) {
|
||||
const limit = Math.min(Math.max(1, Number(args.limit) || 20), 50)
|
||||
const status = args.status as string | undefined
|
||||
const documentType = args.document_type as string | undefined
|
||||
|
||||
let query = supabase
|
||||
.from('invoice_inbox_items')
|
||||
.select('id, status, document_type, confidence, source, created_at, extracted_data, matched_supplier_id, email_from, email_subject, error_message')
|
||||
.eq('company_id', companyId)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(limit)
|
||||
|
||||
if (status) query = query.eq('status', status)
|
||||
if (documentType) query = query.eq('document_type', documentType)
|
||||
|
||||
const { data, error } = await query
|
||||
if (error) throw new Error(`Database error: ${error.message}`)
|
||||
|
||||
// Extract key fields from extracted_data for summary
|
||||
const items = (data || []).map((item) => {
|
||||
const extracted = item.extracted_data as Record<string, unknown> | null
|
||||
let vendorName: string | null = null
|
||||
let amount: number | null = null
|
||||
let invoiceDate: string | null = null
|
||||
|
||||
if (extracted && item.document_type === 'supplier_invoice') {
|
||||
const supplier = extracted.supplier as Record<string, unknown> | undefined
|
||||
const invoice = extracted.invoice as Record<string, unknown> | undefined
|
||||
const totals = extracted.totals as Record<string, unknown> | undefined
|
||||
vendorName = (supplier?.name as string) || null
|
||||
amount = (totals?.total as number) || null
|
||||
invoiceDate = (invoice?.invoiceDate as string) || null
|
||||
} else if (extracted && item.document_type === 'receipt') {
|
||||
const merchant = extracted.merchant as Record<string, unknown> | undefined
|
||||
const totals = extracted.totals as Record<string, unknown> | undefined
|
||||
vendorName = (merchant?.name as string) || null
|
||||
amount = (totals?.total as number) || null
|
||||
}
|
||||
|
||||
return {
|
||||
id: item.id,
|
||||
status: item.status,
|
||||
document_type: item.document_type,
|
||||
confidence: item.confidence,
|
||||
source: item.source,
|
||||
created_at: item.created_at,
|
||||
vendor_name: vendorName,
|
||||
amount,
|
||||
invoice_date: invoiceDate,
|
||||
matched_supplier_id: item.matched_supplier_id,
|
||||
email_from: item.email_from,
|
||||
email_subject: item.email_subject,
|
||||
error_message: item.error_message,
|
||||
}
|
||||
})
|
||||
|
||||
return { items, count: items.length }
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: 'gnubok_get_inbox_item',
|
||||
description:
|
||||
'Get a single document inbox item with full extracted data.\n\n' +
|
||||
'Args:\n' +
|
||||
' - inbox_item_id (string, required): UUID of the inbox item\n\n' +
|
||||
'Returns JSON:\n' +
|
||||
' Full inbox item with id, status, document_type, confidence, source,\n' +
|
||||
' extracted_data (complete), matched_supplier_id, email metadata, timestamps.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
inbox_item_id: { type: 'string', description: 'UUID of the inbox item' },
|
||||
},
|
||||
required: ['inbox_item_id'],
|
||||
},
|
||||
annotations: {
|
||||
readOnlyHint: true,
|
||||
destructiveHint: false,
|
||||
idempotentHint: true,
|
||||
openWorldHint: false,
|
||||
},
|
||||
async execute(args, companyId, userId, supabase) {
|
||||
const id = args.inbox_item_id as string
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('invoice_inbox_items')
|
||||
.select('*, document_attachments(id, file_name, mime_type, file_size_bytes, created_at)')
|
||||
.eq('id', id)
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
|
||||
if (error) throw new Error(`Database error: ${error.message}`)
|
||||
if (!data) throw new Error('Inbox item not found')
|
||||
|
||||
return data
|
||||
},
|
||||
},
|
||||
]
|
||||
@@ -2248,7 +2519,7 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await tool.execute(toolArgs, companyId, supabase)
|
||||
const result = await tool.execute(toolArgs, companyId, userId, supabase)
|
||||
const response: Record<string, unknown> = {
|
||||
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
||||
}
|
||||
|
||||
@@ -71,6 +71,10 @@ export const TOOL_SCOPE_MAP: Record<string, ApiKeyScope> = {
|
||||
gnubok_get_supplier_ledger: 'reports:read',
|
||||
gnubok_list_fiscal_periods: 'reports:read',
|
||||
gnubok_get_reconciliation_status: 'reports:read',
|
||||
// Document inbox
|
||||
gnubok_upload_document: 'transactions:write',
|
||||
gnubok_list_inbox_items: 'transactions:read',
|
||||
gnubok_get_inbox_item: 'transactions:read',
|
||||
}
|
||||
|
||||
export function validateScopes(scopes: unknown): ApiKeyScope[] | null {
|
||||
|
||||
@@ -48,8 +48,8 @@ describe('sectors registry', () => {
|
||||
expect(SECTORS.length).toBe(1)
|
||||
})
|
||||
|
||||
it('should have 8 total extensions', () => {
|
||||
expect(getAllExtensions().length).toBe(8)
|
||||
it('should have 9 total extensions', () => {
|
||||
expect(getAllExtensions().length).toBe(9)
|
||||
})
|
||||
|
||||
it('should have unique slugs within each sector', () => {
|
||||
@@ -94,7 +94,7 @@ describe('sectors registry', () => {
|
||||
|
||||
it('getExtensionsBySector returns extensions for a sector', () => {
|
||||
const extensions = getExtensionsBySector('general')
|
||||
expect(extensions.length).toBe(8)
|
||||
expect(extensions.length).toBe(9)
|
||||
})
|
||||
|
||||
it('all extensions have required fields', () => {
|
||||
|
||||
Generated
+1419
File diff suppressed because it is too large
Load Diff
@@ -14,6 +14,7 @@
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-bedrock-runtime": "^3.1022.0",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
-- Document Ingestion Phase 1: classification support + email connections
|
||||
-- Adds raw LLM response storage and Gmail OAuth connection table
|
||||
|
||||
-- 1. Add raw_llm_response column to invoice_inbox_items
|
||||
ALTER TABLE public.invoice_inbox_items
|
||||
ADD COLUMN IF NOT EXISTS raw_llm_response jsonb;
|
||||
|
||||
-- 2. Email connections for Gmail OAuth
|
||||
CREATE TABLE public.email_connections (
|
||||
id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||
company_id uuid NOT NULL REFERENCES public.companies(id) ON DELETE CASCADE,
|
||||
user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||
provider text NOT NULL DEFAULT 'gmail'
|
||||
CHECK (provider IN ('gmail')),
|
||||
email_address text NOT NULL,
|
||||
encrypted_token text NOT NULL, -- AES-256-GCM: base64(iv):base64(authTag):base64(ciphertext)
|
||||
last_sync_at timestamptz,
|
||||
gmail_label_id text, -- ID of the gnubok-processed Gmail label
|
||||
status text NOT NULL DEFAULT 'active'
|
||||
CHECK (status IN ('active','expired','revoked','error')),
|
||||
error_message text,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE(company_id, email_address)
|
||||
);
|
||||
|
||||
-- RLS
|
||||
ALTER TABLE public.email_connections ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY "email_connections_select" ON public.email_connections
|
||||
FOR SELECT USING (company_id IN (SELECT public.user_company_ids()));
|
||||
CREATE POLICY "email_connections_insert" ON public.email_connections
|
||||
FOR INSERT WITH CHECK (company_id IN (SELECT public.user_company_ids()));
|
||||
CREATE POLICY "email_connections_update" ON public.email_connections
|
||||
FOR UPDATE USING (company_id IN (SELECT public.user_company_ids()));
|
||||
CREATE POLICY "email_connections_delete" ON public.email_connections
|
||||
FOR DELETE USING (company_id IN (SELECT public.user_company_ids()));
|
||||
|
||||
-- Indexes
|
||||
CREATE INDEX idx_email_connections_company_status
|
||||
ON public.email_connections(company_id, status);
|
||||
|
||||
-- updated_at trigger
|
||||
CREATE TRIGGER email_connections_updated_at
|
||||
BEFORE UPDATE ON public.email_connections
|
||||
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
|
||||
|
||||
-- 3. Better index for classification cron queries on inbox items
|
||||
CREATE INDEX IF NOT EXISTS idx_inbox_items_company_status_created
|
||||
ON public.invoice_inbox_items(company_id, status, created_at);
|
||||
Reference in New Issue
Block a user