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:
2026-06-15 14:41:04 +02:00
commit 236053cd7f
30 changed files with 4460 additions and 0 deletions
+25
View File
@@ -0,0 +1,25 @@
# Python
__pycache__/
*.pyc
*.pyo
*.pyd
.Python
*.egg-info/
.venv/
venv/
.pytest_cache/
# macOS
.DS_Store
# Editor / IDE
.vscode/
.idea/
*.swp
*.swo
*~
.DS_Store
# Logs and temp
*.log
*.tmp
+67
View File
@@ -0,0 +1,67 @@
# Radix-Skills
Delt AI-skill-bibliotek for Radix. Bruges af alle medarbejderes AI-agenter
(Hermes, Codex m.fl.) til dansk regnskab/ERP, data-afstemning og devops-workflows.
> **Hvad er en "skill"?** En self-contained YAML-frontmatter + markdown-fil
> (`SKILL.md`) der beskriver et domæne-specifikt workflow, som en AI-agent kan
> loade på forespørgsel. Tænk på det som en delt playbook for en bestemt
> type opgave.
## Struktur
```text
Radix-Skills/
├── README.md # denne fil
├── index.json # maskinlæseligt index over alle skills
└── skills/
├── accounting/ # dansk regnskab, bogføring, ERP
│ ├── radix-accounting/ # top-level bogholderi-skill (sprog, formater, aktivering)
│ ├── coop-bankimport/
│ ├── dansk-csv-import/
│ ├── e-conomic-integration/
│ ├── kontoplan-mapping/
│ ├── momsafregning/
│ └── resultatopgoerelse/
├── data-science/
│ └── erp-data-reconciliation/ # afstemning mellem ERP-systemer
└── devops/
├── gitea-issue-agent/ # agent-drevet Gitea issue/PR-workflow
└── kanban-workflows/
```
Hver skill ligger i sin egen mappe og har mindst `SKILL.md`. Større skills
kan have undermapper:
- `references/` — uddybende dokumentation der kun loades ved behov
- `templates/` — skabeloner (f.eks. issue-kommentarer, rapporter)
- `scripts/` — hjælpescripts der følger med skillen
## Hvordan en agent bruger repo'et
1. Læs `index.json` for at finde relevante skills ud fra tags/description.
2. Gå ind i den relevante `skills/<kategori>/<navn>/` mappe.
3. Læs `SKILL.md` (med YAML-frontmatter) for at aktivere skillen.
4. Følg de nummererede steps i skillen.
5. Hvis skillen henviser til en reference, load den først når det er relevant
(ikke alle på én gang).
## Bidrag
Nye skills skal:
1. Lægges under `skills/<kategori>/<skill-navn>/` med en `SKILL.md`.
2. Følge SKILL.md-formatet (YAML-frontmatter med `name`, `description`,
`version`, `author`, `license` + markdown body).
3. Have en kort `description` der kan bruges til trigger-matching.
4. Have en `version` der følger semver.
5. Blive registreret i `index.json` (kør `python3 scripts/rebuild_index.py`).
Åbn en Gitea-PR med ændringerne — `gitea-issue-agent`-skillen kan guide dig.
## Vedligeholdelse
- `index.json` genereres automatisk af `scripts/rebuild_index.py` ud fra
frontmatter i alle `SKILL.md` filer.
- Store ændringer (ny kategori, formatændring) kræver opdatering af denne
`README.md`.
+215
View File
@@ -0,0 +1,215 @@
{
"repository": "Radix-Skills",
"version": 1,
"description": "Delt AI-skill-bibliotek for Radix — bruges af alle medarbejderes AI-agenter (Hermes, Codex m.fl.) til dansk regnskab/ERP/data/devops.",
"categories": [
"accounting",
"data-science",
"devops"
],
"skill_count": 10,
"skills": [
{
"category": "accounting",
"name": "coop-bankimport",
"path": "accounting/coop-bankimport/SKILL.md",
"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",
"tags": [
"accounting",
"dansk-regnskab",
"coop-danmark",
"uniconta",
"e-conomic",
"reconciliation",
"bankafstemning"
],
"files": [
"accounting/coop-bankimport/SKILL.md"
]
},
{
"category": "accounting",
"name": "dansk-csv-import",
"path": "accounting/dansk-csv-import/SKILL.md",
"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",
"tags": [
"accounting",
"dansk-regnskab",
"csv-import",
"bank",
"nordea",
"danske-bank",
"jyske-bank",
"e-conomic"
],
"files": [
"accounting/dansk-csv-import/SKILL.md"
]
},
{
"category": "accounting",
"name": "e-conomic-integration",
"path": "accounting/e-conomic-integration/SKILL.md",
"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",
"tags": [
"accounting",
"e-conomic",
"dansk-regnskab",
"api",
"cloud-bogfoering",
"integration"
],
"files": [
"accounting/e-conomic-integration/SKILL.md"
]
},
{
"category": "accounting",
"name": "kontoplan-mapping",
"path": "accounting/kontoplan-mapping/SKILL.md",
"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",
"tags": [
"accounting",
"dansk-regnskab",
"kontoplan",
"SUM",
"DG",
"SL",
"uniconta",
"e-conomic"
],
"files": [
"accounting/kontoplan-mapping/SKILL.md"
]
},
{
"category": "accounting",
"name": "momsafregning",
"path": "accounting/momsafregning/SKILL.md",
"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",
"tags": [
"accounting",
"dansk-regnskab",
"moms",
"skat",
"momsangivelse"
],
"files": [
"accounting/momsafregning/SKILL.md"
]
},
{
"category": "accounting",
"name": "radix-accounting",
"path": "accounting/radix-accounting/SKILL.md",
"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",
"tags": [
"accounting",
"dansk-regnskab",
"moms",
"radix-erp",
"uniconta",
"e-conomic",
"coop"
],
"files": [
"accounting/radix-accounting/SKILL.md"
]
},
{
"category": "accounting",
"name": "resultatopgoerelse",
"path": "accounting/resultatopgoerelse/SKILL.md",
"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",
"tags": [
"accounting",
"dansk-regnskab",
"resultatopgoerelse",
"radix-erp"
],
"files": [
"accounting/resultatopgoerelse/SKILL.md"
]
},
{
"category": "data-science",
"name": "erp-data-reconciliation",
"path": "data-science/erp-data-reconciliation/SKILL.md",
"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.",
"version": "",
"author": "",
"tags": [],
"files": [
"data-science/erp-data-reconciliation/SKILL.md",
"data-science/erp-data-reconciliation/references/auditor-pack-and-dedup.md",
"data-science/erp-data-reconciliation/references/bank-implementation.md",
"data-science/erp-data-reconciliation/references/bank-reconciliation.md",
"data-science/erp-data-reconciliation/references/coop-invoice-payment-allocation.md",
"data-science/erp-data-reconciliation/references/coop-payment-advice.md",
"data-science/erp-data-reconciliation/references/coop-uniconta-analysis.md",
"data-science/erp-data-reconciliation/references/coop-uniconta-import-ui-and-payment-advice.md",
"data-science/erp-data-reconciliation/references/coop-uniconta-match-engine.md",
"data-science/erp-data-reconciliation/references/django-accounting-ui-formatting.md",
"data-science/erp-data-reconciliation/references/idempotent-source-file-imports.md",
"data-science/erp-data-reconciliation/references/reconciliation-webapp-architecture.md",
"data-science/erp-data-reconciliation/references/three-way-django-analysis-engine.md",
"data-science/erp-data-reconciliation/scripts/excel-analyzer.py"
]
},
{
"category": "devops",
"name": "gitea-issue-agent",
"path": "devops/gitea-issue-agent/SKILL.md",
"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",
"tags": [
"gitea",
"issues",
"automation",
"hermes-plugin",
"pr-workflow",
"agent-loop",
"approval-gate"
],
"files": [
"devops/gitea-issue-agent/SKILL.md",
"devops/gitea-issue-agent/references/identity-and-safety.md",
"devops/gitea-issue-agent/scripts/verify-plugin-and-gitea.sh",
"devops/gitea-issue-agent/templates/proposal-comment.md"
]
},
{
"category": "devops",
"name": "kanban-workflows",
"path": "devops/kanban-workflows/SKILL.md",
"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",
"tags": [
"kanban",
"orchestration",
"workers",
"task-management",
"hermes"
],
"files": [
"devops/kanban-workflows/SKILL.md"
]
}
]
}
+124
View File
@@ -0,0 +1,124 @@
#!/usr/bin/env python3
"""
Rebuild index.json from SKILL.md frontmatter in this repository.
Usage:
python3 scripts/rebuild_index.py
python3 scripts/rebuild_index.py --check # exit 1 if index.json is out of date
The index is the canonical machine-readable manifest of all skills in this
repository. Regenerate it whenever a SKILL.md is added, removed, or has its
frontmatter changed. Commit index.json together with the SKILL.md change.
"""
from __future__ import annotations
import argparse
import json
import re
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
SKILLS_ROOT = REPO_ROOT / "skills"
INDEX_PATH = REPO_ROOT / "index.json"
FRONTMATTER_RE = re.compile(r"^---\n(.*?)\n---\n", re.DOTALL)
KEY_RE = re.compile(r"^([A-Za-z_][A-Za-z0-9_]*):\s*(.+?)(?=\n[A-Za-z_]|\Z)", re.MULTILINE | re.DOTALL)
def _grab(frontmatter: str, key: str) -> str:
m = re.search(rf"^{key}:\s*(.+?)(?=\n[A-Za-z_]|\Z)", frontmatter, re.MULTILINE | re.DOTALL)
if not m:
return ""
val = m.group(1).strip()
# strip YAML block indicators and quotes
val = val.strip(">").strip("|").strip()
if (val.startswith('"') and val.endswith('"')) or (val.startswith("'") and val.endswith("'")):
val = val[1:-1]
return val.strip()
def _grab_tags(frontmatter: str) -> list[str]:
m = re.search(r"tags:\s*\[(.*?)\]", frontmatter, re.DOTALL)
if not m:
return []
return [t.strip().strip('"').strip("'") for t in m.group(1).split(",") if t.strip()]
def build_index() -> dict:
skills: list[dict] = []
for skill_md in sorted(SKILLS_ROOT.rglob("SKILL.md")):
rel = skill_md.relative_to(SKILLS_ROOT)
parts = rel.parts
category = parts[0] if len(parts) > 1 else "root"
name = parts[-2] if len(parts) > 1 else "root"
text = skill_md.read_text(encoding="utf-8")
fm_match = FRONTMATTER_RE.match(text)
frontmatter = fm_match.group(1) if fm_match else ""
desc = _grab(frontmatter, "description").replace("\n", " ").strip()
version = _grab(frontmatter, "version")
author = _grab(frontmatter, "author")
tags = _grab_tags(frontmatter)
all_files = sorted(
str(p.relative_to(SKILLS_ROOT))
for p in skill_md.parent.rglob("*")
if p.is_file()
)
skills.append(
{
"category": category,
"name": name,
"path": str(rel),
"description": desc,
"version": version,
"author": author,
"tags": tags,
"files": all_files,
}
)
return {
"repository": "Radix-Skills",
"version": 1,
"description": (
"Delt AI-skill-bibliotek for Radix — bruges af alle medarbejderes "
"AI-agenter (Hermes, Codex m.fl.) til dansk regnskab/ERP/data/devops."
),
"categories": sorted({s["category"] for s in skills}),
"skill_count": len(skills),
"skills": skills,
}
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--check", action="store_true", help="Exit 1 if index.json is stale")
args = parser.parse_args()
new_index = build_index()
new_json = json.dumps(new_index, ensure_ascii=False, indent=2) + "\n"
if args.check:
if not INDEX_PATH.exists():
print("index.json does not exist", file=sys.stderr)
return 1
current = INDEX_PATH.read_text(encoding="utf-8")
if current != new_json:
print("index.json is out of date — run scripts/rebuild_index.py", file=sys.stderr)
return 1
return 0
INDEX_PATH.write_text(new_json, encoding="utf-8")
print(
f"Wrote {INDEX_PATH.relative_to(REPO_ROOT)} with {new_index['skill_count']} skills "
f"across {len(new_index['categories'])} categories: {', '.join(new_index['categories'])}"
)
return 0
if __name__ == "__main__":
sys.exit(main())
+198
View File
@@ -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
+178
View File
@@ -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
+161
View File
@@ -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.*
+104
View File
@@ -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 Djangos 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
```
@@ -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.
@@ -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/131/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
@@ -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.
@@ -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
@@ -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
@@ -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()
+248
View File
@@ -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)
+56
View File
@@ -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.