gnubok

Transactions

Bank transactions — ingest, categorise, match to invoices, reconcile.

Endpoints


GET /api/v1/companies/:companyId/transactions {#get-transactions-list}

transactions.list · scope transactions:read

List transactions for a company.

Cursor-paginated transaction list ordered by created_at DESC, id ASC (newest-imported first; the date column is the transaction date and is filterable but not the sort key). Filter by ?status=booked|unbooked, ?currency, ?date_from / ?date_to, ?search (description ilike).

Use when: You need to walk a company's bank ledger — building a categorization queue, reconciling against external statements, or sampling for audit.

Don't use for: Looking up one transaction by id (use the detail endpoint). Reconciliation status (use /reconciliation/bank/status).

Pitfalls

  • Default page size is 50. Pass ?limit=100 for the maximum. Cursor pagination — pass ?cursor=<next_cursor> from the previous response.
  • A booked transaction has a non-null journal_entry_id. is_business / category live on the transaction row even before booking.
  • reverse-charge or storno entries can leave a transaction with journal_entry_id pointing at a cancelled JE — check status on the JE separately.

Risk: low · Idempotent: yes · Reversible: no · Dry-run supported: no

Example response

{
  "data": [
    {
      "id": "a8f1…",
      "date": "2026-05-12",
      "description": "ICA MAXI",
      "amount": -349.5,
      "currency": "SEK",
      "merchant_name": "ICA MAXI",
      "journal_entry_id": null,
      "is_business": null,
      "category": null
    }
  ],
  "meta": {
    "request_id": "req_…",
    "api_version": "2026-05-12",
    "next_cursor": null
  }
}

GET /api/v1/companies/:companyId/transactions/:id {#get-transactions-get}

transactions.get · scope transactions:read

Retrieve a single transaction by id.

Returns the full transaction record including match state, booking state, and import metadata.

Use when: You have a transaction id (from the list or a webhook) and need the full record before deciding to categorize, match, or attach a document.

Don't use for: Walking the ledger — use the list endpoint with a cursor. Fetching the linked invoice/journal entry — separate endpoints.

Pitfalls

  • Both invoice_id (matched) and potential_invoice_id (suggested) can be set independently. The matched id is authoritative for accounting.
  • reconciliation_method is null for transactions that have never been auto-reconciled. journal_entry_id may still be set via manual categorize.

Risk: low · Idempotent: yes · Reversible: no · Dry-run supported: no

Example response

{
  "data": {
    "id": "a8f1…",
    "date": "2026-05-12",
    "amount": -349.5,
    "currency": "SEK",
    "journal_entry_id": null,
    "is_business": null
  },
  "meta": {
    "request_id": "req_…",
    "api_version": "2026-05-12"
  }
}

POST /api/v1/companies/:companyId/transactions/:id/categorize {#post-transactions-categorize}

transactions.categorize · scope transactions:write

Categorize a transaction and create the journal entry.

Resolves the BAS account mapping for the transaction (via category, booking template, or counterparty template), creates the corresponding verifikation, and updates the transaction with is_business / category / journal_entry_id. Idempotent on (transaction, key). Dry-runnable.

Use when: You're categorizing a bank transaction. Pass is_business: true plus either category, template_id (booking template), counterparty_template_id, or account_override. For private transactions, is_business: false is enough.

Don't use for: Matching a payment to an invoice — use :match-invoice or :match-supplier-invoice, which storno any conflicting JE first. Uncategorizing — :uncategorize.

Pitfalls

  • A bank payment that looks like an invoice payment will be flagged via TX_CATEGORIZE_SUGGEST_SI_MATCH — pass confirm_no_match: true to override and force-categorize as direct expense (e.g. when the supplier invoice was already booked).
  • Already-categorized fast path: if the transaction already has a journal_entry_id, only flags get updated. The JE is immutable post-commit.
  • account_override must exist in the chart of accounts; an unknown account returns TX_CATEGORIZE_INVALID_ACCOUNT.

Risk: medium · Idempotent: yes · Reversible: yes · Dry-run supported: yes

Example request

{
  "is_business": true,
  "category": "expense_office"
}

Example response

{
  "data": {
    "success": true,
    "journal_entry_created": true,
    "journal_entry_id": "je_…",
    "category": "expense_office"
  },
  "meta": {
    "request_id": "req_…",
    "api_version": "2026-05-12"
  }
}

POST /api/v1/companies/:companyId/transactions/:id/match-invoice {#post-transactions-match-invoice}

transactions.match-invoice · scope transactions:write

Match a positive bank transaction to a customer invoice.

Confirms an invoice match for a transaction. Storno any conflicting auto-categorization JE, create the payment journal entry, update the invoice status (paid / partially_paid), insert into invoice_payments, and link the transaction. Idempotent.

Use when: You have a bank receipt and a known open invoice it pays. The transaction must be positive (income) and unlinked.

Don't use for: Categorizing a transaction without an invoice — use :categorize. Matching to a supplier invoice — use :match-supplier-invoice. Bulk auto-match — use POST /reconciliation/bank/run.

Pitfalls

  • Proforma + delivery notes are rejected (MATCH_INVOICE_NOT_INVOICE_TYPE) — only document_type='invoice' can be matched.
  • Transaction must be positive (amount > 0) — negative transactions return MATCH_INVOICE_NOT_INCOME.
  • Invoice must be in sent / overdue / partially_paid status — paid or draft invoices return MATCH_INVOICE_NOT_OPEN.
  • Idempotency-Key is mandatory.

Risk: high · Idempotent: yes · Reversible: no · Dry-run supported: no

Example request

{
  "invoice_id": "inv_…"
}

Example response

{
  "data": {
    "success": true,
    "invoice_status": "paid",
    "paid_amount": 12500,
    "remaining_amount": 0,
    "journal_entry_id": "je_…",
    "category": null
  },
  "meta": {
    "request_id": "req_…",
    "api_version": "2026-05-12"
  }
}

POST /api/v1/companies/:companyId/transactions/:id/match-supplier-invoice {#post-transactions-match-supplier-invoice}

transactions.match-supplier-invoice · scope transactions:write

Match a negative bank transaction to a supplier invoice.

Confirms a supplier invoice payment match. Creates the payment journal entry (accrual: 2440 debit / 1930 credit; cash-method: collapsed registration+payment), updates supplier_invoices, inserts a supplier_invoice_payments row, and links the transaction. Handles FX differences for cross-currency payments (7960 gain / 3960 loss).

Use when: You have a bank payment and a known open supplier invoice. The transaction must be negative (expense) and unlinked.

Don't use for: Categorizing a direct supplier expense without an invoice — use :categorize. Matching to a customer invoice — use :match-invoice. Bulk auto-match — POST /reconciliation/bank/run.

Pitfalls

  • Cash-method companies cannot match across currencies (MATCH_SI_CASH_FX_UNSUPPORTED) — switch to accrual or book FX manually.
  • Transaction must be negative (amount < 0). Positive returns MATCH_SI_NOT_EXPENSE.
  • Supplier invoice must NOT be paid/credited already. paid/credited returns MATCH_SI_ALREADY_PAID; registered/approved/partially_paid/overdue are matchable.
  • Idempotency-Key is mandatory.

Risk: high · Idempotent: yes · Reversible: no · Dry-run supported: no

Example request

{
  "supplier_invoice_id": "si_…"
}

Example response

{
  "data": {
    "success": true,
    "invoice_status": "paid",
    "paid_amount": 5000,
    "remaining_amount": 0,
    "journal_entry_id": "je_…"
  },
  "meta": {
    "request_id": "req_…",
    "api_version": "2026-05-12"
  }
}

POST /api/v1/companies/:companyId/transactions/:id/uncategorize {#post-transactions-uncategorize}

transactions.uncategorize · scope transactions:write

Reverse the categorization of a transaction (storno + reset).

Storno the transaction's journal entry (BFL 5 kap 5 §: posted entries are never deleted, only cancelled via a reversing entry) and reset is_business / category / journal_entry_id on the transaction row. Idempotent — a second call on an already-uncategorized transaction returns 400 TX_UNCATEGORIZE_NOT_BOOKED. Dry-runnable.

Use when: You categorized a transaction by mistake and want to redo it from scratch. The storno keeps the audit trail intact.

Don't use for: Changing the categorization of an already-booked transaction — categorize again instead (the second call sees journal_entry_id and only updates flags). Reversing a payment match — there is no v1 verb for that yet.

Pitfalls

  • Idempotency-Key is mandatory.
  • The storno creates a new (cancelling) journal entry. The original entry stays in the ledger marked as cancelled — voucher gaps are documented automatically.
  • A transaction without a journal_entry_id returns 400 TX_UNCATEGORIZE_NOT_BOOKED — there is nothing to reverse.

Risk: medium · Idempotent: yes · Reversible: no · Dry-run supported: yes

Example response

{
  "data": {
    "success": true,
    "reversed_journal_entry_id": "je_…"
  },
  "meta": {
    "request_id": "req_…",
    "api_version": "2026-05-12"
  }
}

POST /api/v1/companies/:companyId/transactions/batch-categorize {#post-transactions-batch-categorize}

transactions.batch-categorize · scope transactions:write

Categorize up to 100 transactions in one call (partial-success).

Per-item categorization mirroring the single :categorize endpoint. Same { results, summary } shape as the other bulk endpoints. all_or_nothing: true returns 501 NOT_IMPLEMENTED. Idempotent over the whole batch.

Use when: You have many transactions to categorize with the same logic (e.g. apply a booking template across a queue, mark a batch as private, override accounts on a series).

Don't use for: Categorizing transactions with mixed logic — make multiple :categorize calls. Auto-categorization via templates — handled inside ingest for matching rows, no separate endpoint needed.

Pitfalls

  • Max 100 items per call. Sequential processing.
  • Idempotency-Key covers the WHOLE batch — replays return the cached full response.
  • all_or_nothing: true returns 501 NOT_IMPLEMENTED. Today only partial-success batches exist.

Risk: medium · Idempotent: yes · Reversible: yes · Dry-run supported: yes

Example request

{
  "items": [
    {
      "transaction_id": "tx_1",
      "categorization": {
        "is_business": true,
        "category": "expense_office"
      }
    }
  ]
}

Example response

{
  "data": {
    "results": [
      {
        "ok": true,
        "request_index": 0,
        "transaction_id": "tx_1",
        "data": {
          "journal_entry_id": "je_…"
        }
      }
    ],
    "summary": {
      "total": 1,
      "succeeded": 1,
      "failed": 0
    }
  },
  "meta": {
    "request_id": "req_…",
    "api_version": "2026-05-12"
  }
}

POST /api/v1/companies/:companyId/transactions/ingest {#post-transactions-ingest}

transactions.ingest · scope transactions:write

Bulk-ingest transactions (up to 500 per call).

Runs the same ingest pipeline as the dashboard CSV importer and the PSD2 bank sync: dedup, insert, invoice match, mapping-rule auto-categorize, auto-JE for high-confidence matches. Idempotent over the whole batch via Idempotency-Key. Dry-runnable.

Use when: You're importing transactions from a CSV, a custom bank feed, or an external accounting system. Each item must have a stable external_id — this is the primary dedup key.

Don't use for: Single ad-hoc transactions (use the dashboard). Documents/receipts (use the documents endpoint). Manually-created journal entries (Phase 4).

Pitfalls

  • external_id is the primary dedup key — make it stable for the same physical transaction across reruns.
  • Content-based dedup (date+amount) runs in addition: a CSV row that matches an already-booked transaction by date+amount is skipped even if external_id differs.
  • raw_insert_only=true skips ALL post-insert pipeline steps (matching, categorization). Use for viewer-only imports.
  • Max 500 items per call. For larger imports, split into pages of 500.
  • Dry-run runs both dedup checks (external_id AND content-based date+amount against booked rows), matching the live pipeline. Numbers should agree barring concurrent imports between preview and commit.

Risk: medium · Idempotent: yes · Reversible: no · Dry-run supported: yes

Example request

{
  "transactions": [
    {
      "date": "2026-05-12",
      "description": "ICA MAXI",
      "amount": -349.5,
      "currency": "SEK",
      "external_id": "csv-line-42",
      "merchant_name": "ICA MAXI"
    }
  ]
}

Example response

{
  "data": {
    "imported": 1,
    "skipped_duplicates": 0
  },
  "meta": {
    "request_id": "req_…",
    "api_version": "2026-05-12"
  }
}