Initial import: 10 Radix skills across 3 categories
Kategoriserer danske regnskabs-/ERP-skills, ERP-data-afstemnings-
skill og to devops-workflows (gitea-issue-agent, kanban-workflows)
som Radix-medlemmers AI-agenter kan dele.
Struktur:
skills/<kategori>/<skill-navn>/SKILL.md
skills/<kategori>/<skill-navn>/{references,templates,scripts}/
Indekseret af index.json (genereret af scripts/rebuild_index.py).
Kør `python3 scripts/rebuild_index.py --check` i CI for at fange
synkroniseringsfejl.
This commit is contained in:
@@ -0,0 +1,198 @@
|
||||
---
|
||||
name: coop-bankimport
|
||||
description: >
|
||||
Reconcilier Coop Danmark-bankbetalinger mod Uniconta- eller e-conomic-fakturaer (og kreditnotaer,
|
||||
bonus, modregninger) — aktiveres ved "Coop afstemning", "Coop bankbetaling", "Coop opgørelse",
|
||||
"forenel Coop betaling med faktura", "Coop kreditnota".
|
||||
version: 0.2.0
|
||||
author: radix-accountant
|
||||
license: MIT
|
||||
metadata:
|
||||
hermes:
|
||||
tags: [accounting, dansk-regnskab, coop-danmark, uniconta, e-conomic, reconciliation, bankafstemning]
|
||||
related: [dansk-csv-import, kontoplan-mapping, resultatopgoerelse, e-conomic-integration]
|
||||
---
|
||||
|
||||
# Coop Danmark-bankimport & afstemning (Uniconta eller e-conomic)
|
||||
|
||||
Specifikt workflow til **Coop Danmark ↔ [Uniconta|e-conomic]-afstemning**. Hver bankbetaling fra Coop følges af en specifikation (opgørelse) der viser præcis hvilke fakturaer, kreditnotaer, bonusser og modregninger beløbet dækker. Målet er at matche **bankbetalinger (Coop) → opgørelseslinjer → bogføringssystem-bilag** og producere bogføringsklare posteringer.
|
||||
|
||||
## Hvilket bogføringssystem?
|
||||
|
||||
| System | Fordel ved Coop-afstemning | Ulempe |
|
||||
|---|---|---|
|
||||
| **Uniconta** | Lokal data, ingen API-rate-limit, kender hele kontohistorik | Manuel CSV-eksport nødvendig |
|
||||
| **e-conomic** | Cloud-baseret, kan bogføre direkte via API, kunde 1 (Coop) findes typisk allerede | Rate-limit, kræver AppSecretToken |
|
||||
|
||||
**Spørg brugeren** hvilket system de bruger — svaret bestemmer hvordan data hentes og hvordan bogføringsforslaget præsenteres.
|
||||
|
||||
## Aktiveres ved
|
||||
|
||||
- "Forenel denne Coop-bankbetaling med Uniconta"
|
||||
- "Importer Coop-opgørelse for uge 22"
|
||||
- "Hvorfor matcher Coop-bankbetalingen ikke Uniconta-saldoen?"
|
||||
- "Generer bogføringsforslag for denne Coop-indbetaling"
|
||||
|
||||
## Forventede input
|
||||
|
||||
Brugeren leverer typisk:
|
||||
1. **Coop bankbetalings-CSV** (Sydbank, Jyske Bank eller Danske Bank — særskilt format for Coop)
|
||||
2. **Coop-specifikation** (PDF eller Excel) — viser linjer for hver faktura
|
||||
3. **Bogføringssystem-data** (ét af):
|
||||
- **Uniconta-eksport** (fakturaer, kreditnotaer, åbne poster) — typisk Excel
|
||||
- **e-conomic API-kald** (se `e-conomic-integration` skill): `/invoices/booked?filter=balance$gt:0&filter=customer.name$like:Coop`
|
||||
|
||||
## Coops bankbetalingsformat (typisk)
|
||||
|
||||
```
|
||||
Bogføringsdato;Posteringstekst;Beløb;Saldo
|
||||
15-06-2026;"Coop Danmark A/S Bankbetaling 12345678";-23.456,78;100.000,00
|
||||
```
|
||||
- **Negativ beløb** = du har modtaget penge fra Coop (kredit på din konto)
|
||||
- **Reference** = Coops bankaftale-id (8 cifre typisk)
|
||||
- **Dato** = afregningsdato
|
||||
|
||||
## Coops specifikation (Excel/PDF, eksempel)
|
||||
|
||||
| Fakturanr | Fakturadato | Beløb | Moms | Total | Type |
|
||||
|---|---|---|---|---|---|
|
||||
| 2026-1234 | 01-06-2026 | 1.000,00 | 250,00 | 1.250,00 | Faktura |
|
||||
| 2026-1235 | 02-06-2026 | 2.500,00 | 625,00 | 3.125,00 | Faktura |
|
||||
| KN-2026-100 | 03-06-2026 | -500,00 | -125,00 | -625,00 | Kreditnota |
|
||||
| Bonus-2026-06 | 30-06-2026 | -1.000,00 | 0,00 | -1.000,00 | Bonus |
|
||||
| Modregning-2026-06 | 30-06-2026 | -250,00 | 0,00 | -250,00 | Modregning |
|
||||
| **Total** | | **1.750,00** | **750,00** | **2.500,00** | |
|
||||
|
||||
Bankbetalingen på 2.500,00 kr. dækker altså: 2 fakturaer − 1 kreditnota − bonus − modregning.
|
||||
|
||||
## Workflow
|
||||
|
||||
### Fase 1: Importér bankbetaling
|
||||
1. **Modtag Coop-CSV** (typisk Sydbank-format)
|
||||
2. **Filtrér Coop-poster** (tekst matcher `Coop Danmark`)
|
||||
3. **Vis hver bankbetaling** med reference, dato, beløb
|
||||
|
||||
### Fase 2: Importér opgørelse (specifikation)
|
||||
1. **Modtag specifikationsfil** (PDF eller Excel)
|
||||
2. **Parse linjer**: fakturaer, kreditnotaer, bonus, modregninger
|
||||
3. **Beregn totaler** og verificer at de matcher bankbetalingen
|
||||
4. **Hvis differens**: marker som "afventer manuel kontrol"
|
||||
|
||||
### Fase 3: Match mod bogføringssystem
|
||||
|
||||
**Variant A: Uniconta**
|
||||
1. **Hent Uniconta-fakturaer** (via API eller eksport) for samme periode
|
||||
2. **For hver faktura i opgørelsen**: søg i Uniconta efter match på:
|
||||
- **Primær match**: fakturanr + beløb (høj confidence)
|
||||
- **Sekundær match**: beløb + dato ±7 dage (medium confidence)
|
||||
- **Ingen match**: marker som "mangler i Uniconta"
|
||||
|
||||
**Variant B: e-conomic**
|
||||
1. **Hent bogførte fakturaer** via API:
|
||||
```
|
||||
GET /invoices/booked?filter=balance$gt:0&filter=customer.name$like:Coop
|
||||
```
|
||||
2. **Filtrér på kunde** (Coop Danmark A/S) — de fleste Coops kunder findes som kunde 1, men tjek
|
||||
3. **For hver faktura i opgørelsen**: søg i e-conomic-respons efter match på:
|
||||
- **Primær match**: `bookedInvoiceNumber` + `grossAmount` (høj confidence)
|
||||
- **Sekundær match**: `grossAmount` + `date` ±7 dage (medium confidence)
|
||||
- **Ingen match**: marker som "mangler i e-conomic"
|
||||
|
||||
**Vis match-status** for hver linje (uanset variant):
|
||||
- ✅ Matchet (faktura fundet)
|
||||
- ⚠️ Forskelligt beløb (faktura fundet, men beløb afviger)
|
||||
- ❌ Manglende (faktura ikke fundet)
|
||||
|
||||
### Fase 4: Generer bogføringsforslag
|
||||
For hver matchet bankbetaling:
|
||||
|
||||
```
|
||||
BOGFØRINGSFORSLAG — Coop bankbetaling [reference]
|
||||
═══════════════════════════════════════════════════
|
||||
Dato: [dd-MM-yyyy]
|
||||
Bankbeløb: 2.500,00 kr. (positivt = indbetaling)
|
||||
───────────────────────────────────────────────────
|
||||
|
||||
DEBET (kontonr / navn) BELØB
|
||||
───────────────────────────────────────────────────
|
||||
[56200] Bank, Sydbank 2.500,00
|
||||
|
||||
KREDIT (kontonr / navn) BELØB
|
||||
───────────────────────────────────────────────────
|
||||
[10120] Tilgodehavender, salg 1.250,00 (faktura 2026-1234)
|
||||
[10120] Tilgodehavender, salg 3.125,00 (faktura 2026-1235)
|
||||
[10120] Tilgodehavender, salg −625,00 (kreditnota KN-2026-100)
|
||||
[52100] Bonus og rabatter 1.000,00 (bonus)
|
||||
[58000] Modregning 250,00 (modregning)
|
||||
|
||||
Total debet: 2.500,00
|
||||
Total kredit: 2.500,00
|
||||
═══════════════════════════════════════════════════
|
||||
Diff: 0,00 ✅ Balanceret
|
||||
═══════════════════════════════════════════════════
|
||||
```
|
||||
|
||||
### Fase 5: Bruger-godkendelse
|
||||
⚠️ **Vis altid bogføringsforslaget og vent på eksplicit godkendelse** før der postes til Radix/Uniconta/e-conomic.
|
||||
|
||||
**Hvis brugeren vil bogføre direkte i e-conomic** (se `e-conomic-integration` skill):
|
||||
1. Vis bogføringsforslaget med konkrete kontonumre
|
||||
2. Generer `entries` JSON-objekt klar til `/journals/:id/vouchers/:n/entries` POST
|
||||
3. Generer et UUID v4 Idempotency-Key
|
||||
4. Vent på "ja, bogfør" — udfør derefter API-kald
|
||||
5. Verificer at svaret er 201 Created og at `entries` indeholder bogførings-ID
|
||||
|
||||
## Almindelige problemer
|
||||
|
||||
- ❌ **Bankbeløb matcher ikke opgørelse-total**: typisk fordi opgørelsen kun viser netto (efter Coops egne fradrag som emballage-pant, kampagnerabat, mv.)
|
||||
- ❌ **Faktura findes ikke i Uniconta**: kan skyldes at fakturaen er bogført i forkert regnskabsår, eller at Uniconta-eksporten er ufuldstændig
|
||||
- ❌ **Kreditnota findes, men med forkert fortegn**: dobbelt-negativ bliver positivt — håndtér dette eksplicit
|
||||
- ❌ **Coop-bonus mangler konto**: virksomheder der ikke har en dedikeret bonuskonto bruger typisk 52100 (Bonus/rabatter) — bekræft med brugeren
|
||||
- ❌ **Modregning uden specifikation**: spørg brugeren — kan være emballage-pant, reklamation, eller andet
|
||||
- ✅ ALTID verificer at bankbeløb = sum(opgørelseslinjer) ±0,01 kr. (afrundingstolerance)
|
||||
- ✅ ALTID vis match-status per linje (matchet / afvigende / manglende)
|
||||
- ✅ ALTID vent på brugerens "ja, bogfør" før API-kald
|
||||
|
||||
## Kontering (typisk dansk kontoplan)
|
||||
|
||||
| Kontotype | Kontonr (SUM/DG) | Bruges til |
|
||||
|---|---|---|
|
||||
| Bank | 56200 | Bank, Sydbank/Jyske/Danske (pengebevægelsen) |
|
||||
| Tilgodehavender | 10120 | Åbne fakturaer der betales af Coop |
|
||||
| Bonus/rabatter | 52100 | Coops bonus, årsbonus, kampagnerabat |
|
||||
| Modregning | 58000 | Emballage-pant, reklamation, Cops egne fradrag |
|
||||
| Kreditnotaer | 10120 (negativ) | Coops kreditnotaer (vareretur mv.) |
|
||||
|
||||
Tallene er eksempler — bekræft altid med virksomhedens egen kontoplan.
|
||||
|
||||
## Eksempel-aktivering
|
||||
|
||||
**Bruger:** "Jeg har lige fået en Coop-betaling på 12.345,67 kr. d. 15-06-2026. Her er opgørelsen — kan du finde ud af hvad der dækker hvad?"
|
||||
|
||||
**Svar (forkortet):**
|
||||
> ✅ Bankbetaling genkendt: 12.345,67 kr. d. 15-06-2026
|
||||
>
|
||||
| Opgørelseslinje | Beløb | Uniconta-match | Status |
|
||||
> |---|---|---|---|
|
||||
> | Faktura 2026-1234 | 1.250,00 | UN-5678 | ✅ Matchet |
|
||||
> | Faktura 2026-1235 | 3.125,00 | UN-5679 | ✅ Matchet |
|
||||
> | Faktura 2026-1240 | 5.000,00 | UN-5680 | ✅ Matchet |
|
||||
> | Kreditnota KN-2026-100 | -625,00 | UN-5681 | ✅ Matchet |
|
||||
> | Bonus Q2 2026 | -1.404,33 | (bogføres direkte) | ⚠️ Ingen reference |
|
||||
> | **Total** | **7.345,67** | | |
|
||||
> | **Diff vs bank** | **5.000,00** | | ⚠️ Mangler 5.000,00 kr. |
|
||||
>
|
||||
> **Bogføringsforslag:**
|
||||
> ```
|
||||
> DEBET 56200 Bank 12.345,67
|
||||
> KREDIT 10120 Tilgodehavender 1.250,00
|
||||
> KREDIT 10120 Tilgodehavender 3.125,00
|
||||
> KREDIT 10120 Tilgodehavender 5.000,00
|
||||
> KREDIT 10120 Tilgodehavender -625,00
|
||||
> KREDIT 52100 Bonus -1.404,33
|
||||
> ```
|
||||
>
|
||||
> ⚠️ **Bemærk:** Diff på 5.000,00 kr. — enten mangler en faktura i opgørelsen, eller opgørelsen er ufuldstændig. Vil du:
|
||||
> 1. Indberette bogføringsforslag som det er (med diff markeret)
|
||||
> 2. Springe bankbetalingen over indtil videre
|
||||
> 3. Kontakte Coop for komplet specifikation
|
||||
@@ -0,0 +1,178 @@
|
||||
---
|
||||
name: dansk-csv-import
|
||||
description: >
|
||||
Importér danske bank-CSV-eksporter (Nordea, Danske Bank, Jyske Bank, Sydbank) og
|
||||
e-conomic-bankimport-format — aktiveres ved "importér CSV", "bankudtog", "kontoudtog",
|
||||
"Nordea eksport", "Danske Bank CSV", "Jyske Bank eksport", "e-conomic bankimport".
|
||||
version: 0.2.0
|
||||
author: radix-accountant
|
||||
license: MIT
|
||||
metadata:
|
||||
hermes:
|
||||
tags: [accounting, dansk-regnskab, csv-import, bank, nordea, danske-bank, jyske-bank, e-conomic]
|
||||
related: [kontoplan-mapping, coop-bankimport, momsafregning, e-conomic-integration]
|
||||
---
|
||||
|
||||
# Dansk CSV-import (bankudtog)
|
||||
|
||||
Håndterer import af **danske bank-CSV-eksporter** til Radix ERP med korrekt detektering af:
|
||||
- Bank (Nordea, Danske Bank, Jyske Bank, Sydbank, Nykredit, Arbejdernes Landsbank)
|
||||
- Datoformat (dansk `dd-MM-yyyy` eller ISO `yyyy-MM-dd`)
|
||||
- Decimalformatering (komma som decimal — dansk standard)
|
||||
- Feltsemikolon (dansk CSV-standard, ikke komma)
|
||||
- Tegnsæt (Latin1, UTF-8 med/uden BOM)
|
||||
|
||||
## Aktiveres ved
|
||||
|
||||
- "Importér denne Nordea-CSV til Radix"
|
||||
- "Jyske Bank har sendt kontoudtog — hjælp med at indlæse det"
|
||||
- "Parse denne bankeksport"
|
||||
- "Vis alle transaktioner fra marts 2026 i denne CSV"
|
||||
|
||||
## Genkendte bankformater
|
||||
|
||||
### Nordea (Erhverv)
|
||||
```
|
||||
Bogføringsdato;Tekst;Beløb;Saldo;Valuta;Reference
|
||||
01-06-2026;"Nets *12345";-123,45;12.345,67;DKK;
|
||||
```
|
||||
- Separator: `;`
|
||||
- Encoding: Latin1
|
||||
- Beløb: dansk format (`-123,45`)
|
||||
- Dato: `dd-MM-yyyy`
|
||||
|
||||
### Danske Bank
|
||||
```
|
||||
Dato;Tekst;Beløb;Saldo
|
||||
01-06-2026;"MobilePay - Anders Hansen";-250,00;5.000,00
|
||||
```
|
||||
- Separator: `;`
|
||||
- Encoding: UTF-8 med BOM
|
||||
- Beløb: dansk format
|
||||
- Dato: `dd-MM-yyyy`
|
||||
|
||||
### Jyske Bank
|
||||
```
|
||||
Dato;Beskrivelse;Beløb;Saldo
|
||||
01-06-2026;"POS DSB 1234 København";-89,00;3.456,78
|
||||
```
|
||||
- Separator: `;`
|
||||
- Encoding: Latin1
|
||||
- Dato: `dd-MM-yyyy`
|
||||
- Negative beløb: kan være `-89,00` eller `(89,00)` — begge understøttes
|
||||
|
||||
### Sydbank
|
||||
```
|
||||
Bogføringsdato;Posteringstekst;Beløb;Saldo
|
||||
01-06-2026;"Coop Danmark A/S Bankbetaling";-12.345,67;45.678,90
|
||||
```
|
||||
- Separator: `;`
|
||||
- Encoding: Latin1
|
||||
- Dato: `dd-MM-yyyy`
|
||||
|
||||
### e-onomic bankimport-format
|
||||
```
|
||||
Dato;Tekst;Beløb;Saldo;Modkonto;Valuta;Bankkonto
|
||||
01-06-2026;"Nets *12345";-123,45;12.345,67;5820;DKK;5508 8765432
|
||||
```
|
||||
- Separator: `;`
|
||||
- Encoding: UTF-8 med BOM
|
||||
- **Modkonto** (e-conomics kontonr, fx 5820 for bank) — bruges direkte ved bogføring
|
||||
- **Bankkonto** i dansk 14-cifret format (reg.nr. + kontonr) — verificer mod kundens egen bankaftale
|
||||
- Dato: `dd-MM-yyyy` (men konverter til ISO `yyyy-MM-dd` før API-kald til e-conomic)
|
||||
|
||||
## Workflow
|
||||
|
||||
1. **Modtag CSV-fil** (enten via sti eller indhold direkte i chat)
|
||||
2. **Detektér bank-format** ud fra header (kolonnenavne) — spørg brugeren hvis usikker
|
||||
3. **Detektér encoding** (tjek for BOM, prøv Latin1, UTF-8)
|
||||
4. **Parse CSV** med korrekt separator (`;` for dansk)
|
||||
5. **Normaliser data**:
|
||||
- Datoer → `dd-MM-yyyy` (ensret formattering)
|
||||
- Beløb → Python float (erstat `,` med `.`, fjern tusindtalsseparator)
|
||||
- Negativ beløb → negativ tal (fjern evt. parentesnotation)
|
||||
- Tekst → trim, fjern dobbelte mellemrum
|
||||
6. **Vis preview** med de første 5-10 transaktioner (bed brugeren bekræfte format)
|
||||
7. **Vis statistik**:
|
||||
- Antal transaktioner
|
||||
- Periode (første/sidste dato)
|
||||
- Total ind, total ud, slutbalance
|
||||
- Kontroller at slutbalance matcher det brugeren forventede
|
||||
8. **Identificér dubletter** (samme dato + beløb + tekst)
|
||||
9. **Kortlæg konti** (se kontoplan-mapping skill) — eller lad brugeren vælge
|
||||
10. **Generer klar-til-import JSON** til Radix ERP API
|
||||
|
||||
## Eksempel-aktivering
|
||||
|
||||
**Bruger:** *indsætter en filsti eller indhold:*
|
||||
```
|
||||
Bogføringsdato;Tekst;Beløb;Saldo;Valuta
|
||||
01-06-2026;"Nets *12345 MobilePay";-123,45;12.345,67;DKK
|
||||
02-06-2026;"Coop Danmark";-5.678,90;6.666,77;DKK
|
||||
...
|
||||
```
|
||||
|
||||
**Svar:**
|
||||
> ✅ Genkendt format: **Nordea Erhverv** (Latin1, semikolon-separeret)
|
||||
>
|
||||
> | Dato | Tekst | Beløb | Saldo |
|
||||
> |---|---|---|---|
|
||||
> | 01-06-2026 | Nets *12345 MobilePay | −123,45 | 12.345,67 |
|
||||
> | 02-06-2026 | Coop Danmark | −5.678,90 | 6.666,77 |
|
||||
> | ... |
|
||||
>
|
||||
> **Statistik:**
|
||||
> - Periode: 01-06-2026 til 30-06-2026
|
||||
> - Transaktioner: 47
|
||||
> - Total ind: 8.901,23 kr.
|
||||
> - Total ud: 23.456,78 kr.
|
||||
> - Slutbalance: 6.666,77 kr.
|
||||
>
|
||||
> ⚠️ **1 mulig dublet** fundet (samme dato, beløb og tekst på række 12 og 23). Skal jeg springe dubletten over ved import?
|
||||
|
||||
## Almindelige CSV-fælder
|
||||
|
||||
- ❌ Latin1-filer åbnet som UTF-8 → `æ` i stedet for `æ`
|
||||
- ❌ Datoer med `.` (amerikansk) i stedet for `-` (dansk)
|
||||
- ❌ Beløb med `.` som tusindtalsseparator ignoreret
|
||||
- ❌ Negative beløb som `(123,45)` (parentes) ikke håndteret
|
||||
- ❌ Header-række fejlfortolket som data
|
||||
- ❌ Linjeskift i `Tekst`-felt (fx "Coop\nDanmark") brækker parser
|
||||
- ✅ ALTID detektér encoding først (BOM, Latin1, UTF-8)
|
||||
- ✅ ALTID vis preview før import
|
||||
- ✅ ALTID vis statistik og balancesum til kontrol
|
||||
|
||||
## Radix ERP API-endpoint (for import)
|
||||
|
||||
## Bogføringssystem-integration (efter import)
|
||||
|
||||
Efter import kan transaktioner bogføres direkte i e-conomic (se `e-conomic-integration` skill):
|
||||
|
||||
```
|
||||
POST /journals/{journalId}/vouchers/{voucherNumber}/entries
|
||||
Headers:
|
||||
X-AppSecretToken: ${ECONOMIC_APP_SECRET_TOKEN}
|
||||
X-AgreementGrantToken: ${ECONOMIC_AGREEMENT_GRANT_TOKEN}
|
||||
Idempotency-Key: <UUID v4>
|
||||
```
|
||||
|
||||
Eksempel-payload genereret fra importeret CSV:
|
||||
```json
|
||||
{
|
||||
"accountingYear": {"year": 2026},
|
||||
"entries": [
|
||||
{
|
||||
"date": "2026-06-01",
|
||||
"text": "Nets *12345 MobilePay",
|
||||
"amount": -123.45,
|
||||
"amountInBaseCurrency": -123.45,
|
||||
"currency": "DKK",
|
||||
"account": {"accountNumber": 1690},
|
||||
"contraAccount": {"accountNumber": 5820},
|
||||
"vatAccount": {"vatCode": "I25"}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
⚠️ **Bekræft ALTID med brugeren før bulk-import** — vis diff (antal transaktioner, totaler) og kræv eksplicit "ja, importer" før API-kald.
|
||||
@@ -0,0 +1,290 @@
|
||||
---
|
||||
name: e-conomic-integration
|
||||
description: >
|
||||
Integration med e-conomic cloud-bogføring — aktiveres ved "bogfør i e-conomic", "hent fakturaer
|
||||
fra e-conomic", "kassekladde", "sync mod e-conomic", "opret kunde i e-conomic", "kontoudtog
|
||||
e-conomic", "REST API", "AppSecretToken". Dækker auth, kontoplan, kassekladder, bogførte
|
||||
fakturaer, kreditorer, kunder og banktransaktioner.
|
||||
version: 0.1.0
|
||||
author: radix-accountant
|
||||
license: MIT
|
||||
metadata:
|
||||
hermes:
|
||||
tags: [accounting, e-conomic, dansk-regnskab, api, cloud-bogfoering, integration]
|
||||
related: [resultatopgoerelse, momsafregning, kontoplan-mapping, coop-bankimport, dansk-csv-import]
|
||||
---
|
||||
|
||||
# e-conomic-integration
|
||||
|
||||
Integration med **e-conomic** cloud-bogføringssystem via den officielle REST API. Bruges til at hente fakturaer, kunder, kreditorer, bogføre bilag, synce banktransaktioner og afstemme konti — alt på dansk.
|
||||
|
||||
## Aktiveres ved
|
||||
|
||||
- "Bogfør denne faktura i e-conomic"
|
||||
- "Hent åbne fakturaer fra e-conomic"
|
||||
- "Synk banktransaktioner til e-conomic"
|
||||
- "Opret kunde X i e-conomic"
|
||||
- "Hvad er saldi på konto 1010 i e-conomic?"
|
||||
- "Generer kassekladdeline for denne betaling"
|
||||
- "Vis alle kreditorer med udestående i e-conomic"
|
||||
|
||||
## API basics
|
||||
|
||||
### Base-URL
|
||||
```
|
||||
https://restapi.e-conomic.com
|
||||
```
|
||||
|
||||
### Auth (kræver to tokens)
|
||||
|
||||
| Header | Værdi | Hvad det er |
|
||||
|---|---|---|
|
||||
| `X-AppSecretToken` | `${ECONOMIC_APP_SECRET_TOKEN}` | App-identifikation (din app's hemmelige token) |
|
||||
| `X-AgreementGrantToken` | `${ECONOMIC_AGREEMENT_GRANT_TOKEN}` | Aftale-specifik adgang (bruger-godkendt) |
|
||||
| `Content-Type` | `application/json` | Altid JSON |
|
||||
| `Idempotency-Key` | `<unik UUID>` | Forhindrer dobbeltbogføring ved netværksfejl (POST/PUT) |
|
||||
|
||||
⚠️ **Tokens skal ALDRIG hardcodes** — brug miljøvariabler eller en secret manager.
|
||||
|
||||
### Demo-mode (sandbox)
|
||||
Til test/udvikling, tilføj `?demo=true` til URL — alle writes deaktiveres (kun GET).
|
||||
|
||||
## Vigtigste endpoints
|
||||
|
||||
| Formål | Endpoint | HTTP |
|
||||
|---|---|---|
|
||||
| List konti (kontoplan) | `/accounts?pagesize=N` | GET |
|
||||
| Hent én konto | `/accounts/:accountNumber` | GET |
|
||||
| List bogføringsår | `/accounting-years` | GET |
|
||||
| List perioder (måneder) i et år | `/accounting-years/:year/periods` | GET |
|
||||
| Hent kontosaldo for en periode | `/accounts/:accountNumber/accounting-years/:year/periods/:period/totals` | GET |
|
||||
| List kassekladder | `/journals` | GET |
|
||||
| Hent kassekladdeline | `/journals/:id/vouchers?voucherNumber=:n` | GET |
|
||||
| Opret kassekladdeline (bilag) | `/journals/:id/vouchers/:n/entries` | POST |
|
||||
| Bogfør voucher | `/journals/:id/vouchers/:n/book` | POST |
|
||||
| List bogførte fakturaer | `/invoices/booked` | GET |
|
||||
| Opret bogført faktura | `/invoices/booked` | POST |
|
||||
| Hent kunde | `/customers/:customerNumber` | GET |
|
||||
| Opret kunde | `/customers` | POST |
|
||||
| List kreditorer | `/suppliers` | GET |
|
||||
| Opret kreditor | `/suppliers` | POST |
|
||||
| List moms-konti | `/vat-accounts` | GET |
|
||||
| Hent valutakurser | `/currencies` | GET |
|
||||
|
||||
⚠️ **Rate limit**: e-conomic har et fair-use loft. Hold dig under 10 requests/sekund.
|
||||
|
||||
## e-conomics standardkontoplan (4-cifret)
|
||||
|
||||
| Kontointerval | Type | Bruges til |
|
||||
|---|---|---|
|
||||
| 1000-1099 | Heading/profitAndLoss | Indtægter, overskrifter |
|
||||
| 1100-1199 | ProfitAndLoss | Omsætning undergrupper (varekategorier) |
|
||||
| 1200-1299 | ProfitAndLoss | Diverse indtægter |
|
||||
| 1300-1399 | ProfitAndLoss | Produktionsomkostninger (COGS) |
|
||||
| 1500-1599 | ProfitAndLoss | Lokaleomkostninger |
|
||||
| 1600-1699 | ProfitAndLoss | Salgsomkostninger |
|
||||
| 1700-1799 | ProfitAndLoss | Administrationsomkostninger |
|
||||
| 1800-1899 | ProfitAndLoss | Personaleomkostninger (løn) |
|
||||
| 2000-2099 | ProfitAndLoss | Andre driftsomkostninger |
|
||||
| 2100-2199 | ProfitAndLoss | Afskrivninger |
|
||||
| 2200-2299 | ProfitAndLoss | Finansielle indtægter |
|
||||
| 2300-2399 | ProfitAndLoss | Finansielle udgifter |
|
||||
| **5500-5899** | **Balance** | **Bank, kasse, tilgodehavender** |
|
||||
| 5800-5899 | Balance | Bankkonti (typisk 5820 = drift) |
|
||||
| 6000-6899 | Balance | Leverandørgæld, moms, A-skat |
|
||||
| 6500-6599 | Balance | Moms (6500 = indgående, 6600 = udgående) |
|
||||
| 6900-6999 | Balance | Diverse statuskonti |
|
||||
|
||||
⚠️ **Dette er e-conomics STANDARD kontoplan** — ikke alle kunder har den. Hent altid kundens egen kontoplan via `/accounts` endpoint.
|
||||
|
||||
## Momskoder (VAT-accounts)
|
||||
|
||||
| Kode | Type | Sats | Bruges til |
|
||||
|---|---|---|---|
|
||||
| `U25` | Udgående | 25% | Salg af varer/ydelser i DK (momspligtig) |
|
||||
| `I25` | Indgående | 25% | Køb af varer/ydelser i DK (momsfradrag) |
|
||||
| `U0` | Udgående | 0% | Eksport, EU-salg med VIES, moms-fritagne ydelser |
|
||||
| `I0` | Indgående | 0% | Køb uden moms (import under bagatelgrænse) |
|
||||
| `UEU25` | Udgående | 25% | EU-varesalg (vareleverance til andet EU-land) |
|
||||
| `UV25` | Udgående | 25% | Byggeydelser i andre EU-lande (omvendt betalingspligt) |
|
||||
|
||||
⚠️ Korrekt momskode er afgørende for momsangivelsen — forkert kode kan give skattesmæk.
|
||||
|
||||
## Kassekladder (Journals)
|
||||
|
||||
| Journal-ID | Navn (typisk) | Bruges til |
|
||||
|---|---|---|
|
||||
| 1 | Daglig / Kassekladde | Almindelige bilag (køb, salg, bank) |
|
||||
| 2 | Indbetalinger | Kundeindbetalinger (kontra-konto typisk 5820 bank) |
|
||||
| 3 | Lønninger | Lønbogføring (entryType `financeVoucher`) |
|
||||
| 4 | Moms | Momsafregning |
|
||||
| 5 | Afskrivninger | Anlægsaktiver |
|
||||
|
||||
Journal-ID'er varierer fra kunde til kunde — hent altid `/journals` først.
|
||||
|
||||
## Workflow: Bogfør et bilag (kassekladdeline)
|
||||
|
||||
### Eksempel: bogfør kontantkøb af kontorartikler for 1.250,00 kr. inkl. moms (15-06-2026)
|
||||
|
||||
**Trin 1: Hent kunde-/kassekladde-info**
|
||||
```
|
||||
GET /journals
|
||||
→ Find journal-ID for "Daglig" (typisk 1)
|
||||
GET /journals/1/vouchers?voucherNumber=42
|
||||
→ Find næste ledige bilagsnummer
|
||||
```
|
||||
|
||||
**Trin 2: Opret kassekladdeline (visuelt, FØR API-kald)**
|
||||
```json
|
||||
{
|
||||
"accountingYear": {
|
||||
"year": 2026
|
||||
},
|
||||
"entries": [
|
||||
{
|
||||
"customerInvoiceNumber": "",
|
||||
"date": "2026-06-15",
|
||||
"voucherNumber": 42,
|
||||
"text": "Kontorartikler — Kontorbutikken A/S",
|
||||
"amount": -1250.00,
|
||||
"amountInBaseCurrency": -1250.00,
|
||||
"currency": "DKK",
|
||||
"account": {
|
||||
"accountNumber": 1690
|
||||
},
|
||||
"contraAccount": {
|
||||
"accountNumber": 5820
|
||||
},
|
||||
"vatAccount": {
|
||||
"vatCode": "I25"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Trin 3: Vis brugeren og bekræft**
|
||||
```
|
||||
⚠️ Klar til at bogføre:
|
||||
Kassekladde: Daglig (#1)
|
||||
Bilagsnr: 42
|
||||
Dato: 15-06-2026
|
||||
Tekst: Kontorartikler — Kontorbutikken A/S
|
||||
Debet: 1690 Kontorartikler 1.250,00 kr.
|
||||
Kredit: 5820 Bank, drift −1.250,00 kr.
|
||||
Momskode: I25 (25% indgående)
|
||||
Total: 1.250,00 kr. (inkl. 250,00 kr. moms)
|
||||
|
||||
Vil du bogføre? (ja/nej)
|
||||
```
|
||||
|
||||
**Trin 4: POST med Idempotency-Key**
|
||||
```
|
||||
POST /journals/1/vouchers/42/entries
|
||||
Headers:
|
||||
X-AppSecretToken: ${ECONOMIC_APP_SECRET_TOKEN}
|
||||
X-AgreementGrantToken: ${ECONOMIC_AGREEMENT_GRANT_TOKEN}
|
||||
Content-Type: application/json
|
||||
Idempotency-Key: <UUID v4>
|
||||
|
||||
Body: [ovenstående entries-array]
|
||||
```
|
||||
|
||||
⚠️ **Bogfør IKKE før brugeren har bekræftet.**
|
||||
|
||||
## Workflow: Synk banktransaktioner
|
||||
|
||||
1. **Hent konti** for bank (`/accounts?filter=accountType$eq:balance&pagesize=100`) — find bank-konto (typisk 5820)
|
||||
2. **Hent kontosaldo** for indeværende periode:
|
||||
```
|
||||
GET /accounts/5820/accounting-years/2026/periods/6/totals
|
||||
```
|
||||
3. **Sammenlign** med bankens faktiske saldo (fra CSV-import eller bank-API)
|
||||
4. **Vis diff** og identificer manglende/ukorrekte transaktioner
|
||||
5. **Foreslå kassekladdeline** for hver manglende transaktion
|
||||
|
||||
## Workflow: Hent åbne fakturaer (kunder)
|
||||
|
||||
```
|
||||
GET /invoices/booked?filter=balance$gt:0&pagesize=50
|
||||
```
|
||||
|
||||
Returnerer alle bogførte fakturaer med ubetalt saldo > 0.
|
||||
|
||||
For hver faktura:
|
||||
- `bookedInvoiceNumber` — fakturanr
|
||||
- `customer.name` — kundenavn
|
||||
- `netAmount` / `grossAmount` — beløb ekskl./inkl. moms
|
||||
- `balance` — udestående
|
||||
- `dueDate` — forfaldsdato (udledes af `termsNetDays` + `date`)
|
||||
- `currency` — DKK/EUR/USD
|
||||
|
||||
Bruges typisk til:
|
||||
- Inkasso-rapporter (forfaldne >30 dage)
|
||||
- Aldersopdelt fordringsliste (0-30, 31-60, 61-90, 90+)
|
||||
- Likviditetsforecast
|
||||
|
||||
## Workflow: Opret kunde
|
||||
|
||||
```json
|
||||
POST /customers
|
||||
{
|
||||
"name": "Coop Danmark A/S",
|
||||
"customerGroup": { "customerGroupNumber": 1 },
|
||||
"paymentTerms": { "paymentTermsNumber": 1 },
|
||||
"vatZone": { "vatZoneNumber": 1 },
|
||||
"currency": "DKK",
|
||||
"address": "Roskildevej 65",
|
||||
"zip": "2620",
|
||||
"city": "Albertslund",
|
||||
"country": "Denmark"
|
||||
}
|
||||
```
|
||||
|
||||
⚠️ **Tjek altid for duplikat** (søg eksisterende kunder først):
|
||||
```
|
||||
GET /customers?filter=name$like:Coop$Danmark
|
||||
```
|
||||
|
||||
## Almindelige fejl
|
||||
|
||||
- ❌ **Glemmer Idempotency-Key** ved POST → netværksfejl kan give dobbeltbogføring
|
||||
- ❌ **Forkert momskode** (U25 vs UEU25) → SKAT fanger det i krydstjek
|
||||
- ❌ **Brug danske datoer i API** — e-conomic bruger ISO `yyyy-MM-dd` i JSON, IKKE dansk `dd-MM-yyyy`
|
||||
- ❌ **Beløb uden fortegn-konvention** — negativ = debet, positiv = kredit (modsat dansk regnskabstradition)
|
||||
- ❌ **PageSize > 1000** uden pagination
|
||||
- ❌ **POST uden først at have hentet den aktuelle voucher** → 409 Conflict
|
||||
- ✅ ALTID brug Idempotency-Key ved alle writes
|
||||
- ✅ ALTID konverter dansk dd-MM-yyyy → ISO yyyy-MM-dd FØR API-kald
|
||||
- ✅ ALTID verificer kontoplanen (`/accounts`) før bogføring — kunden kan have egne konti
|
||||
- ✅ ALTID tjek eksisterende kunder/leverandører før oprettelse (dublet-forebyggelse)
|
||||
- ✅ ALTID vis bogføringsforslag til brugeren FØR API-kald
|
||||
|
||||
## Demo-mode anbefaling
|
||||
|
||||
Brug ALTID `?demo=true` under udvikling:
|
||||
- Læs-data virker (GET)
|
||||
- Skrive-data deaktiveret (POST/PUT returnerer 405)
|
||||
- Forhindrer utilsigtet bogføring i produktion
|
||||
- Kan derfor frit browse konti, fakturaer, kunder uden risiko
|
||||
|
||||
## Eksempel på aktivering
|
||||
|
||||
**Bruger:** "Bogfør et køb af kontorartikler for 1.250 kr. inkl. moms i e-conomic"
|
||||
|
||||
**Svar (forkortet):**
|
||||
> ✅ Forbinder e-conomic...
|
||||
>
|
||||
> Klar til bogføring:
|
||||
>
|
||||
> | Felt | Værdi |
|
||||
> |---|---|
|
||||
> | Kassekladde | Daglig (#1) |
|
||||
> | Bilagsnr | 42 |
|
||||
> | Dato | 15-06-2026 |
|
||||
> | Tekst | Kontorartikler — Kontorbutikken A/S |
|
||||
> | Debet (1690 Kontorartikler) | 1.250,00 kr. |
|
||||
> | Kredit (5820 Bank) | −1.250,00 kr. |
|
||||
> | Momskode | I25 (indgående 25%) |
|
||||
>
|
||||
> ⚠️ Bekræft før jeg bogfører. Svaret "ja, bogfør" udløser API-kald med Idempotency-Key.
|
||||
@@ -0,0 +1,183 @@
|
||||
---
|
||||
name: kontoplan-mapping
|
||||
description: >
|
||||
Kortlæg transaktioner, fakturaer og bilag til dansk standardkontoplan (SUM, DG, SL) eller
|
||||
bogføringssystem-specifik kontoplan (e-conomic 4-cifret, Uniconta) — aktiveres ved
|
||||
"hvilken konto?", "kontoplan", "kontorådgivning", "bogføring af [transaktionstype]",
|
||||
"hvor skal jeg bogføre dette".
|
||||
version: 0.2.0
|
||||
author: radix-accountant
|
||||
license: MIT
|
||||
metadata:
|
||||
hermes:
|
||||
tags: [accounting, dansk-regnskab, kontoplan, SUM, DG, SL, uniconta, e-conomic]
|
||||
related: [resultatopgoerelse, momsafregning, dansk-csv-import, e-conomic-integration]
|
||||
---
|
||||
|
||||
# Kontoplan-mapping
|
||||
|
||||
Mapper transaktioner og bilag til **korrekt konto i dansk standardkontoplan** (SUM, DG, SL). Bruges som hjælpe-skill af andre skills (resultatopgoerelse, momsafregning, dansk-csv-import, coop-bankimport).
|
||||
|
||||
## Aktiveres ved
|
||||
|
||||
- "Hvilken konto skal jeg bogføre et kontorlejemål på?"
|
||||
- "Hvor hører MobilePay-gebyr hjemme i kontoplanen?"
|
||||
- "Lav en kontoplan for min [virksomhedstype]"
|
||||
- "Tjek om denne transaktion er konteret korrekt"
|
||||
|
||||
## Dansk standardkontoplan — overblik
|
||||
|
||||
### Driftskonti (resultatopgørelse)
|
||||
|
||||
| Hovedgruppe | Kontointerval | Bruges til |
|
||||
|---|---|---|
|
||||
| Omsætning | 1000-1999 | Salg, service, licens, abonnement, eksport |
|
||||
| Produktionsomkostninger | 2000-2999 | Råvarer, direkte løn, underleverandører, fragt |
|
||||
| Salgsomkostninger | 3000-3999 | Marketing, salgsfremmende, rejser, repræsentation |
|
||||
| Administrationsomkostninger | 4000-4999 | Kontor, IT, telefon, kontorartikler, abonnementer |
|
||||
| Lokaleomkostninger | 5000-5999 | Husleje, varme, el, vand, renovation, rengøring |
|
||||
| Personaleomkostninger | 6000-6999 | Løn, pension, ATP, feriepenge, AER, sociale bidrag |
|
||||
| Afskrivninger | 7000-7999 | Lineære afskrivninger, scrap, salg af aktiver |
|
||||
| Finansielle poster | 8000-8999 | Renter, gebyrer, kursregulering, realiserede tab/gevinst |
|
||||
| Ekstraordinære poster | 9000-9999 | Ekstraordinære indtægter/udgifter (sjældent) |
|
||||
|
||||
### Statuskonti (balance)
|
||||
|
||||
| Hovedgruppe | Kontointerval | Bruges til |
|
||||
|---|---|---|
|
||||
| Aktiver | 10000-19999 | Tilgodehavender, varelager, anlægsaktiver, bank, kasse |
|
||||
| Passiver | 20000-29999 | Leverandørgæld, moms, A-skat, skyldig løn, feriepengeforpligtelse |
|
||||
| Egenkapital | 30000-39999 | Ansvarlig kapital, overført resultat, årets resultat |
|
||||
|
||||
## e-conomic standardkontoplan (4-cifret, mest udbredt)
|
||||
|
||||
e-conomic leveres med en standard dansk kontoplan. **De fleste danske SMV'er bruger denne uden ændringer.**
|
||||
|
||||
### Driftskonti (resultatopgørelse)
|
||||
|
||||
| Kontointerval | Bruges til |
|
||||
|---|---|
|
||||
| 1000-1099 | Indtægter (heading + omsætning) |
|
||||
| 1100-1299 | Diverse omsætning og indtægter |
|
||||
| 1300-1499 | Produktionsomkostninger / varekøb / direkte løn |
|
||||
| 1500-1599 | Lokaleomkostninger (husleje, el, varme) |
|
||||
| 1600-1699 | Salgs- og rejseomkostninger (marketing, transport) |
|
||||
| 1700-1799 | Administrationsomkostninger (kontor, IT, telefon) |
|
||||
| 1800-1899 | Personaleomkostninger (løn, pension, AER) |
|
||||
| 1900-2099 | Andre driftsomkostninger, afskrivninger |
|
||||
| 2100-2299 | Finansielle poster (renter, gebyrer) |
|
||||
| 2300-2999 | Ekstraordinære poster |
|
||||
|
||||
### Statuskonti (balance)
|
||||
|
||||
| Kontointerval | Bruges til |
|
||||
|---|---|
|
||||
| 5500-5899 | Aktiver — bank, kasse, tilgodehavender (5820 = typisk driftsbank) |
|
||||
| 6000-6899 | Passiver — leverandørgæld, moms, A-skat, skyldig løn |
|
||||
| 6500-6599 | Moms (udgående/indgående/afregning) |
|
||||
| 6900-6999 | Egenkapital, diverse status |
|
||||
|
||||
### e-conomic standardkonti — udvalgte
|
||||
|
||||
| Kontonr | Navn | Bruges til |
|
||||
|---|---|---|
|
||||
| 1010 | Salg af varer/ydelser m/moms | Standard DK-salg (25% moms) |
|
||||
| 1080 | Salg af varer/ydelser u/moms | Eksport, EU-salg (0% moms) |
|
||||
| 1300 | Varekøb | Råvarer til videresalg |
|
||||
| 1500 | Husleje | Kontorlejemål |
|
||||
| 1510 | El | Elforbrug |
|
||||
| 1520 | Varme | Opvarmning |
|
||||
| 1530 | Vand | Vandforbrug |
|
||||
| 1610 | Rejseudgifter | Transport, hotel, fly |
|
||||
| 1690 | Kontorartikler | Småanskaffelser, kontorartikler |
|
||||
| 1700 | Telefon & internet | Mobil, fastnet, internet |
|
||||
| 1730 | Software / IT-licenser | SaaS-abonnementer, licenser |
|
||||
| 1800 | Bruttoløn | Lønudbetaling |
|
||||
| 1810 | Pension | Pensionsbidrag |
|
||||
| 1820 | ATP | ATP-bidrag |
|
||||
| 1900 | AER | AER-bidrag |
|
||||
| 1910 | Feriepenge | Feriepengeforpligtelse |
|
||||
| 2200 | Bankgebyrer | Gebyrer, renter |
|
||||
| 2210 | Renteudgifter | Renter på lån |
|
||||
| 5500 | Kasse | Kontant beholdning |
|
||||
| 5508 | Bank, drift | Primær driftskonto (typisk) |
|
||||
| 5820 | Bank, kassekladde | Standard bank-modkonto |
|
||||
| 6500 | Udgående moms | 25% moms på salg |
|
||||
| 6510 | Indgående moms | 25% moms på køb |
|
||||
| 6520 | Momsafregning | Saldo (kredit = skyldig, debet = tilgodehavende) |
|
||||
|
||||
⚠️ **Tjek altid kundens egen kontoplan** via `GET /accounts` (e-conomic) eller Uniconta-eksport — der kan være afvigelser.
|
||||
|
||||
## Almindelige transaktioner — hurtig-mapping (alle systemer)
|
||||
|
||||
| Transaktion | SUM/DG | e-conomic | Type |
|
||||
|---|---|---|---|
|
||||
| Salg af vare (DK) | 1010 | 1010 | Driftsindtægt |
|
||||
| Salg af ydelse (DK) | 1020 | 1010 | Driftsindtægt |
|
||||
| Eksport (0% moms) | 1030 | 1080 | Driftsindtægt |
|
||||
| Varekøb (DK) | 2010 | 1300 | Driftsomkostning |
|
||||
| Kontorartikler | 4010 | 1690 | Driftsomkostning |
|
||||
| Software-abonnement | 4030 | 1730 | Driftsomkostning |
|
||||
| Hosting / cloud | 4030 | 1730 | Driftsomkostning |
|
||||
| Husleje | 5010 | 1500 | Driftsomkostning |
|
||||
| El, vand, varme | 5020-5040 | 1510-1530 | Driftsomkostning |
|
||||
| Internet, telefon | 5050 | 1700 | Driftsomkostning |
|
||||
| MobilePay-gebyr | 8010 | 2200 | Driftsomkostning (finansiel) |
|
||||
| Stripe-gebyr | 8010 | 2200 | Driftsomkostning (finansiel) |
|
||||
| Bankgebyr | 8020 | 2200 | Driftsomkostning (finansiel) |
|
||||
| Renteindtægt | 8030 | 2210 (neg.) | Driftsindtægt (finansiel) |
|
||||
| Renteudgift | 8040 | 2210 | Driftsomkostning (finansiel) |
|
||||
| Løn til ansat | 6010 | 1800 | Personaleomkostning |
|
||||
| A-skat (trukket fra løn) | 2090 | 6810 | Forpligtelse |
|
||||
| Pension (arbejdsgivers andel) | 6020 | 1810 | Personaleomkostning |
|
||||
| Feriepenge (reserveret) | 6015 | 1910 | Personaleomkostning |
|
||||
| Moms, udgående | 2060 | 6500 | Forpligtelse |
|
||||
| Moms, indgående | 2070 | 6510 | Tilgodehavende (modregnes) |
|
||||
| Moms, afregning | 2080 | 6520 | Forpligtelse/Tilgodehavende |
|
||||
| Bank (drift) | 5810 | 5508 / 5820 | Aktiv |
|
||||
|
||||
⚠️ **Tallene er eksempler iht. SUM-standarden** (Standard for virksomheders økonomistyring). Mange SMV'er bruger deres egen tilpassede kontoplan — **bekræft altid med virksomhedens egen kontoplan** inden bogføring.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. **Modtag transaktionstype** (eller specifik transaktion)
|
||||
2. **Find relevant kontointerval** (se tabellerne ovenfor)
|
||||
3. **Anvend kontekst-regler**:
|
||||
- Virksomhedstype (enkeltmands, ApS, A/S, forening) → kan have egne konti
|
||||
- Branche (detail, engros, service, IT, produktion) → brancherelaterede konti
|
||||
- Skatteforhold (momsregistrering, CVR, SElvangivelse) → særlige konti
|
||||
4. **Vis anbefaling med begrundelse**:
|
||||
- Kontonr, kontonavn, kontogruppe
|
||||
- Hvorfor denne konto og ikke en alternativ
|
||||
5. **Alternativer**: vis 1-2 alternative konti hvis der er rimelig tvivl
|
||||
6. **Bekræft med bruger** — kontoplan-valg er en menneskelig beslutning, ikke en agent-beslutning
|
||||
|
||||
## Eksempel-aktivering
|
||||
|
||||
**Bruger:** "Hvor skal jeg bogføre et Stripe-gebyr på 234,56 kr.?"
|
||||
|
||||
**Svar:**
|
||||
> **Anbefalet konto:** 8010 — Gebyrer, betalingsformidlere
|
||||
>
|
||||
> **Begrundelse:** Stripe-gebyrer er **finansielle omkostninger** (betalingsformidling), ikke driftsomkostninger. De hører under hovedgruppe 8 (Finansielle poster) og bogføres typisk sammen med bankgebyrer og renter.
|
||||
>
|
||||
> **Bogføring:**
|
||||
> ```
|
||||
> DEBET 8010 Gebyrer, betalingsformidlere 234,56
|
||||
> KREDIT 5810 Bank, driftskonto 234,56
|
||||
> ```
|
||||
>
|
||||
> **Alternativer:**
|
||||
> - 8030 (Renteudgifter) — hvis virksomheden samler alle gebyrer dér (mindre korrekt)
|
||||
> - 4025 (Salgsomkostninger, eksterne) — hvis virksomheden klassificerer alle salgsrelaterede omkostninger under salg
|
||||
>
|
||||
> ⚠️ *Dette er ikke bogføringsrådgivning. Kontakt din revisor for endelig kontoplan.*
|
||||
|
||||
## Særlige regler
|
||||
|
||||
- ❌ ALDRIG foreslå en konto uden begrundelse
|
||||
- ❌ ALDRIG antag at kontoplanen følger SUM-standarden — bekræft med brugeren
|
||||
- ❌ ALDRIG modificér kontoplanen uden advarsel og dobbeltgodkendelse
|
||||
- ✅ ALTID vis kontogruppe (drift/balance) og sektion (omsætning/omkostning/aktiv/passiv)
|
||||
- ✅ ALTID vis alternativer når der er rimelig tvivl
|
||||
- ✅ ALTID medtag "Dette er ikke bogføringsrådgivning" disclaimer
|
||||
@@ -0,0 +1,161 @@
|
||||
---
|
||||
name: momsafregning
|
||||
description: >
|
||||
Beregn dansk momsangivelse (moms, momsfradrag, momsrefusion) til SKAT — aktiveres ved
|
||||
forespørgsler om "moms", "momsangivelse", "momsrefusion", "kvartalsmoms", "halvårsmoms",
|
||||
"momsregnskab", "25% moms", "momsfri".
|
||||
version: 0.1.0
|
||||
author: radix-accountant
|
||||
license: MIT
|
||||
metadata:
|
||||
hermes:
|
||||
tags: [accounting, dansk-regnskab, moms, skat, momsangivelse]
|
||||
related: [resultatopgoerelse, kontoplan-mapping, dansk-csv-import]
|
||||
---
|
||||
|
||||
# Dansk Momsafregning
|
||||
|
||||
Forbereder **momsangivelse** klar til indberetning via SKAT's TastSelv Erhverv (eller skat.dk). Håndterer både **kvartalsmoms** (små/mellemstore virksomheder) og **halvårsmoms** (helårsmomsansøgere, mikrovirksomheder under 5 mio. kr. i omsætning).
|
||||
|
||||
## Aktiveres ved
|
||||
|
||||
- "Beregn moms for Q2 2026"
|
||||
- "Forbered momsangivelse for maj 2026"
|
||||
- "Hvad er min momsrefusion for hele 2025?"
|
||||
- "Hvornår skal jeg indberette moms næste gang?"
|
||||
|
||||
## Danske momssatser (gældende)
|
||||
|
||||
| Sats | Bruges til |
|
||||
|---|---|
|
||||
| **25%** | Standard (langt de fleste varer og ydelser i Danmark) |
|
||||
| **0%** | Eksport ud af EU, visse avis/abonnementer, kunstnerisk virksomhed under 300.000 kr. |
|
||||
| Fritaget | Sygehusbehandling, undervisning, finansielle ydelser, forsikring |
|
||||
|
||||
⚠️ **Bemærk**: EU-lande har forskellige satser, men denne skill håndterer **kun dansk moms**. For EU-handel brug særskilt sub-skill (ikke del af denne version).
|
||||
|
||||
## Kontogruppering (dansk standardkontoplan + e-conomic)
|
||||
|
||||
| Konto (SUM) | Konto (e-conomic) | Navn | Beløb i momsangivelsen |
|
||||
|---|---|---|---|
|
||||
| 1100-1199 | 1010 | Salg af varer/ydelser (DK, 25%) | Rubrik A: Varesalg (momspligtig) |
|
||||
| 1200-1299 | 1010 | Salg af ydelser (DK, 25%) | Rubrik A: Ydelsessalg (momspligtig) |
|
||||
| 1500-1599 | 1080 | Eksport (0% moms) | Rubrik A: EU-salg/eksport |
|
||||
| 1600-1699 | 1300 | Råvarer/varekøb (DK, 25%) | Rubrik B: Varekøb (momsfradrag) |
|
||||
| 1700-1799 | 1690 / 1730 | Driftsomkostninger (DK, 25%) | Rubrik B: Driftsomkostninger (momsfradrag) |
|
||||
| 1800-1899 | 1800 | Import (køb fra udland) | Rubrik B: Import |
|
||||
| 1900-1999 | — | Køb af ydelser uden moms | (ingen moms at fradrage) |
|
||||
| 2060 | 6500 | Udgående moms | (salgsmoms) |
|
||||
| 2070 | 6510 | Indgående moms | (momsfradrag) |
|
||||
| 2080 | 6520 | Momsafregning | (saldo) |
|
||||
|
||||
⚠️ **Tjek altid kundens egen kontoplan** — de faktiske kontonumre kan variere. Ovenstående er standard-reference for både SUM (dansk standardkontoplan) og e-conomic (4-cifret).
|
||||
|
||||
## Struktur (momsangivelse, kvartal)
|
||||
|
||||
```
|
||||
MOMSANGIVELSE — [Kvartal] [År]
|
||||
═══════════════════════════════════════════════════
|
||||
Periode: [dd-MM-yyyy] til [dd-MM-yyyy]
|
||||
CVR: [hentes fra Radix]
|
||||
═══════════════════════════════════════════════════
|
||||
|
||||
RUBRIK A — UDGÅENDE MOMS (moms af salg)
|
||||
───────────────────────────────────────────────────
|
||||
A1 Varesalg, momspligtig (25%) 1.234.567,89
|
||||
A2 Ydelsessalg, momspligtig (25%) 89.012,34
|
||||
A3 Varesalg, EU/eksport (0%) 0,00
|
||||
A4 Ydelsessalg, EU (0%) 0,00
|
||||
...
|
||||
Udgående moms i alt (25% af A1+A2) 330.895,06
|
||||
───────────────────────────────────────────────────
|
||||
|
||||
RUBRIK B — INDGÅENDE MOMS (moms på køb, kan fradrages)
|
||||
───────────────────────────────────────────────────
|
||||
B1 Varekøb, momsfradrag (25%) 450.123,45
|
||||
B2 Driftsomkostninger, momsfradrag (25%) 67.890,12
|
||||
B3 Import, betalt toldmoms 0,00
|
||||
...
|
||||
Indgående moms i alt (25% af B1+B2) 129.503,39
|
||||
───────────────────────────────────────────────────
|
||||
|
||||
RUBRIK C — SALGSMOMS (MOMS DER SKAL INDBETALES)
|
||||
═══════════════════════════════════════════════════
|
||||
Udgående moms 330.895,06
|
||||
Indgående moms −129.503,39
|
||||
───────────────────────────────────────────────────
|
||||
SALGSMOMS 201.391,67
|
||||
═══════════════════════════════════════════════════
|
||||
|
||||
Hvis positiv: SKAT skylder dig penge
|
||||
Hvis negativ: Du skylder SKAT penge
|
||||
───────────────────────────────────────────────────
|
||||
|
||||
INDBETALINGSFRIST
|
||||
───────────────────────────────────────────────────
|
||||
Kvartalsmoms: 1. i 3. måned efter kvartalets udløb
|
||||
Eksempel: Q1 (jan-mar) → frist 1. juni
|
||||
═══════════════════════════════════════════════════
|
||||
```
|
||||
|
||||
## Workflow
|
||||
|
||||
1. **Spørg om periode** (måned, kvartal, halvår)
|
||||
2. **Bekræft virksomhedstype**: lille/mellemstor (kvartal) eller helårsmomsansøger (halvår)
|
||||
3. **Hent salg og køb** fra Radix ERP for perioden
|
||||
4. **Adskil konti** efter momssats (25%, 0%, fritaget)
|
||||
5. **Beregn udgående moms** (moms af eget salg) og **indgående moms** (moms af køb)
|
||||
6. **Beregn salgsmoms** = udgående moms − indgående moms
|
||||
7. **Vis beløb at indberette** (positiv = SKAT skylder dig, negativ = du skylder SKAT)
|
||||
8. **Angiv indberetningsfrist** iht. SKAT's regler
|
||||
9. **Tilbyd at generere SKAT XML/CSV** (hvis brugeren vil indberette direkte)
|
||||
|
||||
## Reverse charge (vigtig!)
|
||||
|
||||
For køb af ydelser fra udlandet (fx en tysk konsulent):
|
||||
|
||||
- **Køber** skal selv beregne og indberette momsen (reverse charge)
|
||||
- Konti der er reverse charge: typisk 1900-1999 (køb af ydelser uden moms i købsprisen)
|
||||
- I momsangivelsen: medtag i **Rubrik A** (både salg OG køb) og **Rubrik B** som fradrag — nettoeffekten er nul, men SKAT kræver begge beløb indberettet
|
||||
|
||||
## Almindelige fejl
|
||||
|
||||
- ❌ Medtag IKKE lønudgifter i momsbasis (løn er momsfri i DK)
|
||||
- ❌ Medtag IKKE renter i momsbasis (finansielle ydelser er fritaget)
|
||||
- ❌ Glem IKKE reverse charge-beløb — SKAT krydstjekker
|
||||
- ❌ Brug IKKE EUR-sats for DK-virksomheder — 25% er dansk standardsats
|
||||
- ✅ ALTID angiv CVR-nummer i rapporten
|
||||
- ✅ ALTID vis "Dette er ikke skatterådgivning — kontakt din revisor/SKAT" på momsangivelser
|
||||
|
||||
## SKAT-frister (2026)
|
||||
|
||||
| Type | Periode | Frist |
|
||||
|---|---|---|
|
||||
| Kvartalsmoms | Q1 (jan-mar) | 1. juni |
|
||||
| Kvartalsmoms | Q2 (apr-jun) | 1. september |
|
||||
| Kvartalsmoms | Q3 (jul-sep) | 1. december |
|
||||
| Kvartalsmoms | Q4 (okt-dec) | 1. marts (næste år) |
|
||||
| Halvårsmoms | H1 (jan-jun) | 1. september |
|
||||
| Halvårsmoms | H2 (jul-dec) | 1. marts (næste år) |
|
||||
| Moms, mikrovirksomhed (<5 mio. kr.) | Helår (jan-dec) | 1. marts (næste år, men kun hvis ansøgt om helårsmoms) |
|
||||
|
||||
⚠️ Frister kan ændre sig — tjek altid skat.dk for aktuelle frister.
|
||||
|
||||
## Eksempel på aktivering
|
||||
|
||||
**Bruger:** "Forbered momsangivelse for Q2 2026"
|
||||
|
||||
**Svar (forkortet):**
|
||||
> Momsangivelse for perioden 01-04-2026 til 30-06-2026:
|
||||
>
|
||||
> | Rubrik | Beløb | Moms |
|
||||
> |---|---|---|
|
||||
> | A1 Varesalg | 234.567,89 | 58.641,97 |
|
||||
> | A2 Ydelsessalg | 12.345,67 | 3.086,42 |
|
||||
> | B1 Varekøb | 89.012,34 | 22.253,09 |
|
||||
> | B2 Driftsomk. | 23.456,78 | 5.864,20 |
|
||||
> | **Salgsmoms** | | **33.611,10** |
|
||||
>
|
||||
> **Frist:** 01-09-2026 (1. september 2026)
|
||||
>
|
||||
> *Dette er ikke skatterådgivning. Kontakt din revisor eller SKAT for endelig indberetning.*
|
||||
@@ -0,0 +1,104 @@
|
||||
---
|
||||
name: radix-accountant
|
||||
description: >
|
||||
Dansk bogføring, regnskab og ERP-assistance til Radix-portalen — aktiveres ved forespørgsler om
|
||||
resultatopgørelse, momsafregning, kontoplan, CSV-import, bankafstemning, Uniconta-integration,
|
||||
e-conomic-integration eller Coop Danmark-reconciliation. Svar altid på dansk, brug dansk
|
||||
talformatering (1.234.567,89) og dd-MM-yyyy datoformat.
|
||||
version: 0.2.0
|
||||
author: radix-accountant
|
||||
license: MIT
|
||||
metadata:
|
||||
hermes:
|
||||
tags: [accounting, dansk-regnskab, moms, radix-erp, uniconta, e-conomic, coop]
|
||||
related: [resultatopgoerelse, momsafregning, kontoplan-mapping, dansk-csv-import, coop-bankimport, e-conomic-integration]
|
||||
---
|
||||
|
||||
# radix-accountant
|
||||
|
||||
Dansk bogførings- og regnskabsassistent skræddersyet til **Radix ERP** (CRM/ERP kørt på portal.radixadm.dk) med integration mod **Uniconta** (lokalt), **e-conomic** (cloud-bogføring) og **Coop Danmark**-afstemningsflow.
|
||||
|
||||
## Aktiveres ved
|
||||
|
||||
Når brugeren skriver på dansk om:
|
||||
|
||||
- **Resultatopgørelse, P&L, årsregnskab, balance, indtægter/udgifter** → `resultatopgoerelse`
|
||||
- **Moms, momsangivelse, momsfradrag, momsrefusion, kvartals-/halvårsmoms, 25% / 0%** → `momsafregning`
|
||||
- **CSV-import, bank-eksport, kontoudtog, Nordea/Danske Bank/Jyske/Sydbank-format** → `dansk-csv-import`
|
||||
- **Coop Danmark, bankbetaling, opgørelse, afstemning mod fakturaer** → `coop-bankimport`
|
||||
- **Kontoplan, kontonummer, dansk standardkontoplan, finanskonto, postering** → `kontoplan-mapping`
|
||||
- **e-conomic, bogfør i e-conomic, hent fakturaer fra e-conomic, kassekladde, sync mod e-conomic** → `e-conomic-integration`
|
||||
|
||||
## Faste konventioner (BINDENDE for alle sub-skills)
|
||||
|
||||
| Parameter | Værdi |
|
||||
|---|---|
|
||||
| Sprog | Dansk (alle svar, prompts, fejlmeddelelser) |
|
||||
| Talformatering | `1.234.567,89` — punktum som tusindtalsseparator, komma som decimal |
|
||||
| Negativ beløb | `−1.234,56` med unicode minus-tegn (Unicode minus U+2212), ALDRIG `-` |
|
||||
| Datoformat | `dd-MM-yyyy` (fx `15-06-2026`) |
|
||||
| Klokkeslæt | `HH:mm` 24-timers (fx `14:35`) |
|
||||
| Valuta | `kr.` eller `DKK` — aldrig `$` medmindre brugeren eksplicit beder om anden valuta |
|
||||
| Moms | `25%` (standard), `0%` (fritagne ydelser), `ekskl. moms` / `inkl. moms` |
|
||||
| Kontoplan reference | **e-conomic**: 4-cifret DK-standard (1000-9999). **Uniconta**: egen struktur. **SUM/DG/SL**: generisk dansk standard |
|
||||
| Decimaler i rapporter | 2 decimaler for beløb, 1 decimal for procenter, 0 decimaler for heltal |
|
||||
| Afrundingsregel | Banker's rounding (half-even) for at matche dansk bogføringspraksis |
|
||||
| Bank reference | BIC + 10-cifret kontonr (dansk format) eller bank-reference fra CSV |
|
||||
|
||||
## Reference-miljø
|
||||
|
||||
- **Radix ERP**: kører på `https://portal.radixadm.dk` (DEBUG=True bevidst, dansk datoformat bevidst)
|
||||
- **Test-login**: `testadmin@example.dk` / `test1234` (KUN til test/udvikling — ALDRIG persistér i rapporter)
|
||||
- **Uniconta**: lokalt bogføringssystem, fakturaer og kreditorer hentes derfra
|
||||
- **e-conomic**: cloud-bogføring (https://www.e-conomic.dk), REST API på `https://restapi.e-conomic.com`, OAuth-baseret adgang via AppSecretToken + AgreementGrantToken
|
||||
- **Coop Danmark**: bankbetalinger + specifikationer (opgørelser) per indbetaling
|
||||
|
||||
## Bogføringssystem — hvornår bruges hvad
|
||||
|
||||
| System | Bruges til | Fordel | Ulempe |
|
||||
|---|---|---|---|
|
||||
| **Uniconta** | Dansk lokalt bogføringssystem, mange SMV'er | Lokal kontrol, dyb funktionalitet | Kræver installation |
|
||||
| **e-conomic** | Cloud-bogføring, Danmarks mest udbredte | Altid tilgængeligt, stærk API, gratis for små | Begrænset fleksibilitet i kontoplan |
|
||||
| **Radix ERP (egen)** | Intern ERP, CRM-delen | Integreret med Radix-data | Bogføring er ikke primær funktion |
|
||||
|
||||
**Standardantagelse**: Hvis brugeren ikke specificerer, brug **e-conomic** (mest udbredt i danske SMV'er og har den bedste API). Spørg hvis det er uklart.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. **Identificér brugerens hensigt** ud fra nøgleord (se listen ovenfor)
|
||||
2. **Aktivér den relevante sub-skill** ved at indlæse dennes `SKILL.md` først
|
||||
3. **Anvend de faste konventioner** i alle output (sprog, tal, dato, valuta)
|
||||
4. **Bekræft data med brugeren** før du skriver noget tilbage til Radix/Uniconta/e-conomic (aldrig stille skriv)
|
||||
5. **Generer rapporten** med tydelig header, periode, totaler og fodnoter
|
||||
6. **Vis altid kildereference** (transaktions-id, fakturanummer, bankreference) i rapporter
|
||||
|
||||
## Vigtige regler
|
||||
|
||||
- ❌ ALDRIG bogfør uden brugerens eksplicitte godkendelse — vis diff først
|
||||
- ❌ ALDRIG antag valuta — bekræft `DKK`/`EUR`/`USD` hvis ikke tydeligt
|
||||
- ❌ ALDRIG brug amerikansk/UK-formatering (`,` som tusindtalsseparator) — det er den forkerte vej rundt for dansk
|
||||
- ❌ ALDRIG modificér kontoplanen uden advarsel — ændringer kræver dobbeltgodkendelse
|
||||
- ❌ ALDRIG hardcode API-tokens i eksempler — brug altid `${ECONOMIC_APP_SECRET_TOKEN}` placeholder-syntaks
|
||||
- ❌ ALDRIG bogfør bilag i e-conomic uden først at vise `entries` JSON til brugeren
|
||||
- ✅ ALTID vis "Dette er ikke bogføringsrådgivning — kontakt din revisor" på alle moms- og årsregnskabsrapporter
|
||||
- ✅ ALTID angiv kilde (bankfil, Uniconta-id, e-conomic-bogføringsnr, faktura-nr) for hvert tal
|
||||
- ✅ ALTID spørg før du sletter — brug aldrig `DELETE` uden bekræftelse
|
||||
- ✅ ALTID brug Idempotency-Key ved POST/PUT til e-conomic API (forhindrer dobbeltbogføring ved netværksfejl)
|
||||
|
||||
## Sub-skills
|
||||
|
||||
| Skill | Formål |
|
||||
|---|---|
|
||||
| [resultatopgoerelse](resultatopgoerelse/SKILL.md) | Byg dansk P&L/resultatopgørelse med korrekt kontogruppering |
|
||||
| [momsafregning](momsafregning/SKILL.md) | Beregn momsangivelse (kvartal/halvår), klar til SKAT-indberetning |
|
||||
| [kontoplan-mapping](kontoplan-mapping/SKILL.md) | Map transaktioner til dansk standardkontoplan (SUM/DG/SL/e-conomic) |
|
||||
| [dansk-csv-import](dansk-csv-import/SKILL.md) | Importér danske bank-CSV'er (Nordea, Danske Bank, Jyske, Sydbank) |
|
||||
| [coop-bankimport](coop-bankimport/SKILL.md) | Reconcilier Coop Danmark bankbetalinger mod Uniconta- eller e-conomic-fakturaer |
|
||||
| [e-conomic-integration](e-conomic-integration/SKILL.md) | Integration med e-conomic cloud-bogføring (auth, konti, kassekladde, fakturaer) |
|
||||
|
||||
## Reference-dokumenter
|
||||
|
||||
- `REFERENCES/dansk_kontoplan.md` — Oversigt over SUM/DG/SL-konti (kort)
|
||||
- `REFERENCES/momssatser.md` — Aktuelle danske momssatser og fritagelser
|
||||
- `REFERENCES/coop_format.md` — Forventet format på Coop opgørelser (PDF/Excel)
|
||||
- `REFERENCES/economic_api.md` — e-conomic REST API endpoints og auth-flow
|
||||
@@ -0,0 +1,148 @@
|
||||
---
|
||||
name: resultatopgoerelse
|
||||
description: >
|
||||
Byg en dansk resultatopgørelse (P&L) ud fra Radix ERP-data — aktiveres ved forespørgsler om
|
||||
"resultatopgørelse", "P&L", "årsregnskab", "indtjening pr. måned/kvartal/år".
|
||||
version: 0.1.0
|
||||
author: radix-accountant
|
||||
license: MIT
|
||||
metadata:
|
||||
hermes:
|
||||
tags: [accounting, dansk-regnskab, resultatopgoerelse, radix-erp]
|
||||
related: [kontoplan-mapping, momsafregning]
|
||||
---
|
||||
|
||||
# Resultatopgørelse (Dansk P&L)
|
||||
|
||||
Genererer en **dansk resultatopgørelse** med korrekt kontogruppering iht. dansk standardkontoplan, dansk talformatering (`1.234.567,89`) og dansk terminologi (Bruttofortjeneste, Driftsresultat, Årets resultat).
|
||||
|
||||
## Aktiveres ved
|
||||
|
||||
- "Vis resultatopgørelsen for Q1 2026"
|
||||
- "Hvad er bruttofortjenesten for maj?"
|
||||
- "Generer P&L for hele 2025"
|
||||
- "Sammenlign omsætning mellem januar og februar 2026"
|
||||
|
||||
## Struktur (standard dansk resultatopgørelse)
|
||||
|
||||
```
|
||||
RESULTATOPGØRELSE — [Periode]
|
||||
═══════════════════════════════════════════════════
|
||||
|
||||
NETTOOMSÆTNING
|
||||
Salg af varer/ydelser (konto 1000-1099) 1.234.567,89
|
||||
Andre driftsindtægter (konto 1100-1199) 5.678,90
|
||||
───────────────────────────────────────────────────
|
||||
Nettoomsætning i alt 1.240.246,79
|
||||
|
||||
VAREFORBRUG / COGS
|
||||
Varekøb (konto 1300-1399) −450.123,45
|
||||
Direkte løn (konto 1400-1499) −89.012,34
|
||||
Fragt/emballage (konto 1500-1599) −12.345,67
|
||||
───────────────────────────────────────────────────
|
||||
Vareforbrug i alt −551.481,46
|
||||
|
||||
═══════════════════════════════════════════════════
|
||||
BRUTTOFORTJENESTE 688.765,33
|
||||
Bruttomargin 55,5%
|
||||
|
||||
EKSTERNE OMKOSTNINGER
|
||||
Salgsomkostninger (konto 1600-1699) −23.456,78
|
||||
Lokaleomkostninger (konto 1700-1799) −45.678,90
|
||||
Administrationsomkostninger (1800-1899) −34.567,89
|
||||
───────────────────────────────────────────────────
|
||||
Eksterne omkostninger i alt −103.703,57
|
||||
|
||||
═══════════════════════════════════════════════════
|
||||
DRIFTSRESULTAT FØR AFSKRIVNINGER (EBITDA) 585.061,76
|
||||
EBITDA-margin 47,2%
|
||||
|
||||
AFSKRIVNINGER
|
||||
Afskrivninger (konto 2000-2099) −50.000,00
|
||||
───────────────────────────────────────────────────
|
||||
Afskrivninger i alt −50.000,00
|
||||
|
||||
═══════════════════════════════════════════════════
|
||||
DRIFTSRESULTAT (EBIT) 535.061,76
|
||||
Driftsmargin 43,2%
|
||||
|
||||
FINANSIELLE POSTER
|
||||
Renteindtægter (konto 2100-2199) 1.234,56
|
||||
Renteudgifter (konto 2200-2299) −5.678,90
|
||||
Kursreguleringer (konto 2300-2399) 789,01
|
||||
───────────────────────────────────────────────────
|
||||
Finansielle poster i alt −3.655,33
|
||||
|
||||
═══════════════════════════════════════════════════
|
||||
RESULTAT FØR SKAT 531.406,43
|
||||
|
||||
Skat (konto 2500-2599) −117.149,42
|
||||
───────────────────────────────────────────────────
|
||||
|
||||
═══════════════════════════════════════════════════
|
||||
ÅRETS RESULTAT 414.257,01
|
||||
Nettoavance 33,4%
|
||||
═══════════════════════════════════════════════════
|
||||
```
|
||||
|
||||
## Workflow
|
||||
|
||||
1. **Spørg om periode** (måned, kvartal, år, eller brugerdefineret datointerval)
|
||||
2. **Hent data fra Radix ERP** via API eller Uniconta-eksport — spørg brugeren hvilken kilde der foretrækkes
|
||||
3. **Gruppér konti** efter dansk standardkontoplan-struktur (se kontoplan-mapping skill)
|
||||
4. **Beregn totaler** for hver sektion (Nettoomsætning, Vareforbrug, Eksterne omkostninger, osv.)
|
||||
5. **Beregn marginer**:
|
||||
- Bruttomargin = Bruttofortjeneste / Nettoomsætning × 100
|
||||
- EBITDA-margin = EBITDA / Nettoomsætning × 100
|
||||
- Driftsmargin = EBIT / Nettoomsætning × 100
|
||||
- Nettoavance = Årets resultat / Nettoomsætning × 100
|
||||
6. **Formater tal** iht. faste konventioner (punktum tusindtalsseparator, komma decimal)
|
||||
7. **Vis rapport** med tydelig periode, totaler og marginer
|
||||
8. **Tilbyd sammenligning** med tilsvarende periode året før (hvis data findes)
|
||||
|
||||
## Sammenlignings-rapport (bonus)
|
||||
|
||||
Når brugeren beder om en sammenligning:
|
||||
|
||||
```
|
||||
SAMMENLIGNING — [Periode A] vs [Periode B]
|
||||
═══════════════════════════════════════════════════
|
||||
Periode A Periode B Ændring
|
||||
────────────────────────────────────────────────────────────
|
||||
Nettoomsætning 1.234.567,89 1.100.000,00 +12,2%
|
||||
Bruttofortjeneste 688.765,33 590.000,00 +16,7%
|
||||
Bruttomargin 55,5% 53,6% +1,9 pp
|
||||
Driftsresultat (EBIT) 535.061,76 420.000,00 +27,4%
|
||||
Driftsmargin 43,2% 38,2% +5,0 pp
|
||||
Årets resultat 414.257,01 310.000,00 +33,6%
|
||||
═══════════════════════════════════════════════════
|
||||
```
|
||||
|
||||
`pp` = procentpoint (almindelig dansk regnskabsterm).
|
||||
|
||||
## Almindelige fejl at undgå
|
||||
|
||||
- ❌ Vis "−" som `-` (bindestreg) — brug Unicode minus `−` (U+2212) for korrekt typografi
|
||||
- ❌ Bland ikke `1.234,56` (dansk) med `1,234.56` (US) — brug ALTID dansk
|
||||
- ❌ Medtag ikke moms i omsætningstallet — nettoomsætning er ekskl. moms
|
||||
- ❌ Vis ikke "EBIT" som "Driftsresultat" og "EBITDA" som "Driftsresultat før afskrivninger" som om de er det samme — de er forskellige
|
||||
- ✅ ALTID vis "Dette er ikke bogføringsrådgivning" i årsregnskaber
|
||||
|
||||
## Eksempel på aktivering
|
||||
|
||||
**Bruger:** "Vis mig resultatopgørelsen for 2025"
|
||||
|
||||
**Svar (forkortet):**
|
||||
> Henter transaktioner for perioden 01-01-2025 til 31-12-2025 fra Radix ERP...
|
||||
>
|
||||
> ```
|
||||
> RESULTATOPGØRELSE — 01-01-2025 til 31-12-2025
|
||||
> ═══════════════════════════════════════════════
|
||||
> Nettoomsætning i alt 12.345.678,90
|
||||
> ...
|
||||
> ÅRETS RESULTAT 4.142.570,10
|
||||
> Nettoavance 33,6%
|
||||
> ═══════════════════════════════════════════════
|
||||
> ```
|
||||
>
|
||||
> *Dette er ikke bogføringsrådgivning. Kontakt din revisor for årsregnskab.*
|
||||
@@ -0,0 +1,314 @@
|
||||
---
|
||||
name: erp-data-reconciliation
|
||||
description: |
|
||||
Analyze and reconcile financial/accounting data between two ERP systems
|
||||
(e.g. SAP/Coop and Uniconta) using Excel exports. Covers file inspection,
|
||||
column mapping, Danish-format normalization, match-engine design, and
|
||||
iterative build workflow.
|
||||
trigger: |
|
||||
User asks to reconcile, match, compare, or afstemme data between two
|
||||
accounting/ERP systems, typically via Excel files. Also relevant when
|
||||
importing/exporting financial ledgers, kontoudtog, vendor/customer
|
||||
statements, or bonus calculations across systems.
|
||||
---
|
||||
|
||||
# ERP Data Reconciliation Skill
|
||||
|
||||
## Overview
|
||||
|
||||
Reconciling data between two ERP/accounting systems is a multi-phase task.
|
||||
The typical deliverable is a webapp or script that can:
|
||||
1. Import Excel files from both systems
|
||||
2. Normalize dates, amounts, and text
|
||||
3. Match postings between systems
|
||||
4. Show unmatched, partial matches, and differences
|
||||
5. Support manual approval/rejection
|
||||
6. Generate reports
|
||||
|
||||
## Phase 1: File Analysis (CRITICAL — do not skip)
|
||||
|
||||
Before writing any import code, run a structured analysis of the Excel files.
|
||||
|
||||
### Steps
|
||||
1. List all files in both source directories
|
||||
2. For each file, identify:
|
||||
- Sheet names
|
||||
- Actual header row location (may NOT be row 1!)
|
||||
- Column names and data types
|
||||
- Date columns and their formats
|
||||
- Amount columns and their formats
|
||||
- Text columns
|
||||
- Reference/invoice/document columns
|
||||
- Null percentages
|
||||
- Duplicate rows
|
||||
- Data range (min/max dates)
|
||||
|
||||
### Key Pitfalls
|
||||
- **Header row is NOT always row 1.** Uniconta exports often have account
|
||||
info in rows 1-2, blank row 3, headers in row 4.
|
||||
- **Amounts mixed formats.** Danish Excel files often have amounts as both
|
||||
Excel floats AND as Danish text strings like `-2.543.803,93`.
|
||||
Always normalize using a robust parser:
|
||||
```python
|
||||
def parse_danish_number(val):
|
||||
if pd.isna(val): return float('nan')
|
||||
if isinstance(val, (int, float)): return float(val)
|
||||
s = str(val).strip()
|
||||
if not s: return float('nan')
|
||||
if '.' in s and ',' in s:
|
||||
s = s.replace('.', '').replace(',', '.')
|
||||
elif ',' in s: s = s.replace(',', '.')
|
||||
return float(s)
|
||||
```
|
||||
- **Invoice/reference numbers may NOT match between systems.** This is the
|
||||
#1 cause of failed reconciliation. Always check overlap BEFORE assuming
|
||||
IDs can be used as join keys. Use amount overlap as a quick viability check.
|
||||
|
||||
### Analysis Script Template
|
||||
Use or adapt `scripts/analyze_excel.py` from the Coop-Uniconta project:
|
||||
- Reads all sheets in all files
|
||||
- Prints column stats, null counts, sample values
|
||||
- Detects Danish number formats
|
||||
- Identifies empty/duplicate rows
|
||||
|
||||
## Phase 2: Determine Match Strategy
|
||||
|
||||
After analysis, document the match strategy BEFORE coding the engine.
|
||||
|
||||
### Decision Tree
|
||||
1. **Do invoice/reference numbers overlap?**
|
||||
- If yes → use `Faktura + abs(Amount) + Date` as PRIMARY signal (two-phase).
|
||||
First phase: Reference==Faktura + abs(Amount) within tolerance (±5 DKK) + Date±21d.
|
||||
Second phase: abs(Amount) + Date±21d for remaining rows.
|
||||
- If no → use `Amount + Date + Text similarity`
|
||||
2. **Are dates exact or within tolerance?**
|
||||
- Same day → exact
|
||||
- ±1-3 days → high confidence partial
|
||||
- ±7 days → medium confidence
|
||||
- ±21 days → general match tolerance
|
||||
- >21 days → fuzzy/manual only
|
||||
3. **Is text available on both sides?**
|
||||
- Both have text → Levenshtein/rapidfuzz similarity score
|
||||
- Only one side has text → amount+date only
|
||||
4. **Do signs differ?**
|
||||
- Same sign → same direction transaction
|
||||
- Opposite sign → same transaction viewed from creditor vs debtor side.
|
||||
Match on `abs(amount)` but label "MODSAT" in output.
|
||||
*Example: Coop RE (payment) is negative; Uniconta Faktura is positive.*
|
||||
|
||||
### Tolerance Matching (V6 innovation)
|
||||
|
||||
Instead of exact amount match (±0.01), use a tolerance (±5 DKK):
|
||||
- Catches rounding differences between systems
|
||||
- Catches partial payments (one system may record 440.000, the other 430.732)
|
||||
- Uses bucket-based indexing for fast lookup:
|
||||
```python
|
||||
AMT_TOL = 5.0
|
||||
bucket = round(abs_amount / AMT_TOL) * AMT_TOL
|
||||
# Search adjacent buckets: [bucket-AMT_TOL, bucket, bucket+AMT_TOL]
|
||||
```
|
||||
|
||||
### Coop Fakturaliste + Uniconta Betalingsfordeling as the Simple Forward Workflow
|
||||
|
||||
When the user wants the process to be as simple as possible, prefer a forward-looking workflow based on:
|
||||
1. `Payment_advice`
|
||||
2. latest Uniconta Coop debtor/payment allocation export
|
||||
3. the complete Coop invoice list
|
||||
|
||||
Model the invoice list and payment allocation as separate import sources. Use invoice number as the primary join between Coop invoices and Uniconta allocation lines, and calculate paid/open/partial status from payment/settlement lines rather than only from original invoice postings. Treat the payment allocation as an explanation layer between a bank deposit and many invoice/credit lines.
|
||||
|
||||
See `references/coop-invoice-payment-allocation.md` for the concrete Django model/import/status pattern, UI shape, and tests.
|
||||
|
||||
### Coop Payment Advice / Settlement Files as a Fourth Source
|
||||
|
||||
When the user provides Coop betalingsadviseringer/opgørelser for individual bank
|
||||
payments, treat them as a separate reconciliation source, not just attachments.
|
||||
They are often the missing bridge:
|
||||
|
||||
`Bank deposit → Coop payment advice total → advice invoice/credit lines → Coop ledger / Uniconta invoices`
|
||||
|
||||
For Coop-Uniconta style systems, the import UI should expose the full operational set, not just "Coop" and "Uniconta":
|
||||
1. Uniconta Coop Debitor / payment allocation
|
||||
2. Uniconta/ERP invoice list: all Coop invoices/credit notes for the year
|
||||
3. Coop account statement / kontoudtog
|
||||
4. Bank statement containing all Coop postings
|
||||
5. Coop Payment_advice documents, uploadable continuously as they are collected
|
||||
|
||||
Recommended source/model names:
|
||||
- `coop_settlement` or `coop_payment_advice`
|
||||
- `PaymentAdvice` / `PaymentAdviceDocument` / `SettlementFile`: file name, unique id/bilag number, file type
|
||||
(PDF/XLSX), document date, payment date, total amount, period, parse status, matched bank transaction, raw
|
||||
text/raw JSON.
|
||||
- `PaymentAdviceLine` / `SettlementLine`: invoice number, invoice date, company
|
||||
code, amount, currency, line type (invoice/credit/bonus/modregning/unknown),
|
||||
links to bank/Coop/Uniconta transactions, match status, explanation.
|
||||
|
||||
If the user does not yet have all payment advice documents, still allow upload now. Store raw PDF text or Excel row JSON with best-effort date/amount/reference/classification so later parsers can be tightened without re-requesting files.
|
||||
|
||||
Parser patterns observed:
|
||||
- XLSX files may have header row 3 with columns `Fakturanummer`, `Fakturadato`,
|
||||
`Firmakode`, `Beløb`, `Valuta` and supplier metadata in rows 1-2.
|
||||
- Text PDFs often extract as repeated triples:
|
||||
invoice number line, `dd.mm.yyyy firmakode` line, amount/`DKK` line.
|
||||
- Some PDFs split sign into a separate `-` column/line; treat `123,45`, `-`,
|
||||
`DKK` as `-123,45`.
|
||||
- Some PDFs are bank statement screenshots, not payment advice. Classify and do
|
||||
not parse them as settlement lines.
|
||||
- Some files include manual middle calculations or special layouts. Keep raw text
|
||||
and mark parse confidence/status instead of silently forcing a schema.
|
||||
|
||||
Matching/reporting logic:
|
||||
1. Match payment-advice total to bank deposit using ±5 DKK tolerance.
|
||||
2. Then match advice lines to Uniconta by invoice number first, then amount/date.
|
||||
3. Match advice lines to Coop ledger by reference/doc number; in the Coop-Uniconta
|
||||
project this was a very strong signal.
|
||||
4. Treat payment-advice matches as an evidence/explanation layer, not as automatic
|
||||
ledger settlement. An invoice can remain open in Uniconta while Payment_advice
|
||||
proves Coop included it in a settlement, credit, bonus, modregning, or manual
|
||||
middle calculation.
|
||||
5. Report per bank deposit/open item: bank date/amount, matched advice file,
|
||||
difference, line count, Uniconta hit count, Coop hit count, credit/bonus/
|
||||
modregning total, advice documents/vouchers/classifications, and status OK /
|
||||
partial / needs review.
|
||||
6. Keep 2026 or out-of-period advice files in the database, but exclude or label
|
||||
them separately when current bank/Uniconta data only covers 2024-2025.
|
||||
|
||||
### Invoice Status: Net-based + Deduplication
|
||||
|
||||
**Critical pitfall:** The same payment may exist in BOTH systems (Coop RE and Uniconta Betaling). If you sum both, you get double the actual paid amount.
|
||||
|
||||
**Solution:** Calculate net payments per invoice:
|
||||
```python
|
||||
# Net Coop payments (NOT abs-sum!)
|
||||
coop_net = sum(c['amount'] for c in coop
|
||||
if c['reference'] == fnr and c['doc_type'] in ('RE','ZP'))
|
||||
coop_paid = abs(coop_net) if coop_net < 0 else 0
|
||||
|
||||
# Net Uniconta payments
|
||||
uni_net = sum(u['amount'] for u in uni
|
||||
if u['faktura'] == fnr and u['konteringstype'] == 'Betaling')
|
||||
uni_paid = abs(uni_net) if uni_net < 0 else 0
|
||||
|
||||
# Deduplicate: if both exist, use only one
|
||||
if coop_paid > 0 and uni_paid > 0:
|
||||
if abs(coop_paid - uni_paid) <= AMT_TOL:
|
||||
betalt = coop_paid # Same payment recorded twice
|
||||
else:
|
||||
betalt = max(coop_paid, uni_paid) # Partial, take largest
|
||||
```
|
||||
|
||||
**Also handle:** Positive Coop RE amounts are credit memos (modregninger) that should be SUBTRACTED from the net, not added with abs().
|
||||
|
||||
### Match Scoring Formula (default)
|
||||
```
|
||||
Score = 0
|
||||
# Amount match (40%)
|
||||
if abs(a-b) < AMT_TOL: Score += 40
|
||||
|
||||
# Date match (35%)
|
||||
days = abs(d1 - d2).days
|
||||
if days == 0: Score += 35
|
||||
elif days <= 3: Score += 25
|
||||
elif days <= 7: Score += 15
|
||||
elif days <= 14: Score += 5
|
||||
|
||||
# Text match (25%)
|
||||
if both_have_text:
|
||||
similarity = fuzzy_ratio(text1, text2) # 0-100
|
||||
if similarity > 80: Score += 25
|
||||
elif similarity > 50: Score += 12
|
||||
|
||||
# Reference bonus (extra)
|
||||
if reference_match: Score += 40
|
||||
|
||||
# Thresholds
|
||||
if Score >= 85: exact_match, auto_approvable
|
||||
if Score >= 60: partial_match, requires_manual
|
||||
if Score >= 40: fuzzy_match, requires_manual
|
||||
```
|
||||
|
||||
| Table | Key Fields |
|
||||
|-------|-----------|
|
||||
| `ImportBatch` | source (coop/uniconta), filename, imported_at, raw_row_count |
|
||||
| `Posting` | batch_id, source, fiscal_year, posting_date, amount, original_text, normalized_text, reference, document_number, original_data (JSON) |
|
||||
| `Match` | posting_a_id, posting_b_id, score, match_type (exact/partial/fuzzy/manual), status (proposed/approved/rejected), matched_by (auto/user), matched_at |
|
||||
| `AuditLog` | user, action, match_id, old_status, new_status, comment, timestamp |
|
||||
|
||||
## Phase 4: Build Iteratively
|
||||
|
||||
Follow this order strictly:
|
||||
1. Analysis scripts and documentation → `ANALYSE.md`
|
||||
2. Import parser with preview → validate before commit
|
||||
3. Normalization layer (dates, amounts, text)
|
||||
4. Match engine (exact → partial → fuzzy)
|
||||
5. Simple web UI (dashboard → import → matching table)
|
||||
6. Manual approve/reject + audit log
|
||||
7. Reports and exports
|
||||
8. Bonus calculation (if needed)
|
||||
9. Tests + README + Docker
|
||||
|
||||
### Revisor/Auditor Package Pattern
|
||||
|
||||
When the user asks for output that can be sent to an accountant/revisor, produce a deterministic evidence package rather than relying on dashboard screenshots. First verify and remove duplicate import runs after a DB backup; then export a Markdown summary plus an Excel workbook with source files, month totals, open invoice/credit residuals, unexplained bank items, ignored bank items, payment advice rows, and raw normalized postings. Use explicit status language such as `AFSTEMT_MED_ÅBNE_POSTER` when the bank layer reconciles but invoice/credit-note residuals remain.
|
||||
|
||||
See `references/auditor-pack-and-dedup.md` for the duplicate-import cleanup sequence, auditor workbook sheet shape, status semantics, and verification checklist.
|
||||
|
||||
### Idempotent SourceFile Imports
|
||||
|
||||
For accounting import UIs, especially Coop `Payment_advice` bulk uploads, make uploads idempotent. Add a SHA256 `content_hash` on the source-file model, compute it before creating a new row, skip identical reuploads, and update/reparse an existing same-named source file when the content changed. Wrap child-row replacement in a database transaction and test both duplicate-skip and update-replace paths so repeated user uploads do not inflate reconciliation totals.
|
||||
|
||||
See `references/idempotent-source-file-imports.md` for the Django model/view/parser pattern, UI counters, bulk upload setting, and regression checklist.
|
||||
|
||||
## Danish-specific Conventions
|
||||
|
||||
- Dates: use ISO format internally (`YYYY-MM-DD`), but display in Danish
|
||||
(`dd-MM-yyyy`) per user preference. Parse ambiguous Danish text dates with day-first semantics (`dayfirst=True`).
|
||||
- Amounts: always normalize to float/Decimal; display with 2 decimals, Danish comma.
|
||||
- Fiscal year: calendar year (1/1 – 31/12) unless specified otherwise.
|
||||
- For Coop workflows, model invoice year/month separately from payment year/month: Coop can have ~3 months of credit, so December invoices may be paid in March of the following fiscal year. Reports must not treat that as an error by default.
|
||||
- Text: normalize by lowercasing, stripping extra spaces, removing punctuation before fuzzy comparison.
|
||||
- Regulation/bonus/fee/fine classification should be explicit and reviewable. Start with keyword classification (`bonus`, `rabat`, `gebyr`, `bøde`, `regulering`, `korrektion`, `modregning`) but keep raw data and manual review because wording/layout varies.
|
||||
|
||||
## Django Accounting UI Display Formatting
|
||||
|
||||
When the reconciliation deliverable is a Django webapp, treat amount display as part of the reconciliation contract rather than a cosmetic detail. Use one shared template filter/localization utility for money columns in dashboards, match tables, invoice/payment status pages, and reports.
|
||||
|
||||
For Danish accounting UIs, monetary amounts should normally render with `.` thousands separators, `,` decimal separators, exactly two decimals, and a leading minus sign for negative values (for example `1.234.567,89`, `-1.234.567,89`, `0,00`). Prefer `Decimal`-based formatting over Python floats. Replace monetary `|floatformat:2` usage, but do not reformat percentages, match scores, counts, or raw imported/audit JSON unless explicitly requested.
|
||||
|
||||
Minimum verification for UI formatting changes:
|
||||
- Add a unit/template test for the formatting helper.
|
||||
- Add at least one rendered-page regression test proving formatted money appears and raw `1234567.89`-style output is absent.
|
||||
- Confirm templates using the filter load its tag module, and restart the web container if needed.
|
||||
|
||||
See `references/django-accounting-ui-formatting.md` for the concrete Django template-filter implementation, search targets, Docker test commands, and pitfalls.
|
||||
|
||||
## References
|
||||
- See `references/coop-uniconta-analysis.md` for the concrete discovery
|
||||
patterns from the Coop ↔ Uniconta project (header row locations,
|
||||
column mappings, SAP document type codes, etc.).
|
||||
- See `references/coop-uniconta-match-engine.md` for the 5-phase match engine
|
||||
implementation, tolerance matching, net-based invoice status, and score formulas.
|
||||
- See `references/bank-reconciliation.md` for three-source reconciliation extending
|
||||
Coop ↔ Uniconta with bank CSV — month-level validation, bonus reconciliation,
|
||||
and many-to-one (bank payment = sum of Coop invoices) matching strategy.
|
||||
- See `references/coop-payment-advice.md` for using Coop betalingsadviseringer
|
||||
(PDF/XLSX settlement files) as the bridge between bank deposits and invoice
|
||||
lines, including parser patterns, coverage metrics, and reporting shape.
|
||||
- See `references/three-way-django-analysis-engine.md` for Django implementation
|
||||
structure: separate analysis module, batch import command, three-way report
|
||||
outputs, interpretation pitfalls, and minimum tests.
|
||||
- See `references/coop-invoice-payment-allocation.md` for the simplified forward
|
||||
workflow using Coop invoice lists + Uniconta payment allocation + Payment_advice,
|
||||
including separate models, invoice paid/open status logic, employee-expense
|
||||
exceptions, UI badges, and minimum tests.
|
||||
- See `references/coop-uniconta-import-ui-and-payment-advice.md` for the five-source
|
||||
import UI pattern, flexible PDF/Excel Payment_advice raw import, monthly dashboard
|
||||
fields, 3-month Coop credit handling, and regression tests.
|
||||
- See `references/auditor-pack-and-dedup.md` for revisor-ready exports, duplicate
|
||||
import cleanup after DB backup, status semantics such as `AFSTEMT_MED_ÅBNE_POSTER`,
|
||||
and Excel/Markdown verification.
|
||||
- See `references/idempotent-source-file-imports.md` for SHA256-based idempotent
|
||||
accounting source-file uploads, update-vs-skip semantics, Django transaction
|
||||
boundaries, bulk upload limits, and regression tests.
|
||||
- See `references/django-accounting-ui-formatting.md` for Django money-formatting
|
||||
implementation details, template search targets, regression tests, and pitfalls.
|
||||
@@ -0,0 +1,66 @@
|
||||
# Auditor-ready reconciliation packs and duplicate-import cleanup
|
||||
|
||||
Use this when a reconciliation webapp must produce material that can be sent to an accountant/revisor, not just a green dashboard.
|
||||
|
||||
## Revisor-grade status language
|
||||
|
||||
Do not collapse every successful bank reconciliation into “fully reconciled”. Use explicit statuses:
|
||||
|
||||
- `AFSTEMT`: all required sources loaded, relevant bank movements explained, and no open invoice/credit-note residuals.
|
||||
- `AFSTEMT_MED_ÅBNE_POSTER`: bank layer is explained, but invoice/credit-note residuals remain and must be reviewed by accounting/revisor.
|
||||
- `BANK_DIFFERENCER`: required sources loaded, but relevant bank postings are unexplained.
|
||||
- `MANGLER_DATA`: one or more required sources are missing.
|
||||
|
||||
This prevents the dangerous interpretation “0 unexplained bank postings = no accounting work remains”.
|
||||
|
||||
## Duplicate import cleanup pattern
|
||||
|
||||
Historical accounting imports are often rerun. Before trusting totals, check for duplicate `SourceFile` rows by source/file name/parsed count and repeated downstream row counts.
|
||||
|
||||
Safe cleanup sequence:
|
||||
|
||||
1. Create a database backup first.
|
||||
2. Identify duplicate import runs, keeping the canonical/original `SourceFile` rows.
|
||||
3. Delete duplicate `SourceFile` rows through the ORM so related imported rows cascade consistently.
|
||||
4. Record an audit log entry with reason and deleted source file IDs.
|
||||
5. Recompute counts and reports after cleanup.
|
||||
|
||||
Do not modify original source files. The goal is to remove duplicate imported rows, not change evidence.
|
||||
|
||||
## Auditor pack contents
|
||||
|
||||
Generate a deterministic export package with at least:
|
||||
|
||||
- Markdown executive summary in Danish.
|
||||
- Excel workbook with sheets:
|
||||
- `Resume`: data completeness, bank coverage, open balances, status.
|
||||
- `Kildefiler`: imported files, parsed/error counts, timestamps.
|
||||
- `Måneder`: month-level totals across Coop, bank, invoice list, and payment allocation.
|
||||
- `Åbne poster`: invoice/credit-note residuals with invoice number, date, customer/account, total, paid, residual, vouchers, explanation.
|
||||
- `Uforklaret bank`: relevant bank movements that could not be explained.
|
||||
- `Ignoreret bank`: bank movements outside the debtor reconciliation scope, with reason.
|
||||
- `Payment advice`: uploaded settlement/advice records.
|
||||
- `Rå posteringer`: normalized postings for traceability.
|
||||
|
||||
Use Danish number formatting in human-facing Markdown and UI (`1.234.567,89`). Excel cells may stay numeric where useful, but headings and sheet names should be accountant-readable.
|
||||
|
||||
## Implementation notes
|
||||
|
||||
- Keep the analysis/export layer read-only; it should not mutate match status.
|
||||
- Convert timezone-aware datetimes to ISO strings before writing with pandas/openpyxl; Excel rejects timezone-aware datetimes.
|
||||
- Include both gross open amount and net open balance. Accountants often need both:
|
||||
- gross open review amount = sum of absolute residuals;
|
||||
- positive residuals = potential receivables;
|
||||
- negative residuals = open credits/modregninger;
|
||||
- net residual = positive + negative.
|
||||
- Add tests that read the generated workbook back and assert key sheets/rows exist.
|
||||
- If visual browser automation is unavailable, verify dashboard output with Django’s test client/HTTP response text and keep a separate browser/UI check when the environment supports it.
|
||||
|
||||
## Minimum verification
|
||||
|
||||
- Framework check passes.
|
||||
- Full reconciliation/core tests pass.
|
||||
- Auditor export command runs idempotently.
|
||||
- Workbook exists and contains all expected sheets.
|
||||
- Summary status and open totals match the recomputed analysis function.
|
||||
- Dashboard shows bank coverage and open-poster status consistently with the exported package.
|
||||
@@ -0,0 +1,73 @@
|
||||
# Bank Reconciliation — Django Implementation Guide
|
||||
|
||||
Practical implementation notes for extending a two-source Coop↔Uniconta reconciliation webapp with bank CSV support.
|
||||
|
||||
## Model extensions
|
||||
|
||||
Add these fields to `Transaction` (one migration):
|
||||
```python
|
||||
account_number = models.CharField(max_length=50, blank=True, db_index=True)
|
||||
bank_text = models.TextField(blank=True)
|
||||
valuta_date = models.DateField(null=True, blank=True)
|
||||
```
|
||||
|
||||
Also extend `SourceFile.source` choices and `Transaction.source`/`match_type` choices to include `("bank", "Bank")` and `("bank_total", "Bank-samlet")`.
|
||||
|
||||
## Bank CSV parser
|
||||
|
||||
Key implementation in `apps/import_/parsers.py`:
|
||||
|
||||
- **Encodings**: Try `latin-1` first, then `utf-8`. Most Danish bank exports use latin-1.
|
||||
- **Delimiter**: `;` (semicolon)
|
||||
- **Header mapping**: Fuzzy-match by lowercasing, stripping null bytes and quotes:
|
||||
- `dato` → date column
|
||||
- `valør`/`valuta` → value date column
|
||||
- `tekst`/`beskrivelse` → text column
|
||||
- `bel` (prefix) → amount column
|
||||
- `saldo` → balance column
|
||||
- `reference`/`bilagsrefer` → reference column
|
||||
- **Date parse**: `DD.MM.YYYY` → split by dots → `datetime(int(parts[2]), int(parts[1]), int(parts[0])).date()`
|
||||
- **Amount parse**:
|
||||
1. Strip `\xa0` (nbsp), `\u202f` (narrow nbsp), `" DKK"`
|
||||
2. If `.` and `,` both present → remove dots, replace comma with dot
|
||||
3. If only comma → replace with dot
|
||||
4. Convert to `Decimal`
|
||||
- **Bulk create**: Use `Transaction.objects.bulk_create(txns_to_create)` for performance
|
||||
- **Account extraction**: Regex `r"(\d{8,})"` on text field to find kundenummer/kontonummer
|
||||
|
||||
## Bank ↔ Coop match (many-to-one)
|
||||
|
||||
Group bank payments by `(account_number, year, month)` and match against the sum of unmatched Coop transactions in the same bucket.
|
||||
|
||||
```python
|
||||
bank_txns = Transaction.objects.filter(source="bank", year=year).values(
|
||||
"account_number", "year", "month"
|
||||
).annotate(total=Sum("amount"), count=Count("id"))
|
||||
|
||||
for bt in bank_txns:
|
||||
coop_sum = Transaction.objects.filter(
|
||||
source="coop", year=bt["year"], month=bt["month"],
|
||||
match_status="unmatched", account_number=bt["account_number"],
|
||||
).aggregate(s=Sum("amount"))["s"]
|
||||
|
||||
if coop_sum is not None and abs(bt["total"] - coop_sum) <= TOLERANCE:
|
||||
# Create MatchGroup linking bank bucket to Coop bucket
|
||||
...
|
||||
```
|
||||
|
||||
**Note**: Bank amounts are positive (indbetalinger), Coop RE/Bankoverførsel amounts are negative. Compare absolute values. Set `match_type="bank_total"`.
|
||||
|
||||
## Views/templates changes
|
||||
|
||||
- **Dashboard**: Add `bank_count`, `bank_sum` cards; add `bank_count` / `bank_sum` columns to monthly overview table
|
||||
- **Upload form**: Add `<option value="bank">Bank (Kontobevaegelser .csv)</option>`
|
||||
- **Reconciliation list**: Add `bank` to source filter; render bank badge with `.badge-warning`
|
||||
- **Run match page**: Document match phases: Reference → ZV↔Faktura → RE/Betaling → General → Many-to-one → Bank-total
|
||||
|
||||
## Discovered pitfalls
|
||||
|
||||
1. **Migrations not detected**: If `makemigrations` reports "No changes detected", check whether a prior manual migration already captured the changes. Run `migrate --check` first.
|
||||
2. **Docker file path mismatch**: When testing via `docker compose exec`, container filesystem may differ from host. Use the web UI upload form for file import testing.
|
||||
3. **Bank text homogeneity**: All transactions from the same counterparty often have identical text (e.g. `"Coop 0010014808 -SE MEDD."`). Text similarity is useless for matching. Rely on `account_number` + date+amount grouping.
|
||||
4. **Bank negatives are rare but critical**: Negative bank amounts (~1-2%) represent bonus/modregning/retur. Do NOT match these against positive Coop invoices. Flag separately for bonus reconciliation.
|
||||
5. **Bank total may differ slightly from Coop sum**: Normal difference is ±0.1% (fees, rounding). Use tolerance matching (e.g. ±5 DKK) for bank-total matches.
|
||||
@@ -0,0 +1,222 @@
|
||||
# Bank Reconciliation — Three-Source Analysis (2026-05-26)
|
||||
|
||||
Reference for matching bank transactions against Coop SAP exports and Uniconta
|
||||
ledgers. This extends the two-source reconciliation with a third (bank) dimension
|
||||
that serves as the "ground truth" for actual cash flow.
|
||||
|
||||
---
|
||||
|
||||
## Bank file format (Danske Bank CSV export)
|
||||
|
||||
| Kolonne | Type | Brug |
|
||||
|---------|------|------|
|
||||
| Dato | DD.MM.YYYY | Primær dato |
|
||||
| Valør | DD.MM.YYYY | Sekundær dato |
|
||||
| Tekst | string | **Identifikation** |
|
||||
| (tom) | — | Padding |
|
||||
| Beløb | +/- flot/DK | Primær beløb |
|
||||
| Saldo | flot/DK | Sekundær — løbende saldo |
|
||||
| Egen bilagsreference | string | Audit/trace |
|
||||
|
||||
**CSV separator**: `;` (semikolon)
|
||||
**Encoding**: `latin-1` (CP1252)
|
||||
**Decimal**: Danish comma, Danish point-as-thousand-separator
|
||||
**Negative**: explicit minus in front
|
||||
|
||||
Typisk tekstmønster for Coop-indbetalinger:
|
||||
- `"Coop 0010014808 -SE MEDD."`
|
||||
- Samme kundenummer for ALLE transaktioner
|
||||
- Stort set alle transaktioner har identisk tekst
|
||||
|
||||
---
|
||||
|
||||
## Bank transaktionskategorier
|
||||
|
||||
### Standard indbetalinger (~98%)
|
||||
- Beløb fra ~30.000 kr til ~800.000 kr
|
||||
- Sum 2024: +9.225.626 kr (129 stk)
|
||||
- Sum 2025: +10.031.368 kr (118 stk)
|
||||
- Udgør den "faktiske" Coop-omsætning der rammer banken
|
||||
|
||||
### Negative poster — bonus/modregning/retur (~1-2%)
|
||||
| Dato | Beløb | Tekst | Type |
|
||||
|------|-------|-------|------|
|
||||
| 13.06.2024 | -18.750,00 | COOP DANMARK AS | Bonus-tilbagebetaling |
|
||||
| 25.11.2024 | -31,25 | Coop 365 | Lokal retur/modregning |
|
||||
| 22.04.2025 | -128,00 | DK 57014 Coop Kvickly Slagelse | Butiksretur |
|
||||
| 13.05.2025 | -40,00 | DK 50960 Coop Kvickly Slagelse | Butiksretur |
|
||||
|
||||
**Total negative: -18.949,25 kr**
|
||||
|
||||
---
|
||||
|
||||
## THREE-SOURCE MATCHING CONCEPT
|
||||
|
||||
```
|
||||
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
||||
│ Coop Excel │ │ Uniconta Excel │ │ Bank CSV │
|
||||
│ (fakturaer) │ │ (bogføring) │ │ (indbetalinger)│
|
||||
└────────┬────────┘ └────────┬────────┘ └────────┬────────┘
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ MATCH LAYER 1: Post-for-post (Coop ↔ Uniconta) │
|
||||
│ Reference+abs(amount)+date → 68% match rate │
|
||||
├──────────────────────────────────────────────────────────────────┤
|
||||
│ MATCH LAYER 2: Month-level (All 3 sources) │
|
||||
│ Sum(Coop) ≈ Sum(Uniconta) ≈ Sum(Bank) per month │
|
||||
├──────────────────────────────────────────────────────────────────┤
|
||||
│ MATCH LAYER 3: Bonus reconciliation (separate) │
|
||||
│ Sum(KG + Kreditnota) per period ≈ Bank negative per release │
|
||||
│ (tolerate time lag — bank credit arrives later than postings) │
|
||||
└──────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Bank-to-Coop / Bank-to-Uniconta matching
|
||||
|
||||
### Month-level validation (PRIMARY)
|
||||
|
||||
Compare month totals across all three sources. If Coop and Uniconta diverge,
|
||||
the bank settles the dispute.
|
||||
|
||||
| Måned | Bank (kr) | Coop (kr) | Uniconta (kr) | Status |
|
||||
|-------|-----------|-----------|---------------|--------|
|
||||
| 2024-04 | 2.139.610 | TBD | TBD | To verify |
|
||||
| 2024-05 | 1.632.814 | TBD | TBD | To verify |
|
||||
| 2024-09 | 1.053.598 | TBD | TBD | To verify |
|
||||
|
||||
**Interpretation rule:**
|
||||
- Bank ≈ Coop: period is captured correctly in Coop
|
||||
- Bank ≈ Uniconta: period is correct in Uniconta
|
||||
- Bank >> Coop: missing Coop postings (likely late/omitted)
|
||||
- Bank >> Uniconta: missing Uniconta postings
|
||||
- Bank << both: bonus/modregning not yet reflected in accounting
|
||||
|
||||
### Many-to-one: Bank single payment = sum of Coop invoices
|
||||
|
||||
Each bank posting (e.g. 641.168,06 kr) is a **consolidated transfer** that
|
||||
aggregates many individual Coop invoices. Algorithm:
|
||||
1. Take bank amount B on date D
|
||||
2. Find Coop transactions in [D-30 days, D+3 days] that sum to B ± tolerance
|
||||
3. This is a "subset-sum" / knapsack search:
|
||||
```python
|
||||
def find_combo(target, candidates, tol=5.0):
|
||||
# Greedy for small candidate sets
|
||||
# DP for larger sets
|
||||
candidates = sorted([c for c in candidates
|
||||
if abs(c.amount) <= target + tol])
|
||||
# ... subset sum with tolerance
|
||||
```
|
||||
4. If exact match: mark all matched Coop invoices as "grouped under bank payment"
|
||||
5. If near-match (±500 kr): flag for manual review — likely partial payment or fees
|
||||
|
||||
---
|
||||
|
||||
## Bonus / Rebate reconciliation
|
||||
|
||||
### The mismatch:why Bank negatives don't match individual KG/Kreditnota
|
||||
|
||||
Coop KG-posteringer: 486 stk, total -1.101.849,97 kr
|
||||
Bank negative total: -18.949,25 kr (4 stk)
|
||||
Uniconta Kreditnota total: -4.593.829,17 kr
|
||||
Uniconta Rabat: 1 stk, -178.194,55 kr
|
||||
|
||||
**Observation:** Bank -18.750 kr does NOT match ANY individual Coop KG posting
|
||||
(nor any Uniconta Kreditnota). The nearest Uniconta Kreditnota is -18.961 (2025-03-21).
|
||||
|
||||
**Interpretation:** Bank negatives are **consolidated credit notes or bonus
|
||||
payments** that aggregate MANY smaller individual credit postings across a period
|
||||
(quarter or year). They arrive in the bank later than the individual postings.
|
||||
|
||||
### Suggested bonus reconciliation strategy
|
||||
|
||||
1. **Period-based grouping** (not transaction-based):
|
||||
- Group Coop KG by quarter/year
|
||||
- Group Uniconta Kreditnota by linked Faktura-number
|
||||
- Group Uniconta Rabat by year
|
||||
2. **Compare aggregated totals per period** with bank negatives per period:
|
||||
```
|
||||
Coop KG per period = sum(KG amounts where Posting Date in period)
|
||||
Uniconta credit per period = sum(Kreditnota + Rabat amounts in period)
|
||||
Bank negative per period = sum(bank negative amounts with Value Date in period)
|
||||
```
|
||||
3. **Differences indicate:
|
||||
- Timing: bank credit arrives 1-3 months after postings
|
||||
- Pooling: quarterly/yearly bonus vs monthly individual credits
|
||||
- Source mismatch: some credits originate from Uniconta only, some from Coop only
|
||||
4. **Manual review** for amounts > 50.000 kr discrepancies per period
|
||||
|
||||
### Special: Uniconta "Rabat" -178.194,55 "Coop bonus 2023"
|
||||
|
||||
- Single large posting on 2024-01-16
|
||||
- No matching bank negative from 2023 or early 2024 found
|
||||
- Likely an **accrual/adjustment** recognized in 2024 for 2023 bonus, but the
|
||||
actual cash flow may have been handled differently (e.g. applied as discount
|
||||
on future invoices rather than direct bank transfer)
|
||||
- **Flag:** accrual-type postings should NOT be matched against bank — they
|
||||
are non-cash adjustments. Separate them into "bonus accrued" vs "bonus paid".
|
||||
|
||||
---
|
||||
|
||||
## Key insight: Three sources triangulate
|
||||
|
||||
| Question | How to answer with 3 sources |
|
||||
|----------|-------------------------------|
|
||||
| "Is this invoice paid?" | Uniconta Faktura + Coop RE + Bank amount all agree |
|
||||
| "Did bonus arrive in cash?" | Bank negative confirms, Coop KG shows breakdown |
|
||||
| "Is Uniconta missing a posting?" | Bank has it but Uniconta doesn't → missing entry |
|
||||
| "Is Coop missing a posting?" | Bank has it but Coop doesn't → missing entry |
|
||||
| "Is this payment split?" | Bank single amount = sum of multiple Coop invoices |
|
||||
| "Is there a bonus adjustment?" | Coop KG present, Uniconta Kreditnota present, but bank negative missing → accrual |
|
||||
|
||||
---
|
||||
|
||||
## Practical notes for the webapp
|
||||
|
||||
### Bank import
|
||||
- Import as a third source alongside Coop and Uniconta
|
||||
- Store as `Transaction(source="bank", ...)` with minimal fields:
|
||||
- date, amount, text, original_value_date, original_balance
|
||||
- Apply the same year filter (2024-2025) to exclude stray transactions
|
||||
|
||||
### Bank-specific views
|
||||
1. **Month-level dashboard**: Three-bar chart per month (Coop, Uniconta, Bank)
|
||||
2. **Divergence report**: Months where |Coop - Bank| > tolerance
|
||||
3. **Bonus reconciliation**: Separate tab for aggregated KG/Kreditnota/Rabat vs bank negatives
|
||||
4. **Many-to-one explorer**: Click a bank transaction to see suggested Coop invoice combinations that sum to the bank amount
|
||||
|
||||
### Data model extension for bank
|
||||
```python
|
||||
class BankTransaction(models.Model):
|
||||
date = models.DateField()
|
||||
value_date = models.DateField(null=True)
|
||||
amount = models.DecimalField(max_digits=18, decimal_places=2)
|
||||
text = models.TextField()
|
||||
balance = models.DecimalField(max_digits=18, decimal_places=2)
|
||||
source_file = models.ForeignKey(SourceFile, ...)
|
||||
```
|
||||
Or reuse existing `Transaction` model with `source="bank"`.
|
||||
|
||||
---
|
||||
|
||||
## Performance / verification script
|
||||
|
||||
Use `docker exec` with the container's Python + pandas to parse bank CSVs
|
||||
and cross-check against already-loaded Coop/Uniconta data.
|
||||
|
||||
```python
|
||||
# Quick verification of month-level totals
|
||||
import pandas as pd
|
||||
|
||||
def parse_bank_csv(path):
|
||||
df = pd.read_csv(path, sep=';', encoding='latin-1',
|
||||
header=0, names=['Dato','Valør','Tekst','_','Beløb','Saldo','Ref'])
|
||||
df['Dato'] = pd.to_datetime(df['Dato'], format='%d.%m.%Y', errors='coerce')
|
||||
df['Beløb'] = df['Beløb'].str.replace('.', '').str.replace(',', '.').astype(float)
|
||||
return df
|
||||
|
||||
# Summarize by month and compare with Coop/month, Uniconta/month
|
||||
# Run this as a Django management command or script inside the container
|
||||
```
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
# Coop fakturaliste + Uniconta betalingsfordeling workflow
|
||||
|
||||
Session-derived pattern from the Coop-Uniconta project: simplify reconciliation by using three forward-looking sources instead of many historical exports.
|
||||
|
||||
## Target workflow
|
||||
|
||||
Use these files as the primary operational import set:
|
||||
1. Coop invoice list (`Coop Fakturaer YYYY.xlsx`) — all invoices/credits issued to Coop.
|
||||
2. Uniconta payment allocation / debtor ledger (`Coop Debitor YYYY.xlsx`) — invoice and settlement/payment lines.
|
||||
3. Coop `Payment_advice` — later bridge source for explicit settlement detail.
|
||||
|
||||
This supports the user question: “Mangler Coop at betale nogle fakturaer?”
|
||||
|
||||
## Models / concepts
|
||||
|
||||
Recommended separate models, not generic postings only:
|
||||
|
||||
### `CoopInvoice`
|
||||
- source file, fiscal year/accounting year
|
||||
- date
|
||||
- account number/name
|
||||
- customer reference (`Deres ref`)
|
||||
- order number
|
||||
- invoice number
|
||||
- requisition
|
||||
- total, net, VAT amounts
|
||||
- payment terms / sent dates if present
|
||||
- raw row JSON + sheet/row reference
|
||||
|
||||
### `PaymentAllocationLine`
|
||||
- source file, fiscal/accounting year
|
||||
- date
|
||||
- account
|
||||
- invoice number
|
||||
- voucher/document number
|
||||
- posting number
|
||||
- text
|
||||
- debit, credit, VAT
|
||||
- physical voucher/origin/account type/debtor-creditor/name
|
||||
- `is_payment_line` boolean
|
||||
- raw row JSON + sheet/row reference
|
||||
|
||||
## Payment-line detection
|
||||
|
||||
In Uniconta debtor/payment allocation files, not every line is a payment. They may contain both original invoice postings and later settlement lines.
|
||||
|
||||
Observed signals for payment/settlement lines:
|
||||
- `Bilag != Faktura`
|
||||
- text contains `Coop 0010014808`
|
||||
- text contains `Omp. Coop`
|
||||
|
||||
Do not infer paid status from invoice existence alone; calculate it from settlement/payment lines linked by invoice number.
|
||||
|
||||
## Invoice payment status algorithm
|
||||
|
||||
For each `CoopInvoice.invoice_number`:
|
||||
1. Find `PaymentAllocationLine` rows with same invoice number.
|
||||
2. Sum payment/settlement effect from rows marked `is_payment_line`.
|
||||
3. Compare against `CoopInvoice.total_amount`.
|
||||
4. Emit status:
|
||||
- `paid`: residual within tolerance
|
||||
- `partial`: some payment but residual remains
|
||||
- `open`: no settlement/payment lines found
|
||||
5. Keep explanatory fields: payment vouchers, dates, amount paid, residual, source rows.
|
||||
|
||||
Use Danish accounting display, but ISO dates and Decimal internally.
|
||||
|
||||
## Bank reconciliation with allocation groups
|
||||
|
||||
Use the payment allocation as an explanation layer between bank deposits and invoices:
|
||||
|
||||
`Bank payment → allocation group/voucher → multiple invoice lines → Coop invoices`
|
||||
|
||||
This is especially useful when one bank deposit equals many invoices/credits. Build groups by voucher/payment text/date and compare the group total to the bank payment.
|
||||
|
||||
## Employee expense exception
|
||||
|
||||
Negative bank postings whose text indicates purchases at Coop/Kvickly may be employee expenses, not Coop customer payments. Treat them as ignored/out-of-scope only when the user confirms the business meaning. In the Coop-Uniconta project, two negative bank lines with Coop/Kvickly text were confirmed as employee outlays and excluded from relevant bank coverage.
|
||||
|
||||
Implementation pattern:
|
||||
- keep ignored bank payments visible in reports
|
||||
- do not count them as unmatched customer-payment differences
|
||||
- report both total bank count and relevant bank coverage percentage
|
||||
|
||||
## UI/reporting shape
|
||||
|
||||
Add a dedicated invoice-payment view or dashboard section:
|
||||
- paid invoices
|
||||
- open/different invoices
|
||||
- total invoices
|
||||
- open residual amount
|
||||
- status badges: `BETALT`, `ÅBEN`, `DIFFERENCE`, `FAKTURAER`
|
||||
- filters for year, customer/account, amount, residual, status
|
||||
- export of open/difference invoices to Excel/CSV
|
||||
|
||||
## Minimum tests
|
||||
|
||||
Add tests for:
|
||||
1. A Coop invoice is marked paid via matching Uniconta payment allocation lines.
|
||||
2. Partial/open invoices calculate residual correctly.
|
||||
3. Confirmed Coop/Kvickly employee expense bank lines are ignored but still reported.
|
||||
4. Relevant bank coverage is 100% when all customer-payment bank lines are explained and only confirmed out-of-scope lines remain.
|
||||
|
||||
## Pitfalls
|
||||
|
||||
- Do not hardcode confidential file paths or sample data into committed code.
|
||||
- Keep raw Excel/source folders gitignored.
|
||||
- Do not collapse invoice list, payment allocation, and payment advice into one generic parser; their semantics differ.
|
||||
- Payment advice parser can be added later, but wire the import type early so UI/workflow is ready.
|
||||
@@ -0,0 +1,125 @@
|
||||
# Coop betalingsadviseringer / payment advice as reconciliation bridge
|
||||
|
||||
Use this when Coop provides per-payment settlement/advice documents (PDF/XLSX)
|
||||
for bank deposits. These documents can turn previously unexplained bank deposits
|
||||
into explainable groups of invoice, credit, bonus, and modregning lines.
|
||||
|
||||
## Durable workflow
|
||||
|
||||
1. Inventory files by extension and classify each file:
|
||||
- `payment_advice` — Coop betalingsadvisering/payment advice with invoice lines.
|
||||
- `bank_statement` — bank print/screenshot containing a Coop deposit message.
|
||||
- `unknown` — manual calculation, unsupported layout, or needs OCR/manual review.
|
||||
2. Extract payment advice rows:
|
||||
- invoice/fakturanummer
|
||||
- invoice date/fakturadato
|
||||
- company code/firmakode
|
||||
- amount/beløb
|
||||
- currency/valuta
|
||||
- raw row/text and parse confidence
|
||||
3. Compute advice totals from parsed rows; do not rely solely on a trailing total
|
||||
because some PDFs have manually added middle calculations or split sign columns.
|
||||
4. Match advice total to bank transaction with ±5 DKK tolerance.
|
||||
5. Match advice lines to Coop ledger by reference/doc number and to Uniconta by
|
||||
invoice number. Fall back to amount/date only for remaining lines.
|
||||
6. Produce a bank-deposit-level report for accounting review.
|
||||
|
||||
## PDF/XLSX parser patterns
|
||||
|
||||
XLSX pattern:
|
||||
- Supplier metadata may be rows 1-2.
|
||||
- Header row often contains: `Fakturanummer`, `Fakturadato`, `Firmakode`, `Beløb`,
|
||||
`Valuta`.
|
||||
- Dates can already be Excel datetimes.
|
||||
|
||||
Text PDF pattern A:
|
||||
```text
|
||||
Payment Advice
|
||||
Fakturanummer
|
||||
Fakturadato
|
||||
Firmakode Beløb
|
||||
Valuta
|
||||
5119751312
|
||||
17.11.2023 2245
|
||||
-1556,98 DKK
|
||||
...
|
||||
```
|
||||
|
||||
Text PDF pattern B with split sign:
|
||||
```text
|
||||
5126294822
|
||||
24.10.2024 1000
|
||||
2.119,86
|
||||
-
|
||||
DKK
|
||||
```
|
||||
Treat the amount as negative when a standalone `-` follows the amount.
|
||||
|
||||
Bank-statement PDFs:
|
||||
- May include `Kontobevægelser`, `Coop Danmark A/S -SE MEDD.`, and a bank amount.
|
||||
- Do not parse these as settlement invoice lines; classify separately and optionally
|
||||
link to the bank transaction/message.
|
||||
|
||||
## Reporting shape
|
||||
|
||||
Per bank deposit show:
|
||||
- bank date
|
||||
- bank amount
|
||||
- matched payment advice file(s)
|
||||
- total according to advice
|
||||
- difference
|
||||
- number of advice lines
|
||||
- number of lines found in Coop ledger
|
||||
- number of invoice numbers found in Uniconta
|
||||
- credit/negative line total
|
||||
- status: `OK`, `partial`, `needs_review`, `out_of_period`
|
||||
|
||||
## Interpretation pitfalls
|
||||
|
||||
- Advice files may include invoice dates from prior years but payment dates in the
|
||||
current year. Derive both invoice year and payment/bank year.
|
||||
- A low overlap with existing “likely Coop owes” flags does not mean the advice
|
||||
files are useless; it means the analysis must model settlement files as their
|
||||
own source rather than using them as loose attachments.
|
||||
- 2026 advice files should be imported but labelled out-of-period when bank or
|
||||
Uniconta data for 2026 is incomplete.
|
||||
- Keep original PDFs/XLSX confidential and out of git; only commit parser code,
|
||||
schemas, docs, and non-sensitive aggregate findings.
|
||||
|
||||
## Coop-Uniconta session observations
|
||||
|
||||
A mixed folder of 125 files contained 116 PDFs and 9 XLSX files. A first pass found
|
||||
122 payment advice files, 2 bank-statement PDFs, and 1 unknown/special PDF. Parsed
|
||||
advice lines were strong as a bridge: most lines matched Coop references/doc
|
||||
numbers, many matched Uniconta invoice numbers, and many previously unexplained
|
||||
bank deposits could be explained by matching advice totals to bank amounts within
|
||||
±5 DKK.
|
||||
|
||||
Later production import of the same class of files used PyMuPDF (`fitz`) for PDFs
|
||||
and pandas/openpyxl-style Excel parsing, creating one source-file record per
|
||||
Payment_advice file and line-level records with raw JSON/text. A folder with 125
|
||||
files (116 PDF, 9 Excel) produced 2,727 Payment_advice lines without mutating
|
||||
original files. For a 2025 auditor pack, 1,253 lines were relevant and 78 of 296
|
||||
open invoice/credit-note residuals had a Payment_advice trail.
|
||||
|
||||
Important interpretation: a Payment_advice line must not automatically close or
|
||||
"pay" an invoice in the ledger analysis. Treat it as an evidence/explanation layer
|
||||
for open items: the invoice can remain open in Uniconta while the advice proves
|
||||
Coop included it in a settlement, modregning, credit, bonus, or manual middle
|
||||
calculation. Reports should therefore add fields such as advice line count, advice
|
||||
amount, document list, voucher/bilag list, classifications, and a human-readable
|
||||
explanation rather than changing ledger status silently.
|
||||
|
||||
Implementation pattern that worked:
|
||||
- Add `PyMuPDF>=1.24` to backend dependencies before relying on PDF import in Docker.
|
||||
- Extend the batch importer to include `Entydigt*.pdf`, `Entydigt*.xlsx`, `*.xlsm`,
|
||||
and `*.xls` from the payment-advice folder.
|
||||
- Parse file names like `Entydigt id_4765_Bilag_7283.xlsx` into unique/document id
|
||||
and voucher/bilag metadata when present.
|
||||
- Classify lines as payment, credit, adjustment/manual total, or unknown, while
|
||||
preserving raw row/text for manual review.
|
||||
- Add regression tests that cover the case "open invoice has Payment_advice trail
|
||||
but no Uniconta payment line"; expected output is explanatory evidence, not an
|
||||
auto-paid status.
|
||||
- Regenerate Markdown + Excel auditor packs after import and include Payment_advice
|
||||
coverage counts in the summary.
|
||||
@@ -0,0 +1,235 @@
|
||||
# Coop ↔ Uniconta Reconciliation Reference (Aktualiseret 2025-05-26)
|
||||
|
||||
Konkrete felter, mappings og pitfalls fra det aktuelle Coop-Uniconta projekt.
|
||||
|
||||
---
|
||||
|
||||
## Coop (SAP export) — Kontoudtog.xlsx
|
||||
|
||||
| Kolonne | Navn | Type | Match-brug |
|
||||
|---------|------|------|------------|
|
||||
| Entry Date | Entry Date | datetime | Sekundær |
|
||||
| Document Type | Document Type | **string** | **Filter + match-type** |
|
||||
| Document Date | Document Date | datetime | Sekundær |
|
||||
| **Posting Date** | Posting Date | **datetime** | **Primær dato** |
|
||||
| **Reference** | Reference | **string (75% non-null)** | **Primær match-nøgle** |
|
||||
| Net Due Date | Net Due Date | datetime | Forfaldsdato |
|
||||
| **Amount** | Amount in Local Currency | **float** | **Primær beløb** |
|
||||
| Document Currency | Local Currency | string ('DKK') | Filter |
|
||||
| **Document Number** | Document Number | int | **Audit/trace** |
|
||||
| Text | Text | string (20% non-null) | Sekundær match |
|
||||
| Payment Reference | Payment Reference | string (12% non-null) | OCR/trace |
|
||||
| User Name | User Name | string | Audit |
|
||||
|
||||
### Coop Document Types (2024-2025)
|
||||
| Type | Andel | Beskrivelse | Uniconta-modpart |
|
||||
|------|-------|-------------|----------------|
|
||||
| RE | ~50% | Regning/betaling | Betaling/Faktura |
|
||||
| ZV | ~15% | Udbetalingsforslag/faktura | Betaling |
|
||||
| RG | ~6% | Kontoafstemning | Kreditnota |
|
||||
| KG | ~5% | Kontoafstemning gruppe (bonus/rabat) | Kreditnota |
|
||||
| ZC | ~3% | Kreditering | Kreditnota |
|
||||
| ZP | ~0.4% | Betaling | Betaling |
|
||||
| XX | ~0% (filtreret fra) | Manuelt bogført/migrering | — |
|
||||
|
||||
**VIGTIGT:** ZV-poster er positive fakturaer udstedt til kunder. De har typisk **ingen Reference/Faktura-nummer** og matches via `abs(beløb) + dato` mod Uniconta Betalinger.
|
||||
|
||||
---
|
||||
|
||||
## Uniconta — Coop danmark.xlsx
|
||||
|
||||
### Fil-struktur
|
||||
- Række 1: Konto-info (Checked, Konto, Kontonavn, Adresse, Bynavn)
|
||||
- Række 2-3: Tomme
|
||||
- Række 4: Kolonne-headers: Dato, Faktura, Forfaldsdato, Bilag, Tekst, Beløb, Resterende, Forfalden, Konteringstype, Sum
|
||||
- Række 5+: Data
|
||||
|
||||
| Kolonne | Navn | Type | Match-brug |
|
||||
|---------|------|------|------------|
|
||||
| **Dato** | Dato | **datetime** | **Primær dato** |
|
||||
| **Faktura** | Faktura | **mixed int/str, 0=ingen** | **Primær match-nøgle** |
|
||||
| Forfaldsdato | Forfaldsdato | datetime | Sekundær |
|
||||
| Bilag | Bilag | mixed int/str | Audit |
|
||||
| **Tekst** | Tekst | **string** | **Tekst-match** |
|
||||
| **Beløb** | Beløb | **float/tekst** | **Primær beløb** |
|
||||
| Resterende | Resterende | float | Filter |
|
||||
| Forfalden | Forfalden | float | Filter |
|
||||
| **Konteringstype** | Konteringstype | **string** | **Filter + match-bonus** |
|
||||
| Sum | Sum | float | IGNORE — løbende saldo |
|
||||
|
||||
### Uniconta Konteringstyper
|
||||
| Type | Beskrivelse | Match-modpart (Coop) |
|
||||
|------|-------------|---------------------|
|
||||
| Faktura | Faktura udstedt | RE (betaling) eller ZV |
|
||||
| Betaling | Betaling modtaget | RE, ZP, ZV |
|
||||
| Kreditnota | Kreditnota | RG, ZC, KG |
|
||||
| Primo | Primo-saldo | XX (filtreres fra) |
|
||||
| Afslutning | Årsafslutning | — |
|
||||
| Overførsel | Overførselsbilag | — |
|
||||
| Manuel | Manuel posting | — |
|
||||
| Rabat | Rabat | KG |
|
||||
|
||||
---
|
||||
|
||||
## Match-nøgle-observationer
|
||||
|
||||
### Fakturanumre overlapper IDENTISK
|
||||
- Coop `Reference` og Uniconta `Faktura` bruger **identiske 4-5 cifrede tal**.
|
||||
- Eksempler: 46344, 44907, 48456, 49495, 51519
|
||||
- **2.970 unikke RE-references findes som Faktura-numre i Uniconta.**
|
||||
- Dette er den stærkeste match-signal — langt stærkere end beløb+dato alene.
|
||||
|
||||
### Modsat fortegn er NORMALT
|
||||
| Coop type | Coop fortegn | Uniconta type | Uniconta fortegn | Match |
|
||||
|-----------|-------------|---------------|-----------------|-------|
|
||||
| RE (betaling) | Negativ (-) | Faktura | Positiv (+) | abs(beløb) |
|
||||
| ZV (faktura) | Positiv (+) | Betaling | Negativ (-) | abs(beløb) |
|
||||
| RG (kreditnota) | Positiv (+) | Kreditnota | Negativ (-) | abs(beløb) |
|
||||
| KG (rabat) | Positiv (+) | Kreditnota | Negativ (-) | abs(beløb) |
|
||||
|
||||
**Implikation:** Match-motoren SKAL sammenligne `abs(beløb)`, ikke rå beløb.
|
||||
|
||||
### Dato-tolerance
|
||||
- Faktura-reference matches: ±21 dage fungerer fint
|
||||
- Generelle beløb+dato matches: ±21 dage fanger de fleste
|
||||
- ZV↔Betaling matches: ±30 dage anbefales (betalingsforslag vs. faktura)
|
||||
|
||||
---
|
||||
|
||||
## Normalisering
|
||||
|
||||
### Beløb (dansk format)
|
||||
Nogle Uniconta-celler indeholder tekststrenge som `-2.543.803,93`:
|
||||
```python
|
||||
def parse_danish_number(val):
|
||||
if pd.isna(val) or val is None: return None
|
||||
if isinstance(val, (int, float)): return float(val)
|
||||
s = str(val).strip().replace('\xa0','').replace('\u202f','').replace(' DKK','')
|
||||
if s.lower() in ('nan',''): return None
|
||||
if s.endswith('.0'): s = s[:-2]
|
||||
if '.' in s and ',' in s:
|
||||
s = s.replace('.','').replace(',','.')
|
||||
elif ',' in s:
|
||||
s = s.replace(',','.')
|
||||
try: return float(s)
|
||||
except: return None
|
||||
```
|
||||
|
||||
### Tekst
|
||||
```python
|
||||
def normalize_text(t):
|
||||
if not t or pd.isna(t): return ''
|
||||
return re.sub(r'\s+',' ',str(t).lower().strip()).strip()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Konto-specifikt mønster
|
||||
|
||||
### "Coop 0010014808"
|
||||
- Uniconta indeholder mange poster med teksten "Coop 0010014808"
|
||||
- Beløb varierer fra -1.155.458,24 til -65.000
|
||||
- Nogle har fakturanummer, andre har ikke
|
||||
- Dette ligner en **samlet konto-kontering** for Coop-omsætning, ikke enkeltfakturaer
|
||||
- Disse poster er svære at matche automatisk og bør behandles særskilt
|
||||
|
||||
### Bonus/rabat-poster
|
||||
- Coop KG: "CCM Purchasing Rebate Credit"
|
||||
- Uniconta Rabat: sjælden, men findes
|
||||
- Disse bør matches separat via beløb + dato + bonus-tekst
|
||||
|
||||
---
|
||||
|
||||
## Specifikke problemer opdaget (V6.1)
|
||||
|
||||
1. **Faktura 46344** — Uniconta Faktura +441.720, Coop RE -441.720, men også Uniconta Betaling -430.732,50. Delvis kreditnota.
|
||||
|
||||
2. **Faktura 51519** — Uniconta Faktura +244.926, Coop RE -244.926, Uniconta Betaling -244.926. Dobbeltregistrering (Coop+Uni betaling).
|
||||
|
||||
3. **Faktura 48323** — Ingen Coop-betaling, men Uniconta Betaling -240.000 findes. Faktura er BETALT via Uniconta-betaling.
|
||||
|
||||
4. **Faktura 47140/47347 (DELVIST BETALT)** — Coop har både negativ og positiv RE med samme reference. Netto-sum giver korrekt status.
|
||||
|
||||
5. **Dobbelt-registrering** — Samme betaling findes som både Coop RE og Uniconta Betaling. Ved summing af begge får man 2x betalt. Løsning: brug netto-sum og dedupliker.
|
||||
|
||||
6. **MR8M-modregninger** — Positive Coop RE med "MR8M Credit memo" tekst er modregninger. De skal trækkes fra i netto-beregning, ikke lægges til med `abs()`.
|
||||
|
||||
---
|
||||
|
||||
## Afstemningsstrategi der virkede (V6.1 anbefalet)
|
||||
|
||||
**Fase 1: Reference-match (RE↔Faktura, RG↔Kreditnota)**
|
||||
- Krav: Coop.Reference == Uniconta.Faktura
|
||||
- Krav: abs(Coop.Amount) ≈ abs(Uniconta.Amount) ±5 kr (tolerance)
|
||||
- Krav: Dato ±21 dage
|
||||
- Bonus: Korrekt konteringstype
|
||||
- Score ~250
|
||||
- Resultat 2024-2025: ~2.421 matches
|
||||
|
||||
**Fase 2: ZV↔Faktura**
|
||||
- Coop ZV (positive fakturaer) matcher Uniconta Faktura
|
||||
- abs(beløb) ±5 kr + dato ±21 dage
|
||||
- Bonus: Document Number == Faktura
|
||||
- Resultat 2024-2025: ~173 matches
|
||||
|
||||
**Fase 3: RE↔Betaling uden reference**
|
||||
- Coop RE uden fakturanummer matcher Uniconta Betaling
|
||||
- abs(beløb) ±5 kr + dato ±21 dage
|
||||
- Bonus: tekst-lighed
|
||||
- Resultat 2024-2025: ~192 matches
|
||||
|
||||
**Fase 4: Generel abs(beløb)+dato**
|
||||
- abs(beløb) ±5 kr + dato ±21 dage
|
||||
- Konteringstype-bonus, tekst-lighed, reference-bonus
|
||||
- Score ≥ 40
|
||||
- Resultat 2024-2025: ~1.939 matches
|
||||
|
||||
**Fase 5: Mange-til-én**
|
||||
- Sum af 2-3 Uniconta-poster == Coop-postering ±5 kr
|
||||
- Dato ±14 dage, max 25 kandidater
|
||||
- Resultat 2024-2025: ~276 matches
|
||||
|
||||
**Total matches 2024-2025: 5.001 ud af 7.328 Coop / 6.717 Uniconta (~68%)**
|
||||
|
||||
**Uafstemt: ~2.327 Coop (31,8%) + ~2.211 Uniconta (32,9%)**
|
||||
|
||||
---
|
||||
|
||||
## Faktura-status: Netto-baseret + deduplikering
|
||||
|
||||
### Dobbelt-registrering
|
||||
Samme betaling findes som både Coop RE og Uniconta Betaling.
|
||||
|
||||
```python
|
||||
# Beregn NETTO Coop betalinger (ikke abs-sum!)
|
||||
coop_net = sum(c['amount'] for c in coop
|
||||
if c['reference'] == fnr and c['doc_type'] in ('RE','ZP'))
|
||||
coop_paid = abs(coop_net) if coop_net < 0 else 0
|
||||
|
||||
# Beregn NETTO Uniconta betalinger
|
||||
uni_net = sum(u['amount'] for u in uni
|
||||
if u['faktura'] == fnr and u['konteringstype'] == 'Betaling')
|
||||
uni_paid = abs(uni_net) if uni_net < 0 else 0
|
||||
|
||||
# Dedupliker
|
||||
if coop_paid > 0 and uni_paid > 0:
|
||||
if abs(coop_paid - uni_paid) <= AMT_TOL:
|
||||
betalt = coop_paid # Samme betaling
|
||||
else:
|
||||
betalt = max(coop_paid, uni_paid)
|
||||
elif coop_paid > 0: betalt = coop_paid
|
||||
elif uni_paid > 0: betalt = uni_paid
|
||||
else: betalt = 0
|
||||
```
|
||||
|
||||
**Resultat:** 2.968 BETALT, 2 DELVIST, 186 UBETALT
|
||||
|
||||
---
|
||||
|
||||
## Bevar original rådata
|
||||
|
||||
Exportér ALTID:
|
||||
- Original række-nummer (Excel-rækkeindeks)
|
||||
- Filnavn og ark-navn
|
||||
- Rå JSON med alle kolonner
|
||||
Så brugeren kan spore enhver match tilbage til kildefilen.
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
# Coop-Uniconta import UI + Payment_advice lessons
|
||||
|
||||
Use this reference when building or fixing a Coop/Uniconta reconciliation webapp import flow.
|
||||
|
||||
## Required import sources
|
||||
|
||||
The user expects five uploadable source types, not a generic two-source UI:
|
||||
|
||||
1. **Uniconta Coop Debitor / betalingsfordeling**
|
||||
- Shows how Coop payments are allocated across invoices/credits.
|
||||
- Primary bridge from payment to invoice lines.
|
||||
2. **Uniconta/ERP fakturaliste — all Coop invoices for the year**
|
||||
- Complete invoice/credit-note list for the relevant year.
|
||||
- Primary source for "has Coop paid all invoices?".
|
||||
3. **Coop kontoudtog**
|
||||
- Account statement from Coop's system.
|
||||
4. **Bankkontoudtog — all Coop postings**
|
||||
- Bank statement filtered to Coop-related transactions.
|
||||
5. **Coop Payment_advice documents**
|
||||
- PDF and Excel files, uploaded continuously as they are collected.
|
||||
- Needed to identify and explain regulations, fees, bonuses, fines, credits, and offsets.
|
||||
|
||||
If a UI only shows "Coop" and "Uniconta", treat that as a workflow bug even if the backend has more enum choices.
|
||||
|
||||
## Fiscal year and monthly reporting
|
||||
|
||||
- Fiscal year is calendar year: 1/1–31/12.
|
||||
- Store/report month for every data source.
|
||||
- Keep invoice month/year separate from payment month/year.
|
||||
- Coop can have around 3 months of credit: December 2025 invoices may be paid in March 2026. Cross-year payment timing is expected and should be visible, not automatically flagged as an error.
|
||||
|
||||
## Payment_advice ingestion pattern
|
||||
|
||||
Implement Payment_advice as a real source early, even before final matching logic is known.
|
||||
|
||||
Recommended flexible model fields:
|
||||
|
||||
- source file
|
||||
- accounting year (nullable when no date can be inferred)
|
||||
- document date
|
||||
- payment date
|
||||
- document/bilag number
|
||||
- voucher number
|
||||
- invoice number
|
||||
- amount
|
||||
- classification: `unknown`, `payment`, `bonus`, `fee`, `fine`, `adjustment`, `credit`
|
||||
- raw text
|
||||
- raw JSON / raw row data
|
||||
- raw sheet name / row index
|
||||
|
||||
## Parser approach
|
||||
|
||||
For Excel:
|
||||
- Read all sheets.
|
||||
- Store raw row JSON.
|
||||
- Best-effort infer date from date-like columns (`Dato`, `Date`, `Betaling`, `Forfald`).
|
||||
- Best-effort infer amount from amount-like columns (`Beløb`, `Amount`, `Total`, `Netto`, `Moms`); if multiple amounts appear, pick the largest absolute amount as a temporary headline amount.
|
||||
- Extract invoice/voucher/document IDs from matching column names.
|
||||
|
||||
For PDF:
|
||||
- Store file even if text extraction is poor.
|
||||
- Try `pypdf` / `PyPDF2` text extraction if available.
|
||||
- Infer date and amount from filename + extracted text.
|
||||
- Mark whether text was extracted.
|
||||
|
||||
Do not reject Payment_advice uploads just because final parser rules are not complete. The key is preserving raw documents/data so later parser improvements can be applied.
|
||||
|
||||
## UI copy / workflow guidance
|
||||
|
||||
On the import page, list the five source types in the same order as the user's workflow. Explain that data is split into accounting years by date, and that Payment_advice can be uploaded gradually.
|
||||
|
||||
For monthly dashboard rows, include at least:
|
||||
|
||||
- Coop postings count/sum
|
||||
- Uniconta postings count/sum
|
||||
- Bank postings count/sum
|
||||
- Invoice list count/sum
|
||||
- Payment allocation count, debit, credit
|
||||
- Payment_advice count/sum
|
||||
- Payment_advice classifications per month
|
||||
|
||||
## Minimum regression tests
|
||||
|
||||
Add tests that verify:
|
||||
|
||||
1. The upload page renders all five source labels and accepts PDF for Payment_advice.
|
||||
2. Payment_advice Excel import preserves raw row data and derives accounting year/month.
|
||||
3. Payment_advice classification detects at least a bonus/regulation example.
|
||||
4. Existing invoice/payment allocation tests still pass after adding the new source.
|
||||
@@ -0,0 +1,236 @@
|
||||
# Coop ↔ Uniconta Match Engine Reference (Aktualiseret 2025-05-26)
|
||||
|
||||
Konkrete match-engine-mønstre fra iteration V1 → V6.1.
|
||||
|
||||
---
|
||||
|
||||
## Match-filosofi: Faser i prioriteret rækkefølge
|
||||
|
||||
Fakturanummer-match (Reference ↔ Faktura) er det STÆRKESTE signal.
|
||||
Beløb+dato er sekundært. Sekvens:
|
||||
|
||||
### V6.1 (anbefalet)
|
||||
|
||||
**Fase 1: Reference + abs(beløb) + dato**
|
||||
- Coop `Reference` == Uniconta `Faktura` (identiske 4-5 cifrede tal)
|
||||
- `abs(Coop.Amount) == abs(Uniconta.Amount)` ±5 kr (tolerance for afrunding)
|
||||
- Dato ±21 dage
|
||||
- Konteringstype-bonus (RE↔Faktura, RG↔Kreditnota, KG↔Kreditnota)
|
||||
- Score ~250
|
||||
- Resultat 2024-2025: ~2.421 matches
|
||||
|
||||
**Fase 2: ZV↔Faktura (Coop positive fakturaer)**
|
||||
- Coop `Document Type == ZV` (positive fakturaer udstedt til kunder)
|
||||
- Match mod Uniconta `Konteringstype == Faktura`
|
||||
- `abs(beløb)` ±5 kr + dato ±21 dage
|
||||
- Bonus: `Document Number == Faktura` nummer
|
||||
- Resultat 2024-2025: ~173 matches
|
||||
|
||||
**Fase 3: RE↔Betaling uden reference**
|
||||
- Coop `Document Type == RE` (betalinger) uden fakturanummer
|
||||
- Match mod Uniconta `Konteringstype == Betaling`
|
||||
- `abs(beløb)` ±5 kr + dato ±21 dage
|
||||
- Bonus: tekst-lighed ("coop", "0010014808")
|
||||
- Resultat 2024-2025: ~192 matches
|
||||
|
||||
**Fase 4: Generel abs(beløb) + dato (fallback)**
|
||||
- `abs(beløb)` matcher ±5 kr
|
||||
- Dato ±21 dage
|
||||
- Konteringstype-bonus, tekst-lighed, reference-bonus
|
||||
- Score ≥ 40
|
||||
- Resultat 2024-2025: ~1.939 matches
|
||||
|
||||
**Fase 5: Mange-til-én**
|
||||
- Sum af 2-3 Uniconta-poster == Coop-postering ±5 kr
|
||||
- Dato ±14 dage, max 25 kandidater
|
||||
- Resultat 2024-2025: ~276 matches
|
||||
|
||||
**Total matches 2024-2025: 5.001 ud af 7.328 Coop / 6.717 Uniconta (~68%)**
|
||||
|
||||
---
|
||||
|
||||
## Tolerance matching (V6 innovation)
|
||||
|
||||
I stedet for eksakt beløbs-match med ±0,01 kr, brug ±5 kr tolerance:
|
||||
|
||||
```python
|
||||
AMT_TOL = 5.0 # DKK tolerance
|
||||
|
||||
# Index-building: bucket per tolerance interval
|
||||
def build_index(transactions, max_days=21):
|
||||
idx = defaultdict(lambda: defaultdict(list))
|
||||
for t in transactions:
|
||||
for delta in range(-max_days, max_days + 1):
|
||||
d = t['date'] + timedelta(days=delta)
|
||||
bucket = round(t['abs_amount'] / AMT_TOL) * AMT_TOL
|
||||
idx[d][bucket].append(t)
|
||||
return idx
|
||||
|
||||
# Lookup: søg naboliggende buckets
|
||||
bucket = round(c['abs_amount'] / AMT_TOL) * AMT_TOL
|
||||
for b in [bucket - AMT_TOL, bucket, bucket + AMT_TOL]:
|
||||
candidates.extend(idx.get(c['date'], {}).get(b, []))
|
||||
```
|
||||
|
||||
**Fordel:** Fanger afrundingsforskelle og delvise betalinger som
|
||||
ellers ville være uafstemte.
|
||||
|
||||
**Bivirkning:** Kan skabe flere kandidater → brug score-threshold
|
||||
og konteringstype-filter til at vælge den bedste.
|
||||
|
||||
---
|
||||
|
||||
## Fortegn: MODSAT er normalt og korrekt
|
||||
|
||||
| Coop type | Fortegn | Uniconta type | Fortegn | Match-mode |
|
||||
|-----------|---------|---------------|---------|------------|
|
||||
| RE (betaling) | Negativ | Faktura | Positiv | abs(beløb) |
|
||||
| ZV (faktura) | Positiv | Betaling | Negativ | abs(beløb) |
|
||||
| RG (kreditnota) | Positiv | Kreditnota | Negativ | abs(beløb) |
|
||||
| KG (rabat) | Positiv | Kreditnota | Negativ | abs(beløb) |
|
||||
|
||||
```python
|
||||
# Match via abs(), men marker med fortegn-kolonne
|
||||
amt_match = abs(coop_amount - uniconta_amount) <= AMT_TOL
|
||||
fortegn = "SAMME" if (coop_amount > 0) == (uniconta_amount > 0) else "MODSAT"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Filtrering før match
|
||||
|
||||
**Afgræns til regnskabsår** i stedet for at filtrere på Doc_Type:
|
||||
- 2024+2025 fjerner 8.861 Coop og 5.684 Uniconta poster
|
||||
- Primært primo/XX/saldo-poster fra 2023 og 2026
|
||||
- Langt mere effektiv end Doc_Type-filtrering
|
||||
|
||||
| Filter | Kilde | Hvad | Konsekvens |
|
||||
|--------|-------|------|------------|
|
||||
| År 2024-2025 | Begge | Regnskabsår-afgrænsning | 56% færre poster |
|
||||
| `Primo` | Uniconta | Startsaldo | Fjernes ved års-filter |
|
||||
| `Afslutning` | Uniconta | Årsafslutning | Fjernes ved års-filter |
|
||||
|
||||
---
|
||||
|
||||
## Mange-til-én: Praktisk approach
|
||||
|
||||
Én Coop-postering kan svare til SUM af flere Uniconta-poster.
|
||||
|
||||
- **AVOID:** `itertools.combinations` over 7.328 × 6.717 rækker = O(n²) timeout
|
||||
- **USE:** Index Uniconta per `(dato, abs_beløb)` og søg kombinationer indenfor ±14 dage
|
||||
- **LIMIT:** Maks 3 Uniconta-poster per Coop-postering, max 25 kandidater
|
||||
- **TOLERANCE:** ±5 kr (ikke ±0,01 kr)
|
||||
- Resultat: ~276 matches i 2024-2025 — større effekt end V5.1
|
||||
|
||||
---
|
||||
|
||||
## Faktura-status: Netto-baseret + deduplikering
|
||||
|
||||
### Problemet: Dobbelt-registrering
|
||||
|
||||
Samme betaling kan findes som både:
|
||||
- **Coop RE** (betaling) med fakturanummer i Reference
|
||||
- **Uniconta Betaling** med fakturanummer i Faktura-kolonnen
|
||||
|
||||
Hvis man summerer begge får man **dobbelt så meget betalt** som faktisk.
|
||||
|
||||
### Løsning: Netto-sum per faktura
|
||||
|
||||
```python
|
||||
# 1. Beregn NETTO Coop betalinger per faktura
|
||||
# (positive RE trækker fra — det er modregninger)
|
||||
coop_net = sum(c['amount'] for c in coop
|
||||
if c['reference'] == fnr
|
||||
and c['doc_type'] in ('RE', 'ZP'))
|
||||
coop_paid = abs(coop_net) if coop_net < 0 else 0
|
||||
|
||||
# 2. Beregn NETTO Uniconta betalinger per faktora
|
||||
uni_net = sum(u['amount'] for u in uni
|
||||
if u['faktura'] == fnr
|
||||
and u['konteringstype'] == 'Betaling')
|
||||
uni_paid = abs(uni_net) if uni_net < 0 else 0
|
||||
|
||||
# 3. Dedupliker: hvis begge findes, brug kun én
|
||||
if coop_paid > 0 and uni_paid > 0:
|
||||
if abs(coop_paid - uni_paid) <= AMT_TOL:
|
||||
betalt = coop_paid # Samme betaling, tag én
|
||||
else:
|
||||
betalt = max(coop_paid, uni_paid) # Delvise, tag største
|
||||
```
|
||||
|
||||
### MR8M-modregninger
|
||||
|
||||
Nogle fakturaer har både negativ og positiv RE med samme reference:
|
||||
- Faktura 47140: RE -8.984 + RE +8.984 (MR8M modregning) = netto 0
|
||||
- Med `abs()` ville dette tælle som 17.968 betalt (FORKERT)
|
||||
- Med netto-sum: `abs(-8.984 + 8.984) = 0` (KORREKT)
|
||||
|
||||
---
|
||||
|
||||
## Score-formel (V6.1 konkret)
|
||||
|
||||
```
|
||||
# Fase 1 (Reference-match)
|
||||
Score = 250 - dato_diff * 4 - abs_amount_diff * 2
|
||||
|
||||
# Fase 2 (ZV↔Faktura)
|
||||
Score = 150 - dato_diff * 3 - abs_amount_diff * 2
|
||||
if doc_number == faktura: +50
|
||||
if tekst_identisk: +25
|
||||
if tekst_delvis: +12
|
||||
Threshold: ≥ 60
|
||||
|
||||
# Fase 3 (RE↔Betaling)
|
||||
Score = 120 - dato_diff * 3 - abs_amount_diff * 2
|
||||
if reference == faktura: +40
|
||||
if "coop" in tekst: +15
|
||||
Threshold: ≥ 60
|
||||
|
||||
# Fase 4 (Generel)
|
||||
Score = 100 - dato_diff * 3 - abs_amount_diff * 0.5
|
||||
if konteringstype_match: +25
|
||||
if tekst_identisk: +25
|
||||
if tekst_delvis: +12
|
||||
if reference_match: +40
|
||||
Threshold: ≥ 40
|
||||
|
||||
# Fase 5 (Mange-til-én)
|
||||
Score = 150 (fast, kræver manuel godkendelse)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Rapport-struktur (Excel-ark)
|
||||
|
||||
Rækkefølgen er vigtig — brugeren vil se opsummering først:
|
||||
|
||||
0. `0_Opsummering` — nøgletal, antal matches, totalbeløb, difference, faktura-status
|
||||
1. `1_Faktura_Status` — alle Uniconta fakturaer med BETALT/DELVIST/UBETALT
|
||||
2. `2_Matches` — alle matchede par med type, score, fortegn, beløbs-diff, dato-diff
|
||||
3. `3_Alle_Coop` — alle rå Coop-rækker med match-status kolonne
|
||||
4. `4_Alle_Uniconta` — alle rå Uniconta-rækker med match-status kolonne
|
||||
5. `5_Kun_Coop` — uafstemte Coop-poster
|
||||
6. `6_Kun_Uniconta` — uafstemte Uniconta-poster
|
||||
7. `7_Maaneds_Oversigt` — månedlig opsummering pr. år
|
||||
|
||||
**Nøgle:** Ark 3 og 4 bevarer overblik — brugeren vil se ALLE poster.
|
||||
Ark 5+6 viser kun dem der stadig mangler.
|
||||
|
||||
---
|
||||
|
||||
## Bevar original rådata
|
||||
|
||||
Exportér ALTID:
|
||||
- Original række-nummer (Excel-rækkeindeks)
|
||||
- Filnavn og ark-navn
|
||||
- Rå JSON med alle kolonner
|
||||
Så brugeren kan spore enhver match tilbage til kildefilen.
|
||||
|
||||
---
|
||||
|
||||
## Performance-tips
|
||||
|
||||
- V6.1 kører på ~10 sekunder for 7.328 Coop + 6.717 Uniconta poster
|
||||
- Brug `defaultdict(lambda: defaultdict(list))` til dato→beløb→post indeks
|
||||
- Uniconta parsing: `pd.read_excel(file, header=3)` for at læse fra række 4
|
||||
- Python 3.9 med pandas 2.3.3 — set `PYTHONPATH` hvis user-site-packages
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
# Django Accounting UI Formatting
|
||||
|
||||
Former skill: `django-accounting-ui-formatting`.
|
||||
|
||||
Use this when changing how money, balances, invoices, payments, totals, or differences are displayed in a Django accounting/reconciliation webapp.
|
||||
|
||||
## Core rule
|
||||
|
||||
Accounting amounts must be formatted deliberately and consistently. Do not rely on ad-hoc `floatformat:2` everywhere if the app has locale-specific expectations.
|
||||
|
||||
For Danish Radix accounting/reconciliation UIs, render amounts with:
|
||||
|
||||
- thousands separator: `.`
|
||||
- decimal separator: `,`
|
||||
- exactly 2 decimals
|
||||
- minus sign before the formatted number
|
||||
|
||||
Example:
|
||||
|
||||
```text
|
||||
1234567.89 -> 1.234.567,89
|
||||
-1234567.89 -> -1.234.567,89
|
||||
0 -> 0,00
|
||||
```
|
||||
|
||||
## Recommended Django implementation
|
||||
|
||||
Create a shared template filter in a common app, e.g.:
|
||||
|
||||
```text
|
||||
backend/apps/common/templatetags/amount_format.py
|
||||
```
|
||||
|
||||
Example filter:
|
||||
|
||||
```python
|
||||
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
|
||||
|
||||
from django import template
|
||||
|
||||
register = template.Library()
|
||||
|
||||
|
||||
@register.filter
|
||||
def amount_dk(value):
|
||||
if value is None or value == "":
|
||||
return "0,00"
|
||||
|
||||
try:
|
||||
amount = Decimal(str(value)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
|
||||
except (InvalidOperation, ValueError, TypeError):
|
||||
return value
|
||||
|
||||
sign = "-" if amount < 0 else ""
|
||||
amount = abs(amount)
|
||||
formatted = f"{amount:,.2f}"
|
||||
return sign + formatted.replace(",", "X").replace(".", ",").replace("X", ".")
|
||||
```
|
||||
|
||||
Ensure `templatetags/__init__.py` exists.
|
||||
|
||||
In templates:
|
||||
|
||||
```django
|
||||
{% load amount_format %}
|
||||
{{ amount|amount_dk }} kr
|
||||
```
|
||||
|
||||
## What to update
|
||||
|
||||
Search all relevant templates for amount displays, especially:
|
||||
|
||||
- `floatformat:2`
|
||||
- table columns named Beløb, Sum, Difference, Rest, Betalt
|
||||
- dashboard cards
|
||||
- report tables
|
||||
- invoice/payment status pages
|
||||
- transaction detail pages
|
||||
|
||||
Replace monetary `floatformat:2` with the shared amount filter. Leave percentages, scores, counts, and raw JSON/raw imported data alone unless the user explicitly asks otherwise.
|
||||
|
||||
## Tests
|
||||
|
||||
Follow TDD for behavior changes:
|
||||
|
||||
1. Add a failing unit/template test for the filter:
|
||||
|
||||
```python
|
||||
template = Template("{% load amount_format %}{{ value|amount_dk }}")
|
||||
assert template.render(Context({"value": Decimal("1234567.89")})) == "1.234.567,89"
|
||||
```
|
||||
|
||||
2. Add at least one rendered-page regression test for the main UI path, asserting:
|
||||
|
||||
- formatted amount is present, e.g. `1.234.567,89`
|
||||
- raw unformatted amount is absent, e.g. `1234567.89`
|
||||
|
||||
3. Run the relevant app tests and system check in the project runtime, usually Docker Compose for Radix internal apps:
|
||||
|
||||
```bash
|
||||
docker compose exec -T web python manage.py test apps.core --settings=config.settings.test -v 2
|
||||
docker compose exec -T web python manage.py check
|
||||
```
|
||||
|
||||
## Verification
|
||||
|
||||
Before finalizing:
|
||||
|
||||
- Confirm there are no remaining monetary `|floatformat:2` usages in the changed templates.
|
||||
- Confirm templates that use `amount_dk` include `{% load amount_format %}`.
|
||||
- Restart the web container if the running app needs to load a new template tag module:
|
||||
|
||||
```bash
|
||||
docker compose restart web
|
||||
```
|
||||
|
||||
## Pitfalls
|
||||
|
||||
- Do not alter raw JSON/raw import displays; those are audit data and should preserve original values.
|
||||
- Do not apply money formatting to percentages or match scores.
|
||||
- Avoid Python float math for money formatting; use `Decimal` to avoid binary rounding surprises.
|
||||
- If the app already has a localization/formatting utility, extend that instead of adding a second competing filter.
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
# Idempotent SourceFile imports for accounting/reconciliation apps
|
||||
|
||||
Use this when users can upload the same ERP/accounting source files repeatedly, especially bulk settlement files such as Coop `Payment_advice` PDFs/XLSX.
|
||||
|
||||
## Problem
|
||||
|
||||
Financial import apps often store one `SourceFile` plus many parsed rows/documents. If a user uploads the same file again, the app must not create:
|
||||
- duplicate `SourceFile` records
|
||||
- duplicate parsed settlement/payment/invoice lines
|
||||
- duplicated totals in reconciliation reports
|
||||
|
||||
If the same named file has changed, treat it as an update of that source, not as a new independent source file.
|
||||
|
||||
## Durable pattern
|
||||
|
||||
Add a content hash to the source-file model:
|
||||
|
||||
```python
|
||||
class SourceFile(models.Model):
|
||||
source = models.CharField(max_length=64)
|
||||
file_name = models.CharField(max_length=255)
|
||||
file = models.FileField(upload_to="imports/")
|
||||
content_hash = models.CharField(max_length=64, blank=True, db_index=True)
|
||||
```
|
||||
|
||||
Compute SHA256 from the uploaded bytes before creating a new `SourceFile`. Always rewind the file afterwards:
|
||||
|
||||
```python
|
||||
import hashlib
|
||||
|
||||
def uploaded_file_sha256(uploaded_file):
|
||||
h = hashlib.sha256()
|
||||
for chunk in uploaded_file.chunks():
|
||||
h.update(chunk)
|
||||
uploaded_file.seek(0)
|
||||
return h.hexdigest()
|
||||
```
|
||||
|
||||
Decision table:
|
||||
|
||||
| Existing row? | Same file name? | Same hash? | Action |
|
||||
|---|---:|---:|---|
|
||||
| no | n/a | n/a | create new `SourceFile`, parse rows |
|
||||
| yes | yes | yes | skip import; report as unchanged/skipped |
|
||||
| yes | yes | no | update existing `SourceFile`, delete old parsed child rows, reparse |
|
||||
| yes | no | yes | usually skip as duplicate content; optionally report duplicate content under different name |
|
||||
| yes | no | no | create new source file |
|
||||
|
||||
For source types that represent operational settlement documents, prefer matching on `(source, file_name)` for update semantics, plus `content_hash` for duplicate detection.
|
||||
|
||||
## Transaction boundary
|
||||
|
||||
Wrap the parser/import in a transaction so child rows are not partially replaced:
|
||||
|
||||
```python
|
||||
from django.db import transaction
|
||||
|
||||
@transaction.atomic
|
||||
def import_payment_advice(source_file):
|
||||
PaymentAdviceDocument.objects.filter(source_file=source_file).delete()
|
||||
# parse and recreate child rows/documents
|
||||
```
|
||||
|
||||
If the upload flow creates a new file and parsing fails, delete only that newly-created `SourceFile`. Do not delete/revert an existing source record unless you have an explicit rollback strategy.
|
||||
|
||||
## UI behaviour
|
||||
|
||||
Return counters that distinguish:
|
||||
- imported files
|
||||
- updated files
|
||||
- skipped unchanged files
|
||||
- failed files
|
||||
|
||||
Use user-facing messages like:
|
||||
- `1 uændret fil sprunget over`
|
||||
- `1 fil opdateret`
|
||||
- `23 filer importeret`
|
||||
|
||||
This matters for accounting users: they need confidence that re-uploading a folder does not inflate totals.
|
||||
|
||||
## Bulk upload settings
|
||||
|
||||
Django defaults may reject large batches with `TooManyFilesSent`. For workflows where users upload many small settlement documents, make the limit explicit in settings/env, for example:
|
||||
|
||||
```python
|
||||
DATA_UPLOAD_MAX_NUMBER_FILES = int(os.getenv("DATA_UPLOAD_MAX_NUMBER_FILES", "2000"))
|
||||
```
|
||||
|
||||
Add the setting to `.env.example` and document that it controls maximum files per HTTP upload.
|
||||
|
||||
## Tests to add
|
||||
|
||||
Minimum regression tests:
|
||||
|
||||
1. Upload same file twice with identical bytes:
|
||||
- `SourceFile.objects.count()` unchanged after second upload
|
||||
- child document/line count unchanged
|
||||
- response contains skipped/unchanged message
|
||||
|
||||
2. Upload same file name with changed bytes:
|
||||
- same `SourceFile` primary key reused
|
||||
- `content_hash` changes
|
||||
- old child rows are deleted/replaced, not appended
|
||||
- response contains updated message
|
||||
|
||||
3. Bulk upload many files if the UI supports multi-file import:
|
||||
- no `TooManyFilesSent`
|
||||
- all valid new files imported
|
||||
- repeated files skipped
|
||||
|
||||
4. Parser failure path:
|
||||
- failed new file does not leave orphan `SourceFile`
|
||||
- failed update does not silently erase previous valid data
|
||||
|
||||
## Documentation checklist
|
||||
|
||||
Update README/import docs with:
|
||||
- duplicate uploads are skipped by content hash
|
||||
- same file name with changed content updates the existing source
|
||||
- updates replace parsed rows for that source file
|
||||
- original source files are not edited in place; imported copies live under media/storage
|
||||
- bulk upload file-count limit and env var
|
||||
+167
@@ -0,0 +1,167 @@
|
||||
# Reconciliation Webapp Architecture (Django)
|
||||
|
||||
Reference architecture for building a webapp on top of a reconciliation engine.
|
||||
Based on Coop-Uniconta project (Radix), May 2026.
|
||||
|
||||
## Stack
|
||||
|
||||
- **Backend**: Django 4.2 + Django REST Framework
|
||||
- **Database**: MariaDB 11.4
|
||||
- **Cache/Queue**: Redis + Celery (for background matching)
|
||||
- **Frontend**: Django Templates + vanilla JS (no React needed for internal tools)
|
||||
- **Deployment**: Docker Compose (web, db, redis)
|
||||
- **Auth**: Django built-in + admin, later MS Entra/OIDC
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
backend/
|
||||
config/
|
||||
settings/base.py # Shared config
|
||||
settings/local.py # Dev overrides (DEBUG=True, admin enabled)
|
||||
urls.py # URL routing
|
||||
wsgi.py / asgi.py # Entry points
|
||||
celery.py # Celery app
|
||||
apps/
|
||||
core/ # Models: Transaction, MatchResult, InvoiceStatus, AuditLog, BonusRule
|
||||
models.py
|
||||
admin.py
|
||||
matching.py # 5-phase match engine
|
||||
import_/ # Excel parsers
|
||||
parsers.py # Coop + Uniconta import with preview
|
||||
reconciliation/ # Views: dashboard, import, matching, reports
|
||||
views.py
|
||||
templates/
|
||||
templates/
|
||||
base.html # Dark-themed layout (sidebar nav + main content)
|
||||
static/
|
||||
css/app.css # Dark theme: --bg-primary: #0f172a, --accent: #3b82f6
|
||||
manage.py
|
||||
entrypoint.sh # wait-for-db + migrate + runserver
|
||||
Dockerfile # python:3.12-slim
|
||||
requirements.txt
|
||||
```
|
||||
|
||||
## Data Model
|
||||
|
||||
```
|
||||
AccountingYear (year, start_date, end_date)
|
||||
SourceFile (source, file_name, file_path, row_count, parsed_count, error_count, accounting_year)
|
||||
Transaction (source_file, source, year, month, date, amount, abs_amount,
|
||||
doc_type, reference, faktura, text, text_normalized,
|
||||
konteringstype, bilag, doc_number,
|
||||
match_status, match_type, match_score, match_group,
|
||||
original_data JSON)
|
||||
MatchResult (group_id, match_type, score, status,
|
||||
transaction_a, transaction_b, additional_b_ids JSON,
|
||||
amount_diff, date_diff_days, explanation, comment,
|
||||
approved_by, approved_at)
|
||||
InvoiceStatus (faktura_number, accounting_year, invoice_total, paid_total, remaining,
|
||||
status, invoice_date, first_invoice_text,
|
||||
coop_payment_count, uniconta_payment_count, invoice_count)
|
||||
AuditLog (action, user, match_result, details JSON)
|
||||
BonusRule (name, year, period_start, period_end, conditions JSON, percentage, min_amount)
|
||||
BonusCalculation (rule, transaction, calculated_amount, status)
|
||||
```
|
||||
|
||||
## Key Design Decisions
|
||||
|
||||
1. **Store original_data as JSON** — Always preserve raw Excel row data for traceability.
|
||||
2. **Separate `amount` and `abs_amount`** — `amount` keeps original sign for net calculations; `abs_amount` for matching.
|
||||
3. **MatchResult captures both sides** — Primary match (transaction_a → transaction_b) + optional additional_b_ids for many-to-one.
|
||||
4. **InvoiceStatus is computed, not stored per-transaction** — Recalculated after each matching run.
|
||||
5. **AuditLog for every manual action** — Match approval, rejection, bonus adjustment.
|
||||
|
||||
## Match Engine Integration
|
||||
|
||||
The match engine runs as a Celery task triggered from the web UI:
|
||||
|
||||
```python
|
||||
# apps/core/matching.py
|
||||
AMT_TOL = Decimal("5.00")
|
||||
DATE_TOL_REF = 21
|
||||
DATE_TOL_GEN = 21
|
||||
|
||||
def run_matching(accounting_year=None):
|
||||
# Phase 1: Reference match (RE↔Faktura, RG↔Kreditnota)
|
||||
# Phase 2: ZV↔Faktura
|
||||
# Phase 3: RE↔Betaling without reference
|
||||
# Phase 4: General abs(amount)+date
|
||||
# Phase 5: Many-to-one (sum of 2-3 Uni = 1 Coop)
|
||||
# Bulk create MatchResult, bulk update Transaction statuses
|
||||
```
|
||||
|
||||
## UI Pages
|
||||
|
||||
| Page | Purpose |
|
||||
|------|---------|
|
||||
| Dashboard | Year selector, stat cards, invoice status summary, monthly overview |
|
||||
| Import | Upload Excel, show preview, map columns if auto-detection fails |
|
||||
| Reconciliation List | Filterable table of all transactions, paginated (50/page) |
|
||||
| Transaction Detail | Raw data, normalized data, potential matches, match history |
|
||||
| Faktura Status | All invoices with BETALT/DELVIST/UBETALT, filterable |
|
||||
| Reports | By type, by month, match summary, export to Excel |
|
||||
| Run Match | Trigger background matching for selected year |
|
||||
|
||||
## Dark Theme CSS Variables
|
||||
|
||||
```css
|
||||
:root {
|
||||
--bg-primary: #0f172a;
|
||||
--bg-secondary: #1e293b;
|
||||
--bg-tertiary: #334155;
|
||||
--text-primary: #f8fafc;
|
||||
--text-secondary: #94a3b8;
|
||||
--accent: #3b82f6;
|
||||
--success: #22c55e;
|
||||
--warning: #f59e0b;
|
||||
--danger: #ef4444;
|
||||
--border: #334155;
|
||||
--radius: 8px;
|
||||
}
|
||||
```
|
||||
|
||||
Inspired by Radix-ERP visual style: sidebar navigation, card-based stats,
|
||||
data tables with badges, filter bars above tables.
|
||||
|
||||
## Docker Compose
|
||||
|
||||
```yaml
|
||||
services:
|
||||
web:
|
||||
build: ./backend
|
||||
command: ["web-dev"]
|
||||
ports: ["8000:8000"]
|
||||
depends_on:
|
||||
db: {condition: service_healthy}
|
||||
env_file: [.env]
|
||||
db:
|
||||
image: mariadb:11.4
|
||||
environment:
|
||||
MARIADB_DATABASE: ${DB_NAME}
|
||||
MARIADB_USER: ${DB_USER}
|
||||
MARIADB_PASSWORD: ${DB_PASSWORD}
|
||||
MARIADB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
|
||||
healthcheck:
|
||||
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
```
|
||||
|
||||
## Deployment Notes
|
||||
|
||||
- `python manage.py migrate` runs on container startup via entrypoint.sh
|
||||
- `python manage.py createsuperuser` for first admin login
|
||||
- Uploads stored in MEDIA_ROOT (mounted volume in production)
|
||||
- Static files collected via `collectstatic` for production (nginx)
|
||||
|
||||
## Next Steps for a New Reconciliation Project
|
||||
|
||||
1. Copy project structure from template
|
||||
2. Adapt parsers for the specific Excel formats
|
||||
3. Run analysis script to determine match strategy
|
||||
4. Configure AMT_TOL and DATE_TOL based on data quality
|
||||
5. Build import UI with column mapping fallback
|
||||
6. Implement match engine phases iteratively
|
||||
7. Add manual match/approve/reject with audit log
|
||||
8. Add reports and bonus calculation framework
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
# Three-Way Django Analysis Engine — Coop ↔ Uniconta ↔ Bank
|
||||
|
||||
Session-specific implementation notes for building a maintainable three-source reconciliation layer in a Django-based internal finance app.
|
||||
|
||||
## When to use this pattern
|
||||
|
||||
Use when the task has three sources with different semantic roles:
|
||||
|
||||
- ERP/accounting ledger, e.g. Uniconta = invoiced/booked postings
|
||||
- Counterparty/customer statement, e.g. Coop kontoudtog = settlement/detail source
|
||||
- Bank CSV = actual cash movement / reality check
|
||||
|
||||
Do **not** treat the result as a legal claim automatically. The engine should produce review flags and explainable evidence.
|
||||
|
||||
## Recommended architecture
|
||||
|
||||
Keep the analysis engine separate from import parsing and HTTP views:
|
||||
|
||||
```text
|
||||
apps/core/parsers.py # source-specific import/normalization
|
||||
apps/core/three_way.py # pure-ish analysis functions
|
||||
apps/core/management/commands/*.py # import/analyze CLI commands
|
||||
apps/reconciliation/views.py # dashboard/report presentation only
|
||||
apps/reconciliation/templates/... # HTML tables/cards
|
||||
```
|
||||
|
||||
Good public functions in the analysis module:
|
||||
|
||||
```python
|
||||
analyze_three_way_year(year)
|
||||
build_monthly_summary(year)
|
||||
build_invoice_statuses(year)
|
||||
find_unexplained_bank_payments(year)
|
||||
build_bonus_adjustment_summary(year)
|
||||
```
|
||||
|
||||
Views should call these functions and render results; avoid embedding reconciliation logic in templates or views.
|
||||
|
||||
## Batch import command
|
||||
|
||||
For folder-based source projects, add a deterministic command that imports all configured source directories:
|
||||
|
||||
```bash
|
||||
python manage.py import_all_sources --base-dir /app/source_data
|
||||
```
|
||||
|
||||
Expected source layout:
|
||||
|
||||
```text
|
||||
Fra Coop/
|
||||
Fra Uniconta/
|
||||
Fra bank/
|
||||
```
|
||||
|
||||
The command should:
|
||||
|
||||
1. Walk each known directory.
|
||||
2. Infer source from directory, not filename alone.
|
||||
3. Preserve original file, sheet and row identifiers.
|
||||
4. Store row-level import errors.
|
||||
5. Print counts per file and total imported/error counts.
|
||||
6. Be safe to rerun or clearly document duplicate behavior.
|
||||
|
||||
## Three-way reporting outputs
|
||||
|
||||
A useful first report page should show:
|
||||
|
||||
- possible amounts the counterparty may owe (`Mulige beløb Coop skylder`)
|
||||
- unexplained bank postings (`Uforklarede bankposter`)
|
||||
- invoice statuses analyzed
|
||||
- months with material differences
|
||||
- month-level totals across all three sources
|
||||
- bonus/credit/offset summary by period
|
||||
|
||||
For each proposed issue, include evidence:
|
||||
|
||||
- amount difference
|
||||
- relevant date range
|
||||
- invoice/reference, if present
|
||||
- source counts
|
||||
- which fields matched and which did not
|
||||
- score/status and whether manual review is required
|
||||
|
||||
## Interpretation pitfall
|
||||
|
||||
Large totals such as “possible amount owed” are **analysis flags**, not final claims.
|
||||
|
||||
Reasons:
|
||||
|
||||
- One bank payment can cover many Coop/Uniconta postings.
|
||||
- One Coop settlement can cover many invoices.
|
||||
- Dates may differ between invoice date, statement date, settlement date and bank date.
|
||||
- Bonus/credit notes may be non-cash accruals or period-based offsets.
|
||||
- Open-post lists are status snapshots and should be treated separately from ledger/history.
|
||||
|
||||
The UI and README should label these as review candidates until group matching and manual approval confirm them.
|
||||
|
||||
## Matching strategy after basic import
|
||||
|
||||
Start with conservative layers:
|
||||
|
||||
1. Invoice/reference + absolute amount + date tolerance.
|
||||
2. Remaining amount + date tolerance.
|
||||
3. Month-level totals across all three sources.
|
||||
4. Many-to-one and one-to-many group matching.
|
||||
5. Bonus/credit period reconciliation.
|
||||
6. Manual approval/rejection with audit log.
|
||||
|
||||
For bank matching, prioritize group matching:
|
||||
|
||||
```text
|
||||
bank payment = sum of many Coop settlement lines = sum of many Uniconta invoices
|
||||
```
|
||||
|
||||
Use the bank as cash-flow validation, not necessarily as a row-level join target.
|
||||
|
||||
## Tests to include
|
||||
|
||||
Minimum tests for this class of feature:
|
||||
|
||||
- invoice is paid when Uniconta, Coop and bank agree
|
||||
- invoice is flagged when Coop/bank evidence indicates a residual amount
|
||||
- bank payment without Coop/Uniconta support is flagged as unexplained
|
||||
- monthly summary calculates Bank-Coop and Bank-Uniconta differences correctly
|
||||
|
||||
For Django, add a lightweight test settings module if the normal dev database requires services/permissions:
|
||||
|
||||
```text
|
||||
config/settings/test.py # SQLite, fast local tests
|
||||
```
|
||||
|
||||
Then run:
|
||||
|
||||
```bash
|
||||
DJANGO_SETTINGS_MODULE=config.settings.test python manage.py test
|
||||
```
|
||||
|
||||
## Documentation to update
|
||||
|
||||
When this pattern is implemented, update:
|
||||
|
||||
- README.md: installation, Docker, import flow, analysis commands, limitations
|
||||
- ANALYSE.md or ANALYSE_TREVEJS.md: source findings and interpretation
|
||||
- HANDOFF.md: current status and next recommended steps
|
||||
- TODO.md/ROADMAP.md: group matching, manual review, bonus expansion
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
"""
|
||||
Generic Excel file analyzer for ERP reconciliation projects.
|
||||
|
||||
Usage:
|
||||
python analyze_excel.py <directory> [--output analysis.json]
|
||||
|
||||
This script:
|
||||
1. Scans a directory for .xlsx files
|
||||
2. Identifies sheets, headers, data ranges
|
||||
3. Detects Danish number formats
|
||||
4. Prints null counts and sample values
|
||||
5. Saves structured JSON for downstream processing
|
||||
|
||||
To customize for a project, adjust:
|
||||
- EXPECTED_COLS: columns that must exist for a row to be considered a header
|
||||
- AMOUNT_COL_PATTERNS: regex patterns for amount columns
|
||||
- DATE_COL_PATTERNS: regex patterns for date columns
|
||||
"""
|
||||
import pandas as pd
|
||||
import openpyxl
|
||||
import os
|
||||
import json
|
||||
import re
|
||||
from collections import OrderedDict
|
||||
|
||||
# --- CUSTOMIZE PER PROJECT ---
|
||||
EXPECTED_COLS = {'Dato', 'Faktura', 'Bilag', 'Tekst', 'Beløb'}
|
||||
AMOUNT_COL_PATTERNS = [
|
||||
r'(?i)bel[oø]b',
|
||||
r'(?i)amount',
|
||||
r'(?i)sum',
|
||||
r'(?i)rest',
|
||||
r'(?i)forfalden',
|
||||
]
|
||||
DATE_COL_PATTERNS = [
|
||||
r'(?i)dato',
|
||||
r'(?i)date',
|
||||
r'(?i)posting',
|
||||
r'(?i)entry',
|
||||
]
|
||||
REFERENCE_COL_PATTERNS = [
|
||||
r'(?i)reference',
|
||||
r'(?i)faktura',
|
||||
r'(?i)bilag',
|
||||
r'(?i)document number',
|
||||
r'(?i)invoice',
|
||||
]
|
||||
# --- END CUSTOMIZE ---
|
||||
|
||||
|
||||
def classify_column(col_name):
|
||||
"""Guess the semantic type of a column from its name."""
|
||||
cn = str(col_name)
|
||||
for p in AMOUNT_COL_PATTERNS:
|
||||
if re.search(p, cn):
|
||||
return 'amount'
|
||||
for p in DATE_COL_PATTERNS:
|
||||
if re.search(p, cn):
|
||||
return 'date'
|
||||
for p in REFERENCE_COL_PATTERNS:
|
||||
if re.search(p, cn):
|
||||
return 'reference'
|
||||
return 'other'
|
||||
|
||||
|
||||
def find_header_row(filepath, sheet=0, required_cols=None):
|
||||
"""Find the first row that looks like a header."""
|
||||
if required_cols is None:
|
||||
required_cols = EXPECTED_COLS
|
||||
wb = openpyxl.load_workbook(filepath, data_only=True)
|
||||
ws = wb[wb.sheetnames[sheet]]
|
||||
for i, row in enumerate(ws.iter_rows(values_only=True), start=0):
|
||||
vals = set(str(v) for v in row if v is not None)
|
||||
if required_cols.issubset(vals):
|
||||
return i
|
||||
return None
|
||||
|
||||
|
||||
def parse_danish_number(val):
|
||||
"""Normalize Danish text numbers like '-2.543.803,93' to float."""
|
||||
if pd.isna(val):
|
||||
return float('nan')
|
||||
if isinstance(val, (int, float)):
|
||||
return float(val)
|
||||
s = str(val).strip()
|
||||
if not s:
|
||||
return float('nan')
|
||||
# Danish format: thousands sep = '.', decimal = ','
|
||||
if '.' in s and ',' in s:
|
||||
s = s.replace('.', '').replace(',', '.')
|
||||
elif ',' in s:
|
||||
s = s.replace(',', '.')
|
||||
try:
|
||||
return float(s)
|
||||
except ValueError:
|
||||
return float('nan')
|
||||
|
||||
|
||||
def analyze_file(filepath):
|
||||
info = {
|
||||
"filepath": filepath,
|
||||
"filename": os.path.basename(filepath),
|
||||
"size_kb": round(os.path.getsize(filepath) / 1024, 1),
|
||||
"sheets": OrderedDict(),
|
||||
}
|
||||
xl = pd.ExcelFile(filepath)
|
||||
for sheet in xl.sheet_names:
|
||||
info["sheets"][sheet] = analyze_sheet(filepath, sheet)
|
||||
return info
|
||||
|
||||
|
||||
def analyze_sheet(filepath, sheet):
|
||||
# Try default header=0 first; if columns look wrong, try header detection
|
||||
df = pd.read_excel(filepath, sheet_name=sheet, engine='openpyxl')
|
||||
header_row = None
|
||||
# Simple heuristic: if ALL columns are unnamed, try detecting header
|
||||
unnamed = [c for c in df.columns if 'Unnamed' in str(c)]
|
||||
if len(unnamed) > len(df.columns) * 0.5:
|
||||
hr = find_header_row(filepath, sheet)
|
||||
if hr is not None:
|
||||
df = pd.read_excel(filepath, sheet_name=sheet, engine='openpyxl', header=hr)
|
||||
header_row = hr
|
||||
|
||||
cols = OrderedDict()
|
||||
for col in df.columns:
|
||||
col_type = classify_column(str(col))
|
||||
is_date = pd.api.types.is_datetime64_any_dtype(df[col])
|
||||
is_num = pd.api.types.is_numeric_dtype(df[col])
|
||||
null_count = int(df[col].isna().sum())
|
||||
unique_count = int(df[col].nunique())
|
||||
samples = []
|
||||
for v in df[col].dropna().head(5):
|
||||
if len(samples) >= 3:
|
||||
break
|
||||
try:
|
||||
if is_date:
|
||||
samples.append(str(pd.Timestamp(v).date()))
|
||||
else:
|
||||
samples.append(str(v)[:60])
|
||||
except Exception:
|
||||
samples.append(str(v)[:60])
|
||||
|
||||
# Detect danish number format
|
||||
danish_flag = False
|
||||
if is_num:
|
||||
for v in df[col].dropna().head(10):
|
||||
if isinstance(v, str) and (',' in v or ('.' in v and ',' in v.replace('.', ''))):
|
||||
danish_flag = True
|
||||
break
|
||||
|
||||
cols[str(col)] = {
|
||||
"semantic_type": col_type,
|
||||
"pandas_dtype": str(df[col].dtype),
|
||||
"null_count": null_count,
|
||||
"null_pct": round(null_count / len(df) * 100, 1),
|
||||
"unique_count": unique_count,
|
||||
"is_date": bool(is_date),
|
||||
"is_numeric": bool(is_num),
|
||||
"danish_number_format": danish_flag,
|
||||
"sample_values": samples,
|
||||
}
|
||||
|
||||
empty_rows = int((df.isna().all(axis=1)).sum())
|
||||
dupes = int(df.duplicated().sum())
|
||||
|
||||
# Try to find date range
|
||||
date_range = None
|
||||
for col, meta in cols.items():
|
||||
if meta["is_date"]:
|
||||
non_null = df[col].dropna()
|
||||
if len(non_null) > 0:
|
||||
date_range = {
|
||||
"min": str(non_null.min().date()),
|
||||
"max": str(non_null.max().date()),
|
||||
"column": col,
|
||||
}
|
||||
break
|
||||
|
||||
return {
|
||||
"rows": len(df),
|
||||
"columns": len(df.columns),
|
||||
"detected_header_row": header_row,
|
||||
"empty_rows": empty_rows,
|
||||
"duplicate_rows": dupes,
|
||||
"date_range": date_range,
|
||||
"columns": cols,
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
import argparse
|
||||
parser = argparse.ArgumentParser(description="Analyze Excel files for data reconciliation")
|
||||
parser.add_argument("directory", help="Directory containing .xlsx files")
|
||||
parser.add_argument("--output", default="excel_analysis.json", help="Output JSON file")
|
||||
args = parser.parse_args()
|
||||
|
||||
results = OrderedDict()
|
||||
for f in sorted(os.listdir(args.directory)):
|
||||
if f.endswith('.xlsx') and not f.startswith('~'):
|
||||
fp = os.path.join(args.directory, f)
|
||||
key = os.path.splitext(f)[0]
|
||||
results[key] = analyze_file(fp)
|
||||
print(f"Analyzed: {f}")
|
||||
|
||||
with open(args.output, 'w', encoding='utf-8') as fh:
|
||||
json.dump(results, fh, indent=2, ensure_ascii=False)
|
||||
print(f"\nSaved: {args.output}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,248 @@
|
||||
---
|
||||
name: gitea-issue-agent
|
||||
description: Build a Hermes-managed autonomous issue handler for a self-hosted Gitea instance — scan issues, analyze text + screenshot attachments, post a proposal for human approval, implement, test, push a branch, and open a Gitea PR. Use when the user wants Hermes to act on Gitea issues end-to-end with a human-in-the-loop approval gate, especially with image/visual context in the issue text.
|
||||
version: 0.1.0
|
||||
author: Hermes Agent
|
||||
license: MIT
|
||||
metadata:
|
||||
hermes:
|
||||
tags: [gitea, issues, automation, hermes-plugin, pr-workflow, agent-loop, approval-gate]
|
||||
related_skills: [gitea-tea-macos-setup, software-engineering-workflows]
|
||||
---
|
||||
|
||||
# Gitea Issue Agent
|
||||
|
||||
End-to-end workflow for an autonomous Gitea issue handler that lives inside
|
||||
Hermes. Combines:
|
||||
|
||||
- A **Hermes plugin** exposing Gitea REST API tools (status, scan, get, fetch
|
||||
images, comment, create PR).
|
||||
- A **skill** that orchestrates the human-in-the-loop loop: propose → wait
|
||||
for approval → implement → test → push → open PR.
|
||||
- An optional **cron job** that triggers the loop on a schedule.
|
||||
|
||||
## When to use
|
||||
|
||||
- User wants Hermes to monitor issues across one or more Gitea repos and
|
||||
propose fixes autonomously.
|
||||
- Issue text often includes screenshots or design mock-ups (image support
|
||||
is a first-class requirement).
|
||||
- A human (the user) must approve each fix before code is written.
|
||||
- Push and PR creation should target a working branch the user can review
|
||||
and merge manually.
|
||||
|
||||
Do **not** use for: GitHub (use the `gitea`-equivalent catalog MCP for that),
|
||||
issues that are just questions/discussions (no code work expected), or
|
||||
workflows where the user wants fully autonomous implementation with no
|
||||
human gate.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────┐
|
||||
│ ~/.hermes/plugins/gitea-issue-agent/ │
|
||||
Cron tick → │ ├ plugin.yaml │
|
||||
│ ├ __init__.py (registers 7 tools) │
|
||||
│ └ README.md │
|
||||
└─────────────┬──────────────────────────┘
|
||||
│ exposes
|
||||
▼
|
||||
gitea_agent_status gitea_issue_agent_scan
|
||||
gitea_list_repositories gitea_get_issue
|
||||
gitea_fetch_issue_images gitea_comment_issue
|
||||
gitea_create_pull_request
|
||||
│ consumed by
|
||||
▼
|
||||
┌────────────────────────────────────────┐
|
||||
│ Hermes skill gitea-issue-agent-loop │
|
||||
│ (orchestrates: scan → propose → wait │
|
||||
│ → implement → test → push → PR) │
|
||||
└─────────────┬──────────────────────────┘
|
||||
│ triggers
|
||||
▼
|
||||
┌────────────────────────────────────────┐
|
||||
│ Cron job (optional) "every 30m" │
|
||||
│ "Scan Gitea issues, propose solutions │
|
||||
│ for new ones, implement approved." │
|
||||
└────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
The plugin does **not** implement the loop — it only exposes the API
|
||||
surface. The loop lives in the skill and the cron prompt. This separation
|
||||
keeps the plugin small and lets the loop evolve without code changes to
|
||||
the plugin.
|
||||
|
||||
## Required environment variables
|
||||
|
||||
Add to `~/.hermes/.env` and restart the gateway:
|
||||
|
||||
```bash
|
||||
GITEA_BASE_URL=https://git.example.dk
|
||||
GITEA_TOKEN=*** service-bruger PAT, scope: read+write issues/PRs/attachments>
|
||||
GITEA_APPROVER_LOGIN=<the user's Gitea login, e.g. "dennis">
|
||||
GITEA_DEFAULT_BASE_BRANCH=main
|
||||
# Optional: comma-separated allowlist. If empty, /user/repos is scanned.
|
||||
GITEA_REPOS=owner/repo1,owner/repo2
|
||||
GITEA_AGENT_REPO_LIMIT=200
|
||||
GITEA_AGENT_ISSUE_LIMIT_PER_REPO=50
|
||||
```
|
||||
|
||||
Two Gitea identities, kept separate:
|
||||
|
||||
- `GITEA_TOKEN` → service-bruger account (e.g. `hermes-issues`). Posts
|
||||
comments and PRs as a bot.
|
||||
- `tea` CLI → the **user's personal** token, e.g. `dennis`. Used for
|
||||
local git push of branches the user owns.
|
||||
|
||||
Mixing them causes comments to look like they came from the human
|
||||
personally. See `references/identity-and-safety.md`.
|
||||
|
||||
## Tool set (the plugin registers these)
|
||||
|
||||
| Tool | Purpose |
|
||||
| --- | --- |
|
||||
| `gitea_agent_status` | Verify config + API connectivity; reports `authenticated_user`. |
|
||||
| `gitea_list_repositories` | List repos visible to the token (or `GITEA_REPOS` allowlist). |
|
||||
| `gitea_issue_agent_scan` | Scan open issues, classify each as `new` / `awaiting_approval` / `approved` / `pr_ready`. Returns short summary + image count. |
|
||||
| `gitea_get_issue` | Fetch one issue + comments + image links + classification status. |
|
||||
| `gitea_fetch_issue_images` | Download all screenshots/attachments from the issue + comments to a local cache, so `vision_analyze` can inspect them. |
|
||||
| `gitea_comment_issue` | Post a comment; optional `marker` arg (`proposal` or `pr_ready`) prepends an HTML marker the scanner uses to track state. |
|
||||
| `gitea_create_pull_request` | Open a PR from an already-pushed branch. The branch must be created and pushed by the agent via `git`/`terminal` first. |
|
||||
|
||||
Every tool returns JSON of the shape `{"success": true, ...}` or
|
||||
`{"success": false, "error": "..."}`. JSON is the only contract; the
|
||||
agent's loop should branch on `success` and surface `error` verbatim.
|
||||
|
||||
## The workflow (the loop the skill/cron implements)
|
||||
|
||||
1. **Scan**: `gitea_issue_agent_scan` to find `status == "new"` issues.
|
||||
2. **For each new issue**:
|
||||
1. `gitea_get_issue` for full text + comments.
|
||||
2. `gitea_fetch_issue_images` to download any screenshots to
|
||||
`~/.hermes/cache/gitea-issue-agent/<owner>/<repo>/<index>/`.
|
||||
3. `vision_analyze` on each image (loaded into context) so the
|
||||
proposal is grounded in what the screenshot actually shows.
|
||||
4. Draft a proposal (in Danish if the user is Danish-speaking) covering:
|
||||
- summary of the bug/feature
|
||||
- root-cause hypothesis
|
||||
- proposed change (files, tests, branch name)
|
||||
- explicit approval request
|
||||
5. `gitea_comment_issue` with `marker="proposal"`. The plugin
|
||||
prepends the `<!-- hermes:gitea-issue-agent v1 status=proposal -->`
|
||||
marker so the next scan classifies this issue as `awaiting_approval`.
|
||||
3. **Stop and wait for approval.** Do not implement. The skill exits
|
||||
cleanly; the cron re-scans later and the human will have either:
|
||||
- Posted an approval comment (matched by `APPROVAL_RE` against
|
||||
`GITEA_APPROVER_LOGIN`), or
|
||||
- Applied one of the approval labels (`hermes-approved`,
|
||||
`approved-by-dennis`, `dennis-approved`).
|
||||
4. **On the next scan**, issues that satisfy the approval rule are
|
||||
classified as `approved`. The skill then:
|
||||
1. `cd` into the local clone of the repo (clone it first via
|
||||
`git clone` if not present — `tea clone` will fail on non-standard
|
||||
SSH ports, see `gitea-tea-macos-setup`).
|
||||
2. Create a branch `hermes/issue-<n>-<slug>`.
|
||||
3. Implement, run tests, commit, push (with token in URL or via
|
||||
`~/.netrc`).
|
||||
4. `gitea_create_pull_request` with `head=hermes/issue-<n>-<slug>`,
|
||||
`base=main`, `body` linking to the issue, `draft=true`.
|
||||
5. `gitea_comment_issue` with `marker="pr_ready"` and the PR URL.
|
||||
5. **Stop.** The user reviews the PR in Gitea and merges by hand.
|
||||
|
||||
The two state markers (`proposal`, `pr_ready`) are the only state
|
||||
machine. They survive Gitea restarts, plugin reloads, and Hermes
|
||||
restarts, because they live in the issue's comment history.
|
||||
|
||||
## Approval semantics (default)
|
||||
|
||||
- A comment from `GITEA_APPROVER_LOGIN` matching
|
||||
`(?is)(@?hermes|agent|bot).{0,80}(godkend|approved|ok|go)` counts as
|
||||
approval. Danish is first-class because the user is Danish-speaking.
|
||||
- Any of these labels applied by a maintainer also counts:
|
||||
`hermes-approved`, `approved-by-dennis`, `dennis-approved`.
|
||||
- Approval is only counted if it is **after** the latest `proposal`
|
||||
comment. Re-approving an old proposal does nothing.
|
||||
- If `GITEA_APPROVER_LOGIN` is not set, free-form approval comments are
|
||||
ignored — only labels count. This is the safe default until the user
|
||||
has decided which login should be allowed to approve.
|
||||
|
||||
For a longer rationale, see `references/identity-and-safety.md`.
|
||||
|
||||
## Install checklist (do these in order)
|
||||
|
||||
1. Decide on a service-bruger name (e.g. `hermes-issues`) and create a
|
||||
Gitea PAT for it with `repository` (read+write) + `issue` (write) scope.
|
||||
2. Tell the user the env vars they need to set in `~/.hermes/.env` (see
|
||||
above). The agent **cannot** edit `.env` itself — Hermes blocks it
|
||||
at the tool layer. See `references/identity-and-safety.md` for why.
|
||||
3. Drop the plugin files into `~/.hermes/plugins/gitea-issue-agent/`
|
||||
(`plugin.yaml` + `__init__.py` + `README.md`).
|
||||
4. Tell the user to add `- gitea-issue-agent` under `plugins.enabled`
|
||||
in `~/.hermes/config.yaml`. The agent **cannot** edit `config.yaml`
|
||||
either — same reason.
|
||||
5. Restart the gateway: `hermes gateway restart`.
|
||||
6. Verify with `hermes chat -q "Kald gitea_agent_status" -t gitea_issue_agent`
|
||||
— `api_ok: true`, `authenticated_user: hermes-issues` (or whatever the
|
||||
service-bruger is named).
|
||||
7. Verify the local CLI path: `tea login add --name <user> --url ...`,
|
||||
`tea whoami`, then `git clone` any visible repo (don't rely on
|
||||
`tea clone` — see `gitea-tea-macos-setup` for the SSH-port-2224 trap).
|
||||
8. Only then create the cron job, and only after the user has
|
||||
approved running it unattended.
|
||||
|
||||
## Verification
|
||||
|
||||
Use the bundled script `scripts/verify-plugin-and-gitea.sh` to confirm
|
||||
the plugin is loaded, the API is reachable, and at least one repo is
|
||||
visible. It should print `OK` lines and exit 0.
|
||||
|
||||
## Pitfalls (learned)
|
||||
|
||||
- **Hermes blocks the agent from writing to `~/.hermes/config.yaml` and
|
||||
`~/.hermes/.env`.** The right move when blocked is to give the user
|
||||
the exact one-line edit and stop. Do not try `execute_code`, `patch`,
|
||||
or `write_file` a different way — the guard fires on all of them.
|
||||
See `references/identity-and-safety.md`.
|
||||
- **`tea clone` fails opaquely when SSH is on a non-standard port
|
||||
(e.g. 2224).** Use `git clone` with the HTTPS `clone_url` from the
|
||||
Gitea API. Strip the token from `origin` immediately.
|
||||
- **Stale `osxkeychain` credentials can sabotage `git push` even when
|
||||
the token is correct.** Switch to `~/.netrc` with `chmod 600`. See
|
||||
the troubleshooting reference in `gitea-tea-macos-setup`.
|
||||
- **Service-bruger identity matters.** The plugin's `authenticated_user`
|
||||
field should NOT be your personal login. If it is, create a separate
|
||||
service-bruger or you'll get bot comments attributed to you.
|
||||
- **Free-form approval comments must be gated by a known login** (or
|
||||
by maintainer-applied labels) — otherwise a co-worker saying "looks
|
||||
good!" could trigger an autonomous push.
|
||||
- **First run: verify, don't cron.** Always run a manual scan + a
|
||||
manually-approved test issue before scheduling cron. A cron job
|
||||
firing off PRs in the user's name is the failure mode to avoid.
|
||||
- **Image analysis is mandatory for screenshot issues.** The plugin
|
||||
extracts image URLs and downloads them locally; the skill MUST call
|
||||
`vision_analyze` on each before writing the proposal, otherwise the
|
||||
proposal will be blind to what the screenshot shows.
|
||||
- **Plugin state is in issue comments, not in plugin memory.** The
|
||||
proposal/PR markers make the workflow restart-safe.
|
||||
|
||||
## Related skills
|
||||
|
||||
- `gitea-tea-macos-setup` — the local `tea` + `git` + `osxkeychain`
|
||||
setup that the agent relies on. Includes the SSH-port-2224 trap and
|
||||
the `getlogin(2)` gotcha.
|
||||
- `software-engineering-workflows` — the plan / spike / TDD / debugging
|
||||
patterns the implement-and-test phase of the loop should follow.
|
||||
- `software-development/simplify-code` — useful to invoke on the
|
||||
agent's own code changes before opening the PR.
|
||||
|
||||
## Files in this skill
|
||||
|
||||
- `scripts/verify-plugin-and-gitea.sh` — one-shot verification of the
|
||||
full setup (plugin loaded + API reachable + repos visible + clone
|
||||
works). Run it after every change to the env or plugin.
|
||||
- `references/identity-and-safety.md` — service-bruger vs personal
|
||||
identity split, Hermes safety-guard reality, approval-gate design
|
||||
rationale.
|
||||
- `templates/proposal-comment.md` — starter template for the
|
||||
`gitea_comment_issue` body when posting a proposal. Copy, fill in
|
||||
the issue-specific bits, send.
|
||||
@@ -0,0 +1,101 @@
|
||||
# Identity and safety in the Gitea issue agent
|
||||
|
||||
## Two identities, one Gitea server
|
||||
|
||||
The setup has two Gitea users that should remain separate:
|
||||
|
||||
| Identity | Token stored in | Used for | What it posts |
|
||||
| --- | --- | --- | --- |
|
||||
| **Service-bruger** (e.g. `hermes-issues`) | `GITEA_TOKEN` in `~/.hermes/.env` | All plugin tool calls: scan, comment, create PR | Comments and PRs attributed to the bot |
|
||||
| **Personal user** (e.g. `dennis`) | `tea` login + `~/.netrc` | Local `git push` of branches the agent created | Commits and merges attributed to you |
|
||||
|
||||
The plugin's `gitea_agent_status` reports `authenticated_user`. If it
|
||||
reports your personal login, the bot will post comments as you — fix
|
||||
the `GITEA_TOKEN` before scheduling any cron job.
|
||||
|
||||
## Why the Hermes safety-guards exist (and why the agent can't bypass them)
|
||||
|
||||
Hermes has three layers of "agent cannot edit" protection, all by
|
||||
design:
|
||||
|
||||
1. **`~/.hermes/config.yaml`** — gates plugin load, model selection,
|
||||
toolsets, gateway config. An agent that could rewrite this could
|
||||
give itself new tools, change providers, or open outbound network
|
||||
channels. The `patch` tool explicitly refuses:
|
||||
`Refusing to write to Hermes config file: ... Agent cannot modify
|
||||
security-sensitive configuration.`
|
||||
|
||||
2. **`~/.hermes/.env`** — contains API keys, tokens, passwords. The
|
||||
`write_file`, `patch`, and `execute_code` tools all refuse to
|
||||
modify it.
|
||||
|
||||
3. **`execute_code` with file writes** — when a script is going to
|
||||
write files under `~/.hermes/`, the safety guard blocks it
|
||||
unconditionally: `BLOCKED: execute_code script timed out without
|
||||
user response.`
|
||||
|
||||
**The right move when blocked:** give the user the exact one-line
|
||||
edit (or `cat > file <<EOF ... EOF` block) and stop. Do not try
|
||||
alternative tools, do not retry, do not work around it. The user
|
||||
must run the edit themselves.
|
||||
|
||||
This is not a bug. It is the threat model.
|
||||
|
||||
## Approval-gate design rationale
|
||||
|
||||
The agent should never implement, push, or open a PR for an issue
|
||||
that hasn't been approved. This protects against three classes of
|
||||
mistake:
|
||||
|
||||
1. **Misread issue** — the user wanted "fix the X button", the agent
|
||||
rewrote `X()` in 14 files. Approval forces a human to look at the
|
||||
proposal first.
|
||||
2. **Out-of-scope issue** — the user wanted to push back, not fix.
|
||||
Approval makes "no" cheap (just don't reply).
|
||||
3. **Bad agent day** — the LLM hallucinated, the issue got a wrong
|
||||
proposal, the agent is in a weird state. Approval makes the loop
|
||||
recoverable without reverting commits.
|
||||
|
||||
The two state markers (`proposal`, `pr_ready`) survive Hermes
|
||||
restarts, plugin reloads, and Gitea restarts, because they live in
|
||||
the issue's comment history. The agent never has to remember
|
||||
anything to resume — it can re-scan and pick up where it left off.
|
||||
|
||||
## Approval matching — why it requires a known login by default
|
||||
|
||||
The default approval regex is permissive enough to handle Danish
|
||||
("godkend", "godkender") and English ("approve", "approved", "ok",
|
||||
"go") in either order with the agent mention. But without a
|
||||
known-login check, **any user** with write access to the repo could
|
||||
say "looks good, agent" in a comment and trigger a push. That's a
|
||||
prompt-injection vector — a malicious screenshot, or a co-worker
|
||||
joking, could authorize code execution.
|
||||
|
||||
Two safe modes:
|
||||
|
||||
- **Label-only mode** (the default until `GITEA_APPROVER_LOGIN` is
|
||||
set): a maintainer applies one of three labels. Labels can only
|
||||
be applied by users with write access, so the trust surface is
|
||||
the maintainer set, not "anyone who can comment".
|
||||
- **Login-gated mode** (once `GITEA_APPROVER_LOGIN` is set): a
|
||||
comment from that specific user, matching the regex, AND
|
||||
after the latest proposal comment. This is the strict mode.
|
||||
|
||||
If the user wants both, both work — labels OR login-comment. If they
|
||||
want neither (auto-implement everything), they should set both
|
||||
`GITEA_APPROVER_LOGIN` and approve a *meta-issue* saying "always
|
||||
implement" — but that's not the default and not recommended.
|
||||
|
||||
## The first run is always manual
|
||||
|
||||
Before scheduling a cron, the user must:
|
||||
|
||||
1. Run a manual `gitea_issue_agent_scan` and confirm issues show up.
|
||||
2. Pick a real issue and walk it through: scan → fetch images →
|
||||
analyze → propose → wait → approve → implement → test → push →
|
||||
PR.
|
||||
3. Confirm the PR body links to the issue, the commit message is
|
||||
reasonable, and `git log` shows the expected author/email.
|
||||
4. **Then** schedule a cron. The cron prompt itself should be
|
||||
minimal — "scan, propose for new issues, implement approved" —
|
||||
with a clear hand-off to the skill for the details.
|
||||
@@ -0,0 +1,85 @@
|
||||
#!/usr/bin/env bash
|
||||
# Verify the Gitea issue agent is wired up end to end.
|
||||
#
|
||||
# Exits 0 on success. Prints OK / FAIL lines for each step.
|
||||
# Run after every env or plugin change.
|
||||
#
|
||||
# Does NOT need a real token in the script — reads from ~/.hermes/.env.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
ok() { echo " OK $*"; }
|
||||
bad() { echo " FAIL $*"; exit 1; }
|
||||
|
||||
echo "=== 1. Plugin files present ==="
|
||||
PLUGIN_DIR="$HOME/.hermes/plugins/gitea-issue-agent"
|
||||
for f in plugin.yaml __init__.py README.md; do
|
||||
[ -f "$PLUGIN_DIR/$f" ] && ok "$f" || bad "$PLUGIN_DIR/$f missing"
|
||||
done
|
||||
|
||||
echo
|
||||
echo "=== 2. Plugin listed in config.yaml ==="
|
||||
grep -q "gitea-issue-agent" "$HOME/.hermes/config.yaml" \
|
||||
&& ok "config.yaml references gitea-issue-agent" \
|
||||
|| bad "gitea-issue-agent not in plugins.enabled — add it under plugins.enabled in ~/.hermes/config.yaml"
|
||||
|
||||
echo
|
||||
echo "=== 3. Plugin imports without error ==="
|
||||
python3 - <<'PY' && ok "import gitea-issue-agent OK" || bad "import failed"
|
||||
import importlib.util, sys
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"gitea_issue_agent",
|
||||
f"{__import__('os').path.expanduser('~')}/.hermes/plugins/gitea-issue-agent/__init__.py",
|
||||
)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
sys.modules["gitea_issue_agent"] = mod
|
||||
spec.loader.exec_module(mod)
|
||||
assert len(mod.SCHEMAS) >= 5, f"expected at least 5 tools, got {len(mod.SCHEMAS)}"
|
||||
PY
|
||||
|
||||
echo
|
||||
echo "=== 4. Env vars present ==="
|
||||
set +e
|
||||
. "$HOME/.hermes/.env" 2>/dev/null
|
||||
set -e
|
||||
[ -n "${GITEA_BASE_URL:-}" ] && ok "GITEA_BASE_URL=$GITEA_BASE_URL" || bad "GITEA_BASE_URL missing in ~/.hermes/.env"
|
||||
[ -n "${GITEA_TOKEN:-}" ] && ok "GITEA_TOKEN set (${#GITEA_TOKEN} chars)" || bad "GITEA_TOKEN missing in ~/.hermes/.env"
|
||||
|
||||
echo
|
||||
echo "=== 5. API connectivity ==="
|
||||
HTTP=$(curl -4 -s -o /dev/null -m 10 -w "%{http_code}" \
|
||||
-H "Authorization: token $GITEA_TOKEN" \
|
||||
"$GITEA_BASE_URL/api/v1/user")
|
||||
if [ "$HTTP" = "200" ]; then
|
||||
USER=$(curl -4 -s -m 10 -H "Authorization: token $GITEA_TOKEN" \
|
||||
"$GITEA_BASE_URL/api/v1/user" | python3 -c "import sys,json; print(json.load(sys.stdin).get('login','?'))")
|
||||
ok "API reachable, authenticated as: $USER"
|
||||
else
|
||||
bad "API $GITEA_BASE_URL/api/v1/user returned HTTP $HTTP"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "=== 6. At least one repo visible ==="
|
||||
N=$(curl -4 -s -m 10 -H "Authorization: token $GITEA_TOKEN" \
|
||||
"$GITEA_BASE_URL/api/v1/user/repos?limit=100" | python3 -c "import sys,json; print(len(json.load(sys.stdin)))")
|
||||
[ "$N" -gt 0 ] && ok "$N repos visible to token" || bad "no repos visible — token scope may be too narrow"
|
||||
|
||||
echo
|
||||
echo "=== 7. local tea login (optional but recommended) ==="
|
||||
if command -v tea >/dev/null 2>&1; then
|
||||
TUSER=$(tea whoami 2>/dev/null | grep -E "^[a-zA-Z0-9_-]+$" | head -1)
|
||||
[ -n "$TUSER" ] && ok "tea whoami -> $TUSER" || echo " INFO tea installed but no login yet"
|
||||
else
|
||||
echo " INFO tea not on PATH — see gitea-tea-macos-setup"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "=== 8. ~/.netrc (optional but recommended) ==="
|
||||
if [ -f "$HOME/.netrc" ] && grep -q "git.radixadm.dk\|$GITEA_BASE_URL" "$HOME/.netrc"; then
|
||||
ok "~/.netrc has an entry for $GITEA_BASE_URL"
|
||||
else
|
||||
echo " INFO ~/.netrc missing — git push will use osxkeychain (may be stale)"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "All checks passed. The agent is ready to use."
|
||||
@@ -0,0 +1,40 @@
|
||||
<!-- hermes:gitea-issue-agent v1 status=proposal -->
|
||||
|
||||
## Analyse
|
||||
|
||||
<kort resumé af hvad issuet beder om — i dansk hvis brugeren er dansk>
|
||||
|
||||
## Screenshots / visuelle referencer
|
||||
|
||||
<bullet-liste over hvad hver screenshot viser og hvad vi har udledt af den>
|
||||
|
||||
## Rod-hypotese
|
||||
|
||||
<1-3 bullets der peger på den underliggende årsag, ikke symptomet>
|
||||
|
||||
## Foreslået ændring
|
||||
|
||||
- **Repo:** `<owner>/<repo>`
|
||||
- **Branch:** `hermes/issue-<index>-<kort-slug>`
|
||||
- **Filer der forventes ændret:**
|
||||
- `<sti/til/fil.py>` — <hvad og hvorfor>
|
||||
- `<sti/til/test_x.py>` — ny test der dækker regressionen
|
||||
- **Risiko:** lav / medium / høj — <begrundelse>
|
||||
|
||||
## Test-plan
|
||||
|
||||
- `docker compose exec web python manage.py test <app>` skal køre grønt
|
||||
- Manuel verify: <trin brugeren kan tjekke i UI>
|
||||
- Hvis relevant: <evt. playwright/screenshot-verifikation>
|
||||
|
||||
## Spørgsmål til Dennis
|
||||
|
||||
- <evt. valg brugeren skal tage før implementering — f.eks. "skal eksisterende data migreres, eller kun nye records?">
|
||||
|
||||
## Godkend
|
||||
|
||||
Hvis du vil have mig til at implementere det her, så svar med:
|
||||
|
||||
> @hermes godkend
|
||||
|
||||
(eller sæt label `hermes-approved` på issuet)
|
||||
@@ -0,0 +1,56 @@
|
||||
---
|
||||
name: kanban-workflows
|
||||
description: "Use when operating Hermes Kanban workflows: decomposing work as an orchestrator, executing as a worker, managing lanes, status, blockers, and reconciliation."
|
||||
version: 1.0.0
|
||||
author: Hermes Agent
|
||||
license: MIT
|
||||
metadata:
|
||||
hermes:
|
||||
tags: [kanban, orchestration, workers, task-management, hermes]
|
||||
related_skills: [autonomous-coding-agents]
|
||||
---
|
||||
|
||||
# Kanban Workflows Umbrella
|
||||
|
||||
## Overview
|
||||
|
||||
Kanban work has two sides: orchestrators decompose and route work without doing it themselves; workers execute a scoped card, keep status current, and return verifiable evidence. Use this umbrella for both roles.
|
||||
|
||||
## When to Use
|
||||
|
||||
- Acting as a Hermes Kanban orchestrator.
|
||||
- Acting as a Hermes Kanban worker.
|
||||
- Deciding whether to split, assign, block, or reconcile cards.
|
||||
- Handling worker pitfalls, examples, and edge cases.
|
||||
|
||||
## Role Playbooks
|
||||
|
||||
### Orchestrator
|
||||
|
||||
Decompose outcomes into cards small enough for one worker, include acceptance criteria, and avoid the temptation to implement the work directly. Route work, unblock workers, and reconcile results.
|
||||
|
||||
### Worker
|
||||
|
||||
Own exactly the assigned card. Update status when starting, blocked, or done. Run the requested checks and return concise evidence: files changed, commands run, output, and remaining risks.
|
||||
|
||||
### Lanes and external agents
|
||||
|
||||
If a worker uses an external coding agent lane, keep Kanban as the source of truth and verify the lane's output before closing the card.
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
1. Orchestrators doing implementation work themselves.
|
||||
2. Workers broadening scope beyond the card.
|
||||
3. Closing cards from self-reports without independent verification.
|
||||
4. Losing blocker state instead of surfacing it promptly.
|
||||
|
||||
## Package Notes
|
||||
|
||||
Archived source skills retain the deeper role-specific text: `kanban-orchestrator` and `kanban-worker`.
|
||||
|
||||
## Verification Checklist
|
||||
|
||||
- [ ] Card scope and acceptance criteria are explicit.
|
||||
- [ ] Exactly one owner is responsible for each action.
|
||||
- [ ] Status reflects reality: pending, in progress, blocked, done.
|
||||
- [ ] Completion includes verifiable evidence.
|
||||
Reference in New Issue
Block a user