# Recept: loonjournaalpost aanleveren via de FenoFin-API

Voor elk loonpakket (Het Loonloket, AFAS, Loket, een eigen script). De koppeling stuurt na
iedere verloning de loonjournaalpost naar FenoFin. FenoFin activeert de standaard
RGS-loonrekeningen, boekt de post als memoriaalpost, registreert de loonperiode zodat dezelfde
periode nooit dubbel wordt geboekt, en toont de aanlevering bij **Lonen → Verloningen** onder
"Aangeleverd via de API". Nmbrs is de ingebouwde koppeling van FenoFin en staat hier los van.

- Dit document: `https://www.fenofin.nl/ontwikkelaars/loonjournaal.md` (ruwe markdown, geen login)
- API-referentie (OpenAPI/ReDoc): `https://www.fenofin.nl/api/v1/docs/` — operaties
  *Loonjournaalpost aanleveren* en *Aangeleverde loonjournaalposten*
- OpenAPI-schema: `https://www.fenofin.nl/api/v1/schema/`

## Voor AI-assistenten

Dit document is volledig: alles wat nodig is om een werkende koppeling te bouwen staat hieronder,
inclusief een referentie-implementatie (§7). Kant-en-klare opdracht:

> Bouw met dit recept (https://www.fenofin.nl/ontwikkelaars/loonjournaal.md) een koppeling die de
> loonjournaalpost van **[loonpakket]** per loonperiode naar FenoFin stuurt. De export van het
> pakket is **[bestandsformaat en kolommen]**. Neem de referentie-implementatie in §7 als basis,
> vertaal de pakketcodes naar de RGS-codes uit §3, lees sleutel en administratie-id uit
> omgevingsvariabelen en test eerst tegen de sandbox-administratie.

Wat de AI nog van u nodig heeft: (1) de naam van het loonpakket, (2) een voorbeeldexport van één
verloning, (3) uw sandbox-administratie-id en een test-sleutel (`fenofin_test_…`). Geef de sleutel
alleen door via een omgevingsvariabele — nooit in de chat of in de code.

## 1. Sandbox en sleutel

Maak in FenoFin (Mijn kantoor → Sandbox) een **test-sleutel** aan met de scopes
`grootboek:read`, `lonen:read` en `lonen:write`, gekoppeld aan uw sandbox-administratie.
Een test-sleutel (`fenofin_test_…`) ziet uitsluitend sandbox-administraties en schrijft daar
direct, voor zover zijn scopes dat toestaan (hier `lonen:write`) — u kunt vrij bouwen en testen.
`GET /api/v1/status/` toont met `mag_schrijven` de vrijgave van de omgeving en met `scopes` wat
de sleutel mag.

```
Authorization: Bearer fenofin_test_…
Idempotency-Key: <uuid; één per aanlevering, bij een herhaling dezelfde>
Content-Type: application/json
```

De API accepteert alleen JSON: zonder `Content-Type: application/json` volgt `415`.

## 2. Rekeningschema ophalen

```
GET /api/v1/administraties/{administratie_id}/grootboekrekeningen/?actief=true
```

Elke rekening komt terug met `grootboeknummer`, `naam`, `rgs_referentiecode` en
`direct_boeken_toegestaan`. Gebruik alleen rekeningen met `direct_boeken_toegestaan: true`.
De **standaard RGS-loonrekeningen** (§3) hoeft u niet vooraf te activeren: het
loonjournaal-endpoint zet ze bij de eerste aanlevering zelf aan. Wilt u afwijkende rekeningen
gebruiken, activeer die dan eerst in FenoFin (Boekhouding → Rekeningschema).

## 3. Pakketcodes koppelen: RGS-code of FenoFin-nummer

Elke regel adresseert een rekening op **RGS-code** (`rgs`) óf op **FenoFin-grootboeknummer**
(`grootboeknummer`) — precies één van beide. Het FenoFin-rekeningschema is RGS-gebaseerd en
voor alle administraties gelijk, dus de vertaling hieronder geldt overal; u hoeft niets per
administratie in te richten. Exporteert uw loonpakket op RGS-code, dan is `rgs` uw sleutel en
is de tabel alleen ter controle.

| Rubriek in uw loonpakket                  | `rgs`        | `grootboeknummer` | Zijde  |
|-------------------------------------------|--------------|-------------------|--------|
| Brutoloon / lonen en salarissen           | `WPerLesLon` | `4001040`         | debet  |
| Premies sociale verzekeringen (werkgever) | `WPerSolPsv` | `4002010`         | debet  |
| Pensioenpremies (werkgever)               | `WPerPenPen` | `4003010`         | debet  |
| Netto te betalen loon                     | `BSchSalNet` | `1204010`         | credit |
| Af te dragen loonheffing                  | `BSchBepLhe` | `1205036`         | credit |
| Af te dragen pensioen                     | `BSchStz`    | `1204089`         | credit |
| Reservering vakantiegeld (optioneel)      | `BSchSalTvg` | `1204040`         | credit |
| Reservering vakantiedagen (optioneel)     | `BSchSalTbv` | `1204050`         | credit |
| Werkkleding (optioneel)                   | `WBedOvpWkv` | `4012071`         | debet  |
| Personeelsuitjes en -geschenken (optioneel) | `WBedOvpPug` | `4012075`       | debet  |
| Arbodienst (optioneel)                    | `WBedOvpAbd` | `4012080`         | debet  |
| Ziekengeld / verzuimverzekering (optioneel) | `WBedOvpZie` | `4012100`       | debet  |
| Ontvangen ziekengelden (optioneel)        | `WBedOvpOzi` | `4012110`         | credit |

Deze dertien rekeningen activeert het endpoint zelf. Boekt uw pakket op méér rubrieken
(reiskosten, opleidingen, WKR-posten), dan activeert de klant die rekening in FenoFin en vindt u
hem in stap 2. Een RGS-code kan in het rekeningschema meer dan één keer voorkomen; is de code
dubbelzinnig, dan weigert FenoFin de regel met `422 grootboek_niet_boekbaar` en noemt de
kandidaten — geef dan het `grootboeknummer`.

## 4. Aanleveren

```
POST /api/v1/administraties/{administratie_id}/loonjournaal/
```

```json
{
  "bron": "loonloket",
  "jaar": 2026,
  "periode": 9,
  "datum": "2026-09-30",
  "omschrijving": "Loonjournaal september 2026",
  "regels": [
    {"rgs": "WPerLesLon", "debet": "12500.00", "omschrijving": "Brutoloon"},
    {"rgs": "WPerSolPsv", "debet": "2350.00",  "omschrijving": "Premies sociale verzekeringen"},
    {"rgs": "WPerPenPen", "debet": "980.00",   "omschrijving": "Pensioenpremies WG"},
    {"rgs": "BSchSalNet", "credit": "9870.00", "omschrijving": "Netto loon"},
    {"rgs": "BSchBepLhe", "credit": "4610.00", "omschrijving": "Loonheffing"},
    {"rgs": "BSchStz",    "credit": "1350.00", "omschrijving": "Pensioen"}
  ]
}
```

- `bron` + `jaar` + `periode` is de **periode-sleutel** en moet bij een herhaalde poging exact
  gelijk blijven: die beschermt tegen dubbel boeken van dezelfde verloning. Herhaalt u na een
  time-out met dezelfde `Idempotency-Key` en dezelfde body, dan krijgt u het oorspronkelijke
  antwoord terug (`X-Idempotent-Replay: true`, minimaal 24 uur); met een nieuwe key volgt
  `409 loonperiode_al_geboekt`.
- `bron`: kleine letters, cijfers, `.`, `-` of `_` (max. 50), bijv. `loonloket`, `afas`,
  `eigen-script`.
- `periode`: 1–12 bij maandverloning, 1–13 bij vierwekelijkse, 1–53 bij weekverloning.
- `datum`: boekdatum, meestal de laatste dag van de loonperiode; moet in een open boekjaar vallen.
- `omschrijving` weglaten geeft "Loonjournaal 09-2026 (loonloket)".
- Bedragen als string met punt als decimaalteken; debet en credit moeten in balans zijn.
  Max. 250 regels.

Antwoord `201`:

```json
{
  "aanlevering_id": 12, "bron": "loonloket", "jaar": 2026, "periode": 9,
  "journaal_id": 4711, "boekstuk": "MEM-2026-0031", "datum": "2026-09-30",
  "omschrijving": "Loonjournaal september 2026",
  "totaal_debet": "15830.00", "totaal_credit": "15830.00", "aantal_regels": 6,
  "aangemaakt_op": "2026-10-01T06:02:11Z"
}
```

Fouten (RFC 9457, `application/problem+json`; de machineleesbare `code` staat op topniveau, de
tekst in `fouten[].detail`):

| Status | `code`                     | Betekenis / actie                                                   |
|--------|----------------------------|---------------------------------------------------------------------|
| 409    | `loonperiode_al_geboekt`   | Deze periode staat al in FenoFin — behandel als **klaar**, niet als fout. |
| 422    | `niet_in_balans`           | Debet ≠ credit; controleer de telling.                              |
| 422    | `grootboek_niet_boekbaar`  | Rekening onbekend, inactief, niet direct boekbaar, of RGS-code dubbelzinnig (kandidaten in `detail`). |
| 422    | `boekjaar_afgesloten`      | Het boekjaar van `datum` is afgesloten.                              |
| 400    | `validatiefout`            | Payload ongeldig (bijv. `rgs` én `grootboeknummer` in één regel).    |
| 400    | `idempotency_key_ontbreekt`| Header `Idempotency-Key` ontbreekt.                                  |
| 400    | `ongeldige_json`           | De body is geen geldige JSON.                                        |
| 415    | `mediatype_niet_ondersteund` | Header `Content-Type: application/json` ontbreekt; de API accepteert alleen JSON. |
| 409    | `idempotency_key_hergebruikt` | Dezelfde `Idempotency-Key` met een andere body; gebruik per aanlevering een eigen key. |
| 403    | `scope_ontbreekt`          | De sleutel mist `lonen:write`.                                       |
| 403    | `live_schrijven_niet_vrijgegeven` | Live-sleutel zonder vrijgave voor schrijven (§6).             |
| 404    | `niet_gevonden`            | Administratie onbekend voor deze sleutel (test-sleutel op echte administratie of andersom). |

Een body die geen geldige JSON is of zonder JSON-header binnenkomt, wordt geweigerd vóór de
controle van sleutel, rechten en `Idempotency-Key`. Een geweigerd verzoek legt niets vast: de
gecorrigeerde herhaling mag dezelfde key gebruiken, tenzij die key al bij een eerder geslaagd
verzoek hoort (`idempotency_key_hergebruikt`).

## 5. Ontbrekende periodes bepalen

```
GET /api/v1/administraties/{administratie_id}/loonjournaal/?jaar=2026
```

Geeft per bron, jaar en periode het boekstuk terug, gepagineerd (`resultaten`, `volgende`).
Startpunt van een automatische run: lever alleen periodes aan die hier nog niet in staan.

## 6. Live

Maak een **live-sleutel** aan met dezelfde scopes, zet **live schrijven** voor die sleutel aan en
koppel uitsluitend de administraties waarvoor de koppeling mag boeken (een sleutel zonder
administraties heeft nergens toegang). Vervang `fenofin_test_…` door `fenofin_live_…`; de
aanroepen zijn verder identiek. Webhook `journaalpost.aangemaakt` vuurt ook voor
loonjournaal-aanleveringen, met een extra `loonjournaal`-blok (`bron`, `jaar`, `periode`).

## 7. Referentie-implementatie (Python)

Compleet en werkend; pas `MAPPING` en `lees_export` aan op de export van uw loonpakket.
Vereist Python 3.10+ en `pip install requests`. Sleutels en id's komen uit omgevingsvariabelen
— nooit in de code.

```python
#!/usr/bin/env python3
"""Loonjournaal → FenoFin: referentie-implementatie voor elk loonpakket.

Leest een export van het loonpakket (CSV: code;omschrijving;debet;credit), vertaalt de
pakketcodes naar RGS-codes en levert de post per loonperiode aan. Draai hem na iedere
verloning; herhalen is veilig (409 = periode al geboekt).

Gebruik:  FENOFIN_API_KEY=fenofin_test_… FENOFIN_ADMINISTRATIE_ID=123 \
          python loonjournaal.py 2026 9 2026-09-30 export-2026-09.csv
"""
import csv
import os
import sys
import uuid
from decimal import Decimal

import requests

BASE_URL = os.environ.get('FENOFIN_BASE_URL', 'https://www.fenofin.nl/api/v1')
API_KEY = os.environ['FENOFIN_API_KEY']                 # fenofin_test_… of fenofin_live_…
ADMINISTRATIE_ID = os.environ['FENOFIN_ADMINISTRATIE_ID']
BRON = os.environ.get('FENOFIN_BRON', 'loonloket')       # naam van uw loonpakket

# Pakketcode → RGS-code (§3). Vul aan met de rubrieken die uw pakket gebruikt.
MAPPING = {
    '4000': 'WPerLesLon',  # brutoloon
    '4100': 'WPerSolPsv',  # premies sociale verzekeringen werkgever
    '4200': 'WPerPenPen',  # pensioenpremies werkgever
    '2100': 'BSchSalNet',  # netto te betalen loon
    '1700': 'BSchBepLhe',  # af te dragen loonheffing
    '1900': 'BSchStz',     # af te dragen pensioen
    '1830': 'BSchSalTvg',  # reservering vakantiegeld
}

HEADERS = {'Authorization': f'Bearer {API_KEY}', 'Content-Type': 'application/json'}
LOONJOURNAAL = f'{BASE_URL}/administraties/{ADMINISTRATIE_ID}/loonjournaal/'


def geboekte_periodes(jaar: int) -> set[tuple[int, int]]:
    """Alle (jaar, periode) van deze bron die al in FenoFin staan (§5)."""
    url, periodes = f'{LOONJOURNAAL}?jaar={jaar}', set()
    while url:
        antwoord = requests.get(url, headers=HEADERS, timeout=30)
        antwoord.raise_for_status()
        data = antwoord.json()
        periodes.update(
            (a['jaar'], a['periode']) for a in data['resultaten'] if a['bron'] == BRON
        )
        url = data['volgende']
    return periodes


def lees_export(pad: str) -> list[dict]:
    """CSV met kolommen code;omschrijving;debet;credit → regels op RGS-code."""
    regels = []
    with open(pad, newline='', encoding='utf-8') as bestand:
        for rij in csv.DictReader(bestand, delimiter=';'):
            rgs = MAPPING.get(rij['code'].strip())
            if not rgs:
                sys.exit(f"Onbekende pakketcode {rij['code']!r} — vul MAPPING aan.")
            regels.append({
                'rgs': rgs,
                'omschrijving': (rij.get('omschrijving') or '').strip(),
                'debet': str(Decimal(rij.get('debet') or '0')),
                'credit': str(Decimal(rij.get('credit') or '0')),
            })
    return regels


def lever_aan(jaar: int, periode: int, datum: str, regels: list[dict]) -> None:
    payload = {'bron': BRON, 'jaar': jaar, 'periode': periode, 'datum': datum, 'regels': regels}
    antwoord = requests.post(
        LOONJOURNAAL, json=payload, timeout=60,
        headers={**HEADERS, 'Idempotency-Key': str(uuid.uuid4())},
    )
    if antwoord.status_code == 201:
        body = antwoord.json()
        print(f"Geboekt: {body['boekstuk']} — totaal {body['totaal_debet']}")
        return
    probleem = antwoord.json()
    if antwoord.status_code == 409 and probleem.get('code') == 'loonperiode_al_geboekt':
        print(f"Periode {periode}-{jaar} stond al in FenoFin — niets gedaan.")
        return
    details = '; '.join(f.get('detail', '') for f in probleem.get('fouten', []))
    sys.exit(f"FenoFin weigerde ({antwoord.status_code} {probleem.get('code')}): "
             f"{details or probleem.get('detail', '')}")


if __name__ == '__main__':
    if len(sys.argv) != 5:
        sys.exit('Gebruik: loonjournaal.py <jaar> <periode> <boekdatum JJJJ-MM-DD> <export.csv>')
    jaar, periode, datum, pad = int(sys.argv[1]), int(sys.argv[2]), sys.argv[3], sys.argv[4]
    if (jaar, periode) in geboekte_periodes(jaar):
        print(f'Periode {periode}-{jaar} is al geboekt.')
        sys.exit(0)
    lever_aan(jaar, periode, datum, lees_export(pad))
```

Testvolgorde: (1) draai tegen de sandbox met een test-sleutel, (2) controleer de post onder
Lonen → Verloningen en Boekhouding → Journaal, (3) draai hetzelfde commando nogmaals en zie de
409-melding, (4) wissel naar de live-sleutel (§6).
