# Jubeljahr — instance API for agents

You are reading the machine-facing manual for a **Jubeljahr instance** — a
single-tenant pre-accounting system (receipts in, DATEV/CSV out, data deleted
after handover). Each customer runs their own isolated instance; there is no
shared tenancy and no cross-customer data.

Canonical copy of this file: `https://www.jubeljahr.com/agents.md`
Human context: `https://www.jubeljahr.com/wiki/agenten-schnittstelle`
Instances update automatically from one release channel; this manual tracks
the current release.

**Prefer MCP if your runtime supports it**: connect to
`https://mcp.jubeljahr.com/i/<instance>/mcp` (the instance is the customer's
subdomain) with the same `Authorization: Bearer jj_…` header. You get five
tools — `read_manual` (this file), `api_request`, `upload_receipt`,
`upload_statement`, `download_export` — backed by exactly the API
described below. The upload tools set the file's MIME type from its
extension; `api_request` carries JSON only, so use the upload tools for
anything binary.

## Authentication

- The customer creates an API key in their instance under
  **Einstellungen → Zugang → Programmschnittstelle** and hands it to you.
- Send it on every request: `Authorization: Bearer jj_…`
- Base URL is the customer's instance, e.g. `https://<name>.jubeljahr.com/api`.
- The key acts as the instance owner and passes through the exact same
  server-side guards as the human UI. It is **blocked** (HTTP 403,
  `code: "fach-token-bereich"`) from access management: passkeys,
  invitations, admin access, update holds, and API-key management itself.
- **Releasing is a separate permission.** By default a key prepares —
  sets up, delivers, categorises — and the release in the review stays
  with the human. Only a key the customer created with "Darf auch
  freigeben" ticked may release: without it, `POST
  /api/transactions/mass-sync` and `PUT /api/transactions/:documentId`
  with `syncStatus: "synced"` answer HTTP 403, `code:
  "fach-token-freigabe"`. That is the customer's decision, made once per
  key and fixed afterwards — do not work around it; tell them what you
  prepared and that releasing is theirs unless they hand you a key that
  may. Every booking a key releases carries the key's name
  (`freigegebenDurch`), visible in the review and kept after export.
- An unknown or revoked key gets HTTP 401 (`code: "fach-token-unbekannt"`).

## Conventions

- Standard Strapi v5 content API: collection endpoints wrap payloads in
  `{ "data": … }`, records are addressed by `documentId`.
- Custom endpoints (listed below) use plain JSON bodies without the wrapper.
- Error messages are English and often carry a stable `code`; user-facing
  translation happens in the UI, not here.
- The system **fails instead of guessing**: invalid settings block the
  export, unreadable receipts are rejected rather than half-booked, and a
  released booking is immutable until reopened. Expect and respect refusals.
- `GET /api/version` tells you which build this instance runs
  (`{ "version": "0.1.0+440", "quelle": "artefakt" | "git" | "paket",
  "stand": <ISO time or null> }`). Instances update themselves nightly;
  mention the version when you report a problem.

## Workflow 1 — set up the instance

Everything a tax office letter typically dictates:

- `GET/PUT /api/tax-setting` — chart of accounts (`skr`: `skr03`|`skr04`),
  VAT period, deletion windows, default bank account numbers. Also the
  company's master data, all optional: `firmenname`, `anschrift`
  (multi-line), `steuernummer` (digits and slashes, 10–13 digits),
  `ustIdNr` (`DE` + 9 digits), `finanzamt`, `gewinnermittlung`
  (`euer`|`bilanz`), `kleinunternehmer` (boolean). A malformed number
  answers `400` with `code` `ustidnr-format` or `steuernummer-format`;
  nothing here is required for an export.
- `GET/POST/PUT /api/konten`, `DELETE /api/konten/:documentId` — the money
  locations (bank, cash, PSP, private). Deletion is guarded: system
  accounts, connection-fed accounts and accounts with bookings refuse with
  a `code` (`konto-grundausstattung`, `konto-gespeist`, `konto-buchungen`).
  `POST /api/konten/:documentId/verschiebe` `{ "ziel": "<documentId>" }`
  moves open bookings to another account (exported ones stay).
- `GET/PUT /api/export-ziele` — export targets, each with `aktiv`. The
  file exists on every export: with the DATEV target active it is the
  DATEV batch and needs `einstellungen.consultantNumber` (1001–9999999)
  and `einstellungen.clientNumber` (1–99999), optional
  `einstellungen.fiscalYearStart` (`"01.07."`) for a diverging fiscal
  year; with DATEV off it is a plain CSV. Invalid values on an active
  target block the export with a named field error.
  Transfer targets are additional and parallel. The Collmex target
  (`art: "collmex"`) needs `einstellungen.kundennummer`,
  `einstellungen.firmaNr` (usually `1`), `einstellungen.benutzer` (a
  Collmex user flagged "Nur für API") and a `passwort` sent once in the
  same `PUT` body — the server tries it against Collmex before storing it
  encrypted, and answers `409 zugang-abgelehnt` if Collmex refuses;
  afterwards only `zugangHinweis` (last characters) is visible. Optional
  `einstellungen.kontenrahmen` (`skr03`/`skr04`) when the Collmex company
  runs a different chart than the instance. Optional
  `einstellungen.belegPostfach`: the mailbox address Collmex reads in its
  Belegverwaltung — when set, the instance mails each booking's receipts
  there after an accepted transfer (see Workflow 4).
  `POST /api/export-ziele/:documentId/verbindung` checks the stored
  credentials without writing anything.
- `GET /api/beleg-regeln`, `PUT /api/beleg-regeln/:kennung`
  `{ "aktiv": true, "wirkung": "markieren"|"abweisen", "parameter": {…} }` —
  the customer's own receipt rules, a fixed catalogue (`e-rechnung-erwartet`,
  `rechnungsnummer-pflicht`, `eigene-ustidnr-pflicht`,
  `lieferant-ustidnr-pflicht`, `betrag-ueber`, `fremdwaehrung`, `beleg-alt`,
  `lesung-unsicher`, `unbekannter-lieferant`, `e-rechnung-gueltig`,
  `ohne-beleg-ueber`, `beleg-fehlt-seit` — the last one runs on a daily
  timer over open bank rows), everything off by default. `GET` lists each rule with
  its `anlaesse`, its `felder` (the input fields: name, `typ` zahl|datum,
  `pflicht`, range), the customer's current `parameter` values and
  `darfAbweisen`. A rule that *marks* adds a note to the booking
  (`hinweise: [{ kennung, text, am }]`) — visible in the review, never a
  lock. A rule that *refuses* (only where `darfAbweisen` and only on
  structured data: e-invoices and bank rows) stops a receipt before it is
  booked: the receipt job answers `ok: false` with an issue
  `{ "kind": "regel", "regel": "<kennung>", "detail": "<German text>" }`.
  Rules for **one partner only**: `POST /api/beleg-regeln/lieferant`
  `{ "partnerName", "kennung", "wirkung", "parameter" }` (matched on the
  normalised partner key, so spelling variants are the same partner;
  `DELETE /api/beleg-regeln/lieferant/:documentId` removes one; `GET`
  lists them under `lieferanten`). Set rules only when the customer asked
  for them; do not switch one on to get past a refusal.
- `GET /api/categories` — the account categories, each with SKR03/SKR04
  numbers, tax rate, optional BU key. `POST` and `PUT` are open too: when
  the customer's tax office prescribes its own accounts, create or adjust
  categories to match. The server only checks what a DATEV export would
  refuse later — account numbers must be 4–8 digits, the BU key numeric,
  the tax rate one of 0, 7, 19 — and answers a named validation error
  otherwise. Which number is right is the tax office's call, not the
  server's. `DELETE` removes a self-created category — refused for the
  standard set (`standard: true`) and for categories that bookings still
  point to; the learned rules of a deleted category vanish with it.
- `GET /api/buchungs-regeln?suche=&seite=` — the learned partner → category
  rules (what the instance derived from the customer's choices), 20 per
  page, searchable by partner, keyword and category name; each row carries
  `partnerName`, `stichwort` (a keyword in the booking text, or null),
  `kategorie`, `state` (`active` | `ambiguous` = suspended), `origin`
  (`learned` | `manual`), `treffer`. `POST` `{ "partnerName", "kategorie",
  "stichwort"? }` creates one by partner name (spelling variants fold onto
  the same key; an identical rule gets the new category instead of a
  twin); `PUT /:documentId` changes `kategorie`, `stichwort` or `state`;
  `DELETE /:documentId` removes it. Touching a rule re-activates a
  suspended one unless `state` is sent explicitly.

- **Kontist** (a bank with its own API): the customer creates an OAuth
  client at kontist.dev (redirect URI `<instance>/api/import-quellen/kontist/rueckkehr`,
  scopes transactions, accounts, offline) and enters client id and secret in
  the Kontist card. `POST /api/import-quellen/kontist/verbinden`
  `{ "clientId", "secret", "name"?, "abrufAb"? }` answers with the Kontist
  URL the customer must open — hand it over, never open it yourself. Then
  like a bank source: `verbindung`, `POST /:id/abgleich`, `POST /:id/erneuern`;
  fetched lines carry `source: "kontist"`.

## Workflow 2 — deliver documents

- `POST /api/transactions/receipts/upload` — multipart, field name `file`;
  images (JPEG, PNG, HEIC, WebP, TIFF), PDFs, and **e-invoices**
  (ZUGFeRD/Factur-X PDFs, bare XRechnung XML in CII or UBL). Returns
  `{ "jobId": …, "zustand": "wartet" }` (HTTP 202).
  **Prefer e-invoices whenever the customer has them**: their figures are
  taken from the structured XML exactly (result `herkunft: "e-rechnung"`),
  with no OCR and no model involved — only a cross-check that gross equals
  net plus tax, which fails loudly instead of booking silently.
  After booking, an e-invoice is checked in the background against the
  EN 16931 rule set (schema, business rules, profile; for ZUGFeRD also the
  PDF/A-3 shell). The verdict lands on the booking as `eRechnung`
  `{ gueltig, art: "zugferd"|"xrechnung"|"keine-e-rechnung", profil,
  version, meldungen: [{ schwere: "fehler"|"hinweis", code, text }],
  geprueftAm }` — usually within seconds, never as a guard: the booking
  stays as it is. Read it as what the rule set says about the *issuer's*
  document, not as a tax judgement; an invalid verdict is something to
  tell the customer, not something to fix in the booking. A PDF without
  embedded XML is `keine-e-rechnung`, not invalid.
- Poll `GET /api/jobs/aktuell` for running/finished jobs; a finished job's
  `ergebnis` carries the booking (`ok: true`) or the named `issues`
  (`ok: false`) explaining why nothing was booked — including a customer
  rule that refused (`kind: "regel"`). `POST /api/jobs/verwerfen`
  clears the finished list.
- Bank statements: `POST /api/transactions/import/erkenne` (what is this
  file? — reads PDFs, CSVs and XML alike), `…/preview` (parse, nothing
  written), `…/commit` (write, after the preview was acceptable). All
  three accept multipart (`file`) — or JSON `{ "content": "<csv text>",
  "filename": … }` for UTF-8 CSVs. Over MCP, `upload_statement` does
  preview and commit in one call and returns the job id. Duplicates are
  recognised by content hash — re-sending a file does not double
  anything.

## Workflow 3 — review and categorise

- `GET /api/transactions` (filter by `syncStatus=open`) — the review queue.
- **Match receipts to bank rows.** An uploaded receipt and an imported
  statement row describe the same event twice. `GET
  /api/transactions/match/vorschlaege` lists candidate pairs (exact
  amount, same direction, receipt dated up to 30 days before the
  payment); confirm one with `POST /api/transactions/:documentId/match`
  `{ "belegDocumentId": … }` on the **bank** row — the receipt entry
  dissolves into it, its file and readable partner name travel along.
  The bank amount always wins; a differing receipt amount (tip,
  cash discount) is allowed and reported back as `abweichung` — check it,
  never hide it. Structural mismatches are refused with stable codes
  (`match-richtung`, `match-waehrung`). `POST …/unmatch` restores the
  open receipt entry losslessly while the booking is still open. Never
  merge on amount equality alone — two identical amounts in one month
  are common; confirm against partner and date.
- `POST /api/transactions/:documentId/categorize`
  `{ "categoryDocumentId": "<documentId>" }` — assigns the category **and
  teaches the instance**: the partner→category rule is learned and applied
  to future bookings, so your first pass makes yourself less necessary.
- `POST /api/transactions/auto-sort` — apply the learned rules in bulk.
- Core `PUT /api/transactions/:documentId` — fix date, partner, purpose,
  account (`konto`), EUR value for foreign-currency bookings.

## Workflow 4 — release and export

- Releasing a booking sets `syncStatus` to `synced`; a booking without a
  category refuses. `POST /api/transactions/mass-sync` releases every
  categorised open booking at once. Both need a key that may release (see
  Authentication); otherwise stop here and report what is ready for the
  customer to release.
- `POST /api/tax-exports/run` — build the export for the current period;
  `GET /api/tax-exports/periods` lists periods,
  `GET /api/tax-exports/:documentId/download` fetches the file (DATEV EXTF
  ZIP with document images, or CSV, depending on whether DATEV is active),
  `POST /api/tax-exports/:documentId/confirm` confirms handover to the tax
  office — which starts the deletion clock. This system is a corridor, not
  an archive: confirm only what the customer's tax office truly received.
- `GET /api/ausdrucke/summen-salden?von=YYYY-MM-DD&bis=YYYY-MM-DD`,
  `GET /api/ausdrucke/journal?von=&bis=`,
  `GET /api/ausdrucke/ustva?period=2026-07` (or `2026-Q3`) — printouts as
  `application/pdf`: trial balance (per account debit, credit, balance),
  journal (one line per booking), and the VAT-return figures (Kz 81, 86,
  43, 66; Kz 48, 89, 46 left empty and marked as not fillable). Numbers
  without judgement, the sentence saying so on every page; test data and
  foreign-currency bookings without a euro value are excluded and named.
  `ustva` answers `409 kleinunternehmer` when the master data says § 19
  UStG — no return to file. No ELSTER transmission; the sheet is for
  typing into ELSTER-Online, and ELSTER computes the balance itself.
  Every sheet also names how many bookings in the range are still open
  (not released) — those figures may still change.
- `GET /api/ausdrucke/dauerfrist?jahr=2026` — basis for the permanent
  filing extension: last year's Kz 81/86/66 from what the instance still
  holds, the computed prepayments and, for monthly filers, one eleventh
  as the special prepayment (Kz 38). `GET /api/ausdrucke/euer?jahr=2025`
  — the Anlage EÜR pre-filled by form line name (no line numbers), net
  with VAT lines separated, gaps for depreciation and private shares left
  empty and marked. `GET /api/ausdrucke/verfahrensdokumentation` — the
  GoBD procedure documentation generated from this instance's actual
  setup, with a final section the customer fills in. All three are PDFs.
- `POST /api/tax-exports/:documentId/uebertrage`
  `{ "zielDocumentId": "<collmex target>", "probelauf": true }` — the
  transfer of a finished batch to an active transfer target. Run it with
  `probelauf: true` first: it returns booking count, total, the first
  payload lines and whether the credentials work, and writes nothing.
  Then without `probelauf`: every booking goes to Collmex as `CMXUMS`
  (income) or `CMXLRN` (expense) against the booking's bank account,
  keyed by `JJ-<documentId>` so a repeated transfer never duplicates. The
  upload is all-or-nothing; a refusal comes back as
  `409 uebertragung-abgelehnt` naming the booking, and the batch stays as
  it was. On success the batch carries `uebertragungen.collmex`. The
  downloadable file is unchanged by this — receipts stay in the batch
  package, because the Collmex API has no upload path for document images.
  Collmex takes receipts only through its Belegverwaltung mailbox: when
  the target has `einstellungen.belegPostfach`, the instance mails one
  message per booking with a readable receipt (PDF, JPEG, PNG, TIFF, XML;
  HEIC and WebP are converted to JPEG first, the stored original is
  untouched) to that address after acceptance, invoice number `JJ-<documentId>` in
  the body, so Collmex offers "Rechnung der Buchung zuordnen". Each
  booking's receipts go once (`uebertragungen.collmex.belegeVersandt`);
  a repeated transfer sends only what is missing. The response's `belege`
  reports `postfach`, `geplant` (dry run), `versandt`, `uebersprungen`,
  `fehlgeschlagen` and a `fehler` text. The customer still clicks the
  assignment in Collmex — that step has no API.

## What you must not do

- Do not try to manage access (passkeys, invitations, admin, API keys) —
  those paths are blocked for you by design, not by omission.
- Do not work around refusals. A named error is the system telling you what
  the customer must decide or fix; surface it, don't bury it.
- Tax judgement stays human: which category, which SKR number, which BU key
  applies is the customer's and their tax office's call (§ 5 StBerG). You
  arrange data; you do not give tax advice on Jubeljahr's behalf.
