Finance & SaaS SpendFinance Operations
On this page0

Reference

Included in: Advanced. Which Plan Includes What

Finance Operations

How a bill moves through Finance on your instance: reading and human review, matching to purchase orders, approval, segregation of duties, budgets and their own approval, the general ledger and month-end close, and the journal export for an outside accounting system. The flow ends when you mark a bill Paid. Zonitix does not pay bills and nothing here moves money.

What It Does

Finance makes sure that raising, approving and paying a spend cannot all be done by one person, and that spend that breaches a budget cannot be approved silently. Two things are worth being precise about, because they are easy to overstate.

Separation Of Duties Is Real, But Toggleable

By default the approver must differ from the uploader and submitter, and the payer must differ from every approver, the uploader and the submitter. The Enforce segregation of duties switch in Finance → Settings can turn that off, and only someone whose role may change security settings can move it — except at or above the high-value floor (default 25,000 in base currency), where these checks are always enforced no matter what the switch says.

Budgets Block, They Don't Just Colour

An approval the budget cannot cover is refused. It can still proceed, but only when someone with the invoice-approval permission chooses to override and writes a reason — and that override is written to the audit trail as an over-budget approval, so it is visible after the fact. A frozen budget is a harder stop: no override gets past it.

If something here is not working as you expect, start with When something goes wrong.

The plain explanation is above. This part is for IT and developers: the exact settings, permission names, API addresses and data fields behind it. Closed: select the heading above to open it. While it is closed, your browser’s find-on-page (Ctrl+F or Cmd+F) does not search this part.

The Invoice State Machine

Every recorded invoice is in one of six states (NeedsReview, Draft, Submitted, Approved, Rejected, Paid) and moves between them by five transitions. Each transition is a single endpoint call, carries its own permission gate, and is written to the append-only finance audit trail. There is no manual shortcut to Approved or Paid — the legacy status endpoint rejects those targets with a 403.

clear_review

NeedsReview → Draft

submit

Draft / Rejected → Submitted

approve

Submitted → Approved

reject

Submitted → Rejected

pay

Approved → Paid

Where These Endpoints Live

Everything below is served by the document-parser service (Python, FastAPI) on your instance and reached through your instance gateway under /api/v1/parser. The tables on this page write the shorter alias /api/parser, which reaches the same routes. The gateway authenticates the caller and injects the verified user (roles + permissions) that the finance service reads. The examples write your instance’s API address as https://<your-instance>-api.zonitix.com, the same placeholder the API reference uses.

Finance is part of the Advanced plan, and the service enforces that itself. /parse, /stats, and every path starting /invoices, /my-documents or /finance return 403 when the instance’s plan does not include Finance, and 402 once a subscription has lapsed. On a plan without Finance the hourly budget-alert sweep and the scheduled finance digest stop as well. The check is deliberately lenient: access is allowed while the plan has not yet been synced to the instance, or when the licence record cannot be read.

The Finance Workspace, Tab By Tab

In the app, almost everything on this page is one screen: the Finance workspace, opened from Finance → Finance in the sidebar by anyone holding view_finance or manage_finance. It has fifteen tabs, and a tab is shown only to people whose role includes a permission it needs. The sidebar’s Bills, Approvals and Vendors items are shortcuts to those tabs. Budgets opens the budgets screen, which a department manager holding only the budget permissions can also reach. An employee whose role includes only submit_invoice does not get the workspace: they upload from My Work → Submit Invoice and see only their own bills.

TabWhat it is forShown toDocumented under
OverviewSpend figures and charts for the whole ledger.everyone who can open the workspaceTrends, Open Commitments & SaaS Spend
BillsUpload bills and see what was read from each.manage_financeExtraction & The Human Review Gate
ReviewBills held for review, with the original beside the fields that were read.manage_financeExtraction & The Human Review Gate
LedgerEvery invoice, with its approval actions.everyone who can open the workspaceInvoices: List, Detail & Self-Service
MatchThe purchase-order register and the match exceptions that block approval.manage_finance2-/3-Way PO Matching Blocks Approval
ApprovalsThe queue of submitted bills waiting for an approver.manage_finance · approve_invoice · pay_invoiceThe Workflow Endpoint & Who May Do Each
TrendsSpend over time.everyone who can open the workspaceTrends, Open Commitments & SaaS Spend
Budgets & ChargebackBudgets against actuals, budget approvals, chargeback, spend requests and budget transfers.everyone who can open the workspaceBudgets (CRUD) and the sections after it
SystemsThe systems whose cost is tracked.everyone who can open the workspaceSystems & Cloud Cost Against A Budget
SaaS SpendSpend per application.everyone who can open the workspaceTrends, Open Commitments & SaaS Spend
VendorsThe vendor list: edit a vendor, or merge one vendor into another.manage_financeVendors & Vendor Merge
CloseThe month-end checklist, the period lock, the journal export and adjusting entries.manage_finance · close_financeGeneral Ledger, Month-End Close & Journal Export
AI InsightsAsk a question about your spend in plain words.everyone who can open the workspaceAsk The Ledger
AuditThe finance audit trail.everyone who can open the workspaceAudit Trail & Digest
SettingsFinance settings, exchange rates and approval rules.manage_finance · manage_finance_policyFinance Settings; Base Currency & FX Recompute; Configurable Approval Rules

Before The Ladder — Extraction & The Human Review Gate

An uploaded invoice is read on your instance by a heuristic extractor, optionally refined by a local LLM. The refinement runs only when the heuristic’s confidence is below 0.8, and it is limited in what it may change: a total is accepted only when the digits of its whole-number part appear in the source text, a tax amount needs the same check, must be below the total and is taken only when the heuristic found no tax, and an invoice number is accepted only when it appears in the text and the heuristic found none. A vendor name is accepted without a text check, but only when the heuristic found no vendor at all. Confidence is raised (to at most 0.9) only when the total itself was re-read, so a vendor-, tax- or number-only refinement never lifts an invoice out of review. The pass is controlled by the finance.llm_extraction setting (default on) and does not run when local AI is disabled on the instance (ENABLE_LOCAL_AI). Nothing about the invoice leaves your instance. The reading itself is described step by step under Reading Bills (OCR Extraction).

The upload screens accept PDF, DOCX, PNG and JPG files up to 10 MB. The API route behind them (POST /api/parser/parse, gate: manage_finance or submit_invoice) additionally accepts .xlsx and .txt; the older .xls format is refused. A byte-identical copy of a file already on the instance is refused with 409 before any reading happens. Spreadsheets of many invoices go through the CSV bulk import described under Bulk Operations, Import & Export below.

Bills from a connected AWS account can be pulled in without uploading them by hand. POST /api/auth/aws-resources/{providerId}/sync-invoices (gate: manage_compliance) fetches the account’s invoice PDFs for the last six complete months and passes each one through the same parse route, so they are read, held or drafted, and approved exactly like an uploaded bill. An invoice that is already stored (the same file, or the same vendor and invoice number) is skipped, so running the sync again does not duplicate anything. The run is written to the audit log as CLOUD_INVOICES_SYNCED, on success and on failure. The matching Azure route reads cost data only and creates no invoices.

Extraction produces a confidence score. A clean read lands in Draft and is submittable. The document is instead held in NeedsReview, and kept out of committed spend, when any of five things is true: the confidence is below the review floor (the finance.review_confidence_floor setting, default 0.80); the amount’s thousands/decimal separator is ambiguous; the invoice date is missing or its day/month order is ambiguous (with no date, the upload date is stored as a placeholder); there is no exchange rate for the invoice’s currency; or it looks like a duplicate of an existing invoice (same vendor, amount and date). submit refuses that state, so a held invoice cannot be paid without a human first confirming it.

The exit is PATCH /api/parser/finance/documents/{id}/review (gate: manage_finance or submit_invoice). Each field change is audited before→after and the base amount is recomputed. A manage_finance reviewer sending { "commit": true } releases a NeedsReview document to Draft; the release returns 409 while an ambiguous amount or a flagged invoice date is unconfirmed (the reviewer clears each flag by saving that field in the same call or an earlier one) and 400 while the currency still has no rate. A submit_invoice-only user may correct an invoice they uploaded themselves, but can never release it — any change they make sends it (back) to NeedsReview for finance to confirm, and someone else’s invoice returns 403. A reviewer can also clear the hold without editing fields by calling the workflow endpoint with the clear_review action (manage_finance), which applies the same gates: 409 for an unconfirmed amount or date, 400 for a currency with no rate.

Duplicate-Payment Block At Submit

submit refuses an invoice whose vendor + invoice number is already recorded on another copy, returning 409 and naming the existing copy and its status. A true duplicate is stopped on one copy before the other can proceed, so the same bill cannot be paid twice. The check is fail-open on error (a duplicate-check bug must never wedge a legitimate submit), so a missing block is not proof no duplicate existed.

The Workflow Endpoint & Who May Do Each

One endpoint drives the five transitions: POST /api/parser/finance/documents/{id}/{action} where action is one of submit, approve, reject, pay, clear_review. An unknown action returns 404; a missing document returns 404; an illegal transition (e.g. paying a Draft) returns 400. On success the body is { "success": true, "approvalStatus": "…" }.

MethodPathPurposeAuth
POST/api/parser/finance/documents/{id}/submitDraft or Rejected → Submitted; stamps submitted_by / submitted_at, clears any rejection reasonmanage_finance | approve_invoice | pay_invoice
POST/api/parser/finance/documents/{id}/approveSubmitted → Approved; runs SoD + budget checks; stamps approved_byapprove_invoice (>= high-value); else manage_finance | approve_invoice
POST/api/parser/finance/documents/{id}/rejectSubmitted → Rejected; requires a non-empty reason in the body (400 without one)manage_finance | approve_invoice
POST/api/parser/finance/documents/{id}/payApproved → Paid; stamps paid_by / paid_at; syncs legacy status=Paidpay_invoice (>= high-value); else manage_finance | pay_invoice
POST/api/parser/finance/documents/{id}/clear_reviewNeedsReview → Draft without editing fields; 409 while an ambiguous amount or a flagged invoice date is unconfirmed, 400 while the currency has no ratemanage_finance

submit, approve and reject return 409 when the invoice is dated in a locked accounting period; pay is still allowed after the period is closed, because the payment is booked in the current period.

The permission gate is amount-aware. Below the high-value floor (the finance.high_value_floor setting, default 25,000 in base currency — the same single floor the segregation-of-duties rules use) a manage_finance holder may approve or pay. At or above it, the dedicated approve_invoice / pay_invoice permission is required — manage_finance alone no longer suffices. Users holding the administrator role bypass the permission check entirely (but not the segregation-of-duties identity checks below, which compare user ids). Holding pay_invoice also puts an account on the MFA floor, so it is asked for a second factor at every sign-in whatever its groups are set to; see Forcing MFA By Group.

// Walk one invoice through the full chain (three DIFFERENT people).

// The gateway turns your bearer token into the verified user the service reads.

# 1) The requester submits it for approval

curl -X POST https://<your-instance>-api.zonitix.com/api/v1/parser/finance/documents/42/submit \

-H 'Authorization: Bearer <requester-token>'

# 2) A DIFFERENT user approves (not the uploader, not the submitter)

curl -X POST https://<your-instance>-api.zonitix.com/api/v1/parser/finance/documents/42/approve \

-H 'Authorization: Bearer <approver-token>'

# 3) A THIRD user marks it paid (not an approver, the uploader or the submitter)

curl -X POST https://<your-instance>-api.zonitix.com/api/v1/parser/finance/documents/42/pay \

-H 'Authorization: Bearer <payer-token>'

// Reject requires a reason in the body (400 without it):

curl -X POST https://<your-instance>-api.zonitix.com/api/v1/parser/finance/documents/42/reject \

-H 'Content-Type: application/json' \

-d '{ "reason": "Missing PO number" }'

Invoices: List, Detail & Self-Service

Two audiences read invoices. Finance roles (view_finance or manage_finance) read the whole ledger. An employee holding only submit_invoice reads only the invoices they uploaded themselves, through the my-documents routes; asking for someone else’s returns 404, the same answer as an invoice that does not exist.

The timeline route is the employee’s view of the audit trail, and it is a whitelist. It returns only these steps: uploaded, corrected by the uploader, checked by finance, confirmed by finance, sent for approval, an approval step, approved, rejected and paid. Each step carries a label and a time; approval and rejection steps also name who decided, and, while the invoice is still rejected, the rejection step carries its reason. Internal finance actions (allocation, a budget override, a waived match exception) and the audit entry’s detail text are never returned.

MethodPathPurposeAuth
GET/api/parser/invoicesThe paginated ledger. Query: page, limit (default 25), sort_by (default date), sort_dir (default desc), date_from, date_to, vendor, category, status, cost_center, approval_status, searchview_finance | manage_finance
GET/api/parser/statsThe spend totals behind the Overview tabview_finance | manage_finance
GET/api/parser/finance/insightsDuplicate-invoice and price-increase alerts, computed on the instanceview_finance | manage_finance
GET/api/parser/finance/documents/{id}One invoice in full: fields, review flags, extraction confidence, match status, line items and the extracted text (404 if not found)view_finance | manage_finance
GET/api/parser/finance/documents/{id}/fileThe original uploaded file. A PDF or image is served inline, anything else as a download; 404 when no file is stored, or when a submit_invoice-only caller did not upload itview_finance | manage_finance | submit_invoice
GET/api/parser/my-documentsThe caller’s own uploads, newest first, with hold reasons and whether each can still be edited or deleted. ?limit= (default 50, at most 200)view_finance | manage_finance | submit_invoice
GET/api/parser/my-documents/{id}/timelineWhere one invoice is, as employee-safe steps. Finance roles may read any invoice; a submit_invoice-only caller only their own (404 otherwise)view_finance | manage_finance | submit_invoice
DELETE/api/parser/documents/{id}Delete a Draft, Rejected or NeedsReview invoice. A submit_invoice-only caller may delete only their own upload (403 otherwise). 409 once it is Submitted, Approved or Paid, once it has approval or ledger history, or when it is dated in a locked periodmanage_finance | submit_invoice
GET/api/parser/finance/categoriesThe category names offered in the category pickerview_finance | manage_finance | submit_invoice
GET/api/parser/finance/allocation-valuesCost centres, projects and departments already in use, for the allocation pickersview_finance | manage_finance

Segregation Of Duties — The Identity Rules

When SoD is enforced, the transition functions compare the acting user’s id against the ids already recorded on the invoice, and refuse a self-serving action with a 403. The exact rules:

  • Approve: the acting user must differ from submitted_by (the submitter cannot approve their own request) and from userId (the uploader cannot approve the invoice they uploaded).
  • Reject: the acting user must differ from submitted_by (the submitter cannot reject their own request).
  • Pay: the acting user must differ from every approver recorded on the invoice (all of them under an N-of-M rule, not only the last), from userId (the uploader) and from submitted_by (the submitter). An approver, the uploader, or the submitter cannot also mark it paid.

Enforcement is governed by the enforce_sod finance setting — the Enforce segregation of duties switch in Finance → Settings, which only a holder of manage_security can change. It is on by default: only the literal value false disables it, and when disabled the service emits a warning log Segregation of duties is GLOBALLY DISABLED. The invoice amount used for the floor check is amount_base (falling back to amount).

The Hard Floor Is Always On

An invoice whose base-currency amount is at or above the high-value floor (finance.high_value_floor, default 25,000) always enforces the identity rules above, regardless of the enforce_sod switch. Turning SoD off cannot relax the control where the money is material. It is one floor, not two: the same value decides when the dedicated approve_invoice / pay_invoice permissions become mandatory. The floor itself is a setting, and changing it needs manage_security and is audited. To verify: attempt a same-user approve on an invoice at or above the floor with enforce_sod=false; it must still return 403.

Finance Settings — Who May Change A Control

The controls on this page are tuned in Finance → Settings, backed by one audited endpoint that accepts only a fixed list of keys (an unknown key returns 400). Each write records old → new as a setting_changed audit entry. The settings that are the control — enforce_sod, finance.high_value_floor, match.tolerance_pct (clamped to 0–0.5) and match.require_grn — need manage_security, so a finance operator cannot weaken the rules they work under. The rest need manage_finance: the review confidence floor, LLM-assisted extraction on/off, the digest (enabled, recipients, cadence), the fiscal-year start month, the budget warning and over thresholds, and the cloud cost anomaly threshold.

MethodPathPurposeAuth
GET/api/parser/finance/settingsThe editable finance settings with current value, default and whether each is a critical controlview_finance | manage_finance
PUT/api/parser/finance/settingsSet one setting: { key, value }. Numbers are clamped to the setting’s allowed range; auditedmanage_security (critical keys); else manage_finance

Configurable Approval Rules — Amount-Tiered N-of-M

How many approvers an invoice needs is not a single fixed $25k rule — it is a configurable set of amount bands. Each rule carries a minAmount, an optional maxAmount (omit it for an open-ended top band), and a requiredApprovals count. On approve, the service picks the highest active band the invoice’s amount falls into and requires that many distinct approvers before it advances Submitted → Approved. A band covers minAmount inclusive up to maxAmount exclusive, so an invoice of exactly a band’s maximum falls into the next band; maxAmount must be greater than minAmount. Where active bands overlap, the one with the highest minAmount applies. With no matching band the requirement is 1 (and a rule-lookup error also fails open to 1, so a bad rule can never wedge approvals). Changing the rules is a policy change: it needs manage_finance_policy, not manage_finance, so an AP operator cannot lower the approver count on their own invoices. A refused attempt is itself audited. A rule name is at most 80 characters and requiredApprovals at most 10; an invalid rule returns 400.

Distinctness is enforced twice: the same user cannot approve an invoice twice at the application layer, and a database partial-unique index on (doc_id, actor_id) backstops the race with a 409. Approvals are counted per submission round: only approvals given since the invoice was last submitted count, so after a rejection and a re-submission the earlier approvals no longer fill the quota, and someone who approved the earlier round may approve the new one. Below quota the invoice stays Submitted in the approvals queue; GET /finance/documents/{id}/approvals reports progress as distinct-approvers-so-far vs required. This N-of-M band engine is separate from the $25k SoD floor above: the floor decides when the identity checks and the dedicated approve_invoice/pay_invoice permissions are force-enforced; these rules decide how many distinct approvers a given amount needs.

MethodPathPurposeAuth
GET/api/parser/finance/approval-rulesList amount-tiered approval rules (minAmount, maxAmount, requiredApprovals, isActive)view_finance | manage_finance | manage_finance_policy
GET/api/parser/finance/approval-rules/preview?amount= (base currency): which rule applies and how many distinct approvers it needsview_finance | manage_finance | manage_finance_policy
POST/api/parser/finance/approval-rulesCreate a band (400 on an invalid rule)manage_finance_policy
PATCH/api/parser/finance/approval-rules/{id}Update a band (404 if not found; 400 on an invalid rule)manage_finance_policy
DELETE/api/parser/finance/approval-rules/{id}Delete a bandmanage_finance_policy

2-/3-Way PO Matching Blocks Approval

An invoice that references a purchase order must reconcile to it. Header matching always compares the invoice total against the PO total within a configurable tolerance (match.tolerance_pct, default ±2%, settable between 0 and 50%). When both the invoice and the PO carry line items, line matching additionally checks quantity and unit price per line; where goods-receipt lines exist, received-versus-ordered is checked (a true 3-way match), and with strict 3-way enabled (match.require_grn) a PO-referenced invoice with no receipt is flagged. A variance beyond tolerance — or a referenced PO that is missing — opens a match exception that blocks approve with a 409 until the exception is resolved or explicitly waived with a reason.

Matching is fail-open by design: an invoice with no PO number is not_applicable and is never blocked, so a legitimate non-PO invoice always flows. A missing block is therefore not proof a PO existed and matched.

A purchase order is itself a commitment against a budget, so it is checked when it is created: a PO whose base-currency total exceeds what the matching budget has Available is refused with 409 unless the body carries budget_override: true, in which case it is created and a po_budget_override audit entry is written. When a PO has line items its total is derived from them (quantity × unit price); a total supplied alongside lines is ignored. A PO in a currency with no exchange rate returns 400. Open POs are listed in the PO register and can be closed, cancelled or re-opened.

MethodPathPurposeAuth
POST/api/parser/finance/purchase-ordersCreate a PO (with optional line items); 409 if the PO number already exists or the budget cannot cover itmanage_finance
GET/api/parser/finance/purchase-ordersPO register; optional ?status= filterview_finance | manage_finance
GET/api/parser/finance/purchase-orders/{po_id}One PO with its lines and receipts (404 if not found)view_finance | manage_finance
POST/api/parser/finance/purchase-orders/{po_id}/closeSet status: closed (default), cancelled or open; auditedmanage_finance
POST/api/parser/finance/purchase-orders/bulk-closeSame for up to 500 POs: { ids, status }; per-PO result, a bad id never aborts the batchmanage_finance
POST/api/parser/finance/goods-receiptsRecord a goods receipt (enables the 3-way received-vs-ordered check)manage_finance
POST/api/parser/finance/rematch/{doc_id}Re-run matching for one invoice after a PO / receipt / allocation changemanage_finance
GET/api/parser/finance/match-exceptionsOpen + resolved match exceptions (expected/actual/variance per row)view_finance | manage_finance
POST/api/parser/finance/match-exceptions/{exc_id}/waiveWaive one exception with a required reason (audited); unblocks approvemanage_finance
POST/api/parser/finance/match-exceptions/bulk-waiveWaive up to 500 exceptions with one shared reason: { ids, reason }; an ineligible one is skipped and reportedmanage_finance

The Budget Block & The Audited Override

On approve (only), the service checks the invoice against every current budget its spend rolls into. It walks vendor, then category, then department, then cost-center, then global scope; monthly period first, then annual. The rule depends on whether the invoice references a purchase order:

  • No PO (net-new spend): blocked when the invoice amount exceeds the budget’s Available — budget minus actual, minus open PO commitments, minus open spend requests. This fires before the line is over, on the invoice that would take it over.
  • PO-backed: the PO already holds that budget as a commitment, so approving the invoice only moves it from committed to actual. These are blocked only when the line is already Over.

Either way the approval is refused with 409 and a human-readable detail naming the line. Purchase orders are checked the same way when they are created (see PO matching above).

To override, send { "budget_override": true, "override_reason": "…" } in the body. An override relaxes a control, so it needs the dedicated approve_invoice permission at any amount (manage_finance alone returns 403) and a non-empty override_reason (400 without one). The approval then proceeds and an extra audit entry, action budget_override with detail Approved OVER BUDGET (…) — reason: …, is written alongside the normal approved entry. The block is deliberately fail-open: if the budget calculation itself throws, it returns no block and logs a warning — a budget-calc bug must never wedge the approval workflow, so a missing block is not proof no budget applied.

A frozen budget is different. If the invoice rolls into a budget scope that finance has frozen, approval is refused with 409 and budget_override does not get past it — the hold has to be lifted first (see Budget Lifecycle below).

// A plain approve of a non-PO invoice the budget cannot cover:

POST /api/parser/finance/documents/42/approve → 409

{ "detail": "Budget exceeded — Cloud 2026-08: this 9000.0 exceeds 4000.0 available

(budget 50000.0 - actual 41000.0 - committed 5000.0). Approval requires an explicit budget override." }

// The explicit, audited escape (caller holds approve_invoice):

curl -X POST https://<your-instance>-api.zonitix.com/api/v1/parser/finance/documents/42/approve \

-H 'Authorization: Bearer <approver-token>' \

-H 'Content-Type: application/json' \

-d '{ "budget_override": true, "override_reason": "Approved by CFO, ticket FIN-212" }'

# → 200 { "success": true, "approvalStatus": "Approved" }

# plus a budget_override entry in /finance/audit

Allocation Is Frozen After Submit

Cost-center, department, project, GL code, currency and any custom allocation fields are set through PATCH /api/parser/finance/documents/{id}/allocation (gate: manage_finance). Because currency drives amount_base, and cost-center drives which budget the spend rolls into, these are frozen once the invoice enters the workflow. A PATCH against an invoice in Submitted, Approved or Paid state returns 409; re-allocation is permitted while Draft, Rejected or NeedsReview. An invoice dated in a locked accounting period is also refused with 409, whatever its state. Only the keys you send are changed. A glCode that is not in the chart of accounts returns 400, as does a currency with no exchange rate; customFields keeps only keys defined in your allocation custom-field schema. A missing document returns 404.

// Allowed while Draft/Rejected/NeedsReview in an open period. Currency change re-values amount_base.

curl -X PATCH https://<your-instance>-api.zonitix.com/api/v1/parser/finance/documents/42/allocation \

-H 'Authorization: Bearer <token>' \

-H 'Content-Type: application/json' \

-d '{ "costCenter": "CC-100", "department": "Eng", "project": "Atlas", "glCode": "6100", "currency": "EUR" }'

// After it is Submitted/Approved/Paid:

→ 409 "Cannot re-allocate a 'Approved' invoice — cost-center and currency are frozen…"

Budgets (CRUD)

A budget targets a scope (global, vendor, category, department or cost_center) for a period: YYYY-MM is a calendar month, YYYY is a fiscal year whose start month is the finance.fiscal_year_start_month setting (default 1, the calendar year). A budget may be entered in any currency that has an exchange rate (400 otherwise); all comparisons are done in base currency. Actuals are summed live in base currency, and count only committed spend — invoices in NeedsReview, Draft, Submitted or Rejected are excluded (a null approval status is treated as committed). Thresholds are configurable: a budget’s own warnPct / overPct win, otherwise the global finance.budget_warn_pct / finance.budget_over_pct settings apply (defaults 80% and 100%). Alongside actual, each row reports committed (open purchase orders) and available (budget minus actual, commitments and open spend requests).

Budget access is its own permission family, separate from invoice handling. Reading needs any of view_finance, manage_finance, view_finance_consolidated, manage_all_finance_budgets, approve_finance_budget, view_finance_budgets or manage_finance_budget; writing needs manage_finance, manage_all_finance_budgets or manage_finance_budget. The list is scoped to the caller: finance and consolidated roles see every budget, while a department manager holding only the budget permissions sees their own and their department’s, may create budgets only for their own department (the owner is stamped server-side), and may edit only budgets they own (403 otherwise).

MethodPathPurposeAuth
GET/api/parser/finance/budgetsList the budgets the caller may see, with computed actual, committed, available, consumedPct, status. Filters: budgetType, status, period, scopeType, department, over, atRisk, sortany budget read permission
POST/api/parser/finance/budgetsCreate a Draft budget; period is required (400 if missing); 403 for a scope outside the caller’s departmentany budget write permission
PATCH/api/parser/finance/budgets/{id}Update a budget (404 if not found; 403 if not yours to edit)any budget write permission
DELETE/api/parser/finance/budgets/{id}Delete a budget (404 if not found; 403 if not yours; 409 once it has been approved or locked — reforecast or freeze it instead; the administrator role may still delete it, and the deletion is audited)any budget write permission

// Create a monthly category budget. scopeType defaults to "global".

curl -X POST https://<your-instance>-api.zonitix.com/api/v1/parser/finance/budgets \

-H 'Authorization: Bearer <token>' -H 'Content-Type: application/json' \

-d '{ "scopeType": "category", "scopeValue": "Cloud",

"period": "2026-08", "amount": 50000 }'

// Response (one serialized budget row):

{ "id": 7, "scopeType": "category", "scopeValue": "Cloud", "label": "Cloud",

"period": "2026-08", "budget": 50000.0, "actual": 0.0, "remaining": 50000.0,

"variance": 50000.0, "consumedPct": 0.0, "status": "OK" }

Field Naming

Create/update take camelCase keys (scopeType, scopeValue) — a missing or empty scopeValue becomes a global-scope budget. Only period is mandatory on create. Optional keys include name, description, budgetType, currency, startDate / endDate, warnPct / overPct, and authoritativeSource (invoice, cloud or both), which decides whether actuals come from invoices, from linked cloud systems, or both. For budgetType the app offers opex, capex, project, cost_center, cloud and headcount (default opex). The API stores the value as sent and does not validate it against that list. When both startDate and endDate are set they replace the period key as the window actuals are summed over, and the end date is inclusive: an invoice dated on the last day counts.

Budget Lifecycle — Submit, Approve, Reforecast, Freeze

A budget is more than a number to compare against. It has its own approval lifecycle, shown on the Budgets page in the Finance section of the sidebar and on the Budgets & Chargeback tab of the Finance workspace. A new budget starts as Draft. Its owner submits it; an approver approves or rejects it; approval locks it as the baseline. The read and write permission lists are the ones given under Budgets (CRUD) above.

  • Submit: only a Draft or Rejected budget, and only your own (409 / 403 otherwise).
  • Approve / reject: an owner cannot approve their own budget. The approver must be either a finance-level approver or the owner’s manager; anyone else gets 403, and the refusal is audited. The one exception is the administrator role, which is not held to this check: an administrator can approve a budget they own, and that approval is flagged on the audit trail as budget_admin_self_approval. Reject requires a reason (400 without one).
  • Reforecast: a locked budget is not edited in place. Reforecasting creates a new Draft version; earlier versions stay readable.
  • Unlock: needs manage_finance_policy. Any edit after an unlock sends the budget back to Submitted for re-approval, and the person who edited it cannot approve their own edit. Until it is re-approved, an increase does not take effect — spend is still measured against the previously approved amount — while a decrease applies immediately.
  • Freeze: a hold on the budget’s scope. While frozen, invoice approvals that roll into it return 409 and no override gets past it.
MethodPathPurposeAuth
POST/api/parser/finance/budgets/{id}/submitDraft or Rejected → Submittedmanage_finance | manage_all_finance_budgets | manage_finance_budget | submit_finance_budget
POST/api/parser/finance/budgets/{id}/approveSubmitted → Approved and locked (403 if you are not an eligible approver)manage_finance | manage_all_finance_budgets | approve_finance_budget
POST/api/parser/finance/budgets/{id}/rejectSubmitted → Rejected; { reason } requiredmanage_finance | manage_all_finance_budgets | approve_finance_budget
POST/api/parser/finance/budgets/{id}/reforecastCreate a new Draft version of your own budgetsame as submit
POST/api/parser/finance/budgets/{id}/lock{ locked: true | false }. Unlocking needs manage_finance_policymanage_finance | manage_all_finance_budgets | approve_finance_budget | manage_finance_policy
POST/api/parser/finance/budgets/{id}/freeze{ frozen: true | false, reason }manage_finance | manage_all_finance_budgets | close_finance
GET/api/parser/finance/budgets/{id}/versionsVersion history of a budgetany budget read permission
POST/api/parser/finance/budgets/{id}/copyCopy the budget and its lines into a new Draft for another period: { period }any budget write permission
GET/api/parser/finance/budgets/approvals-inboxBudgets and transfers waiting for the caller’s approvalany budget read permission
POST/api/parser/finance/approvals/bulkApprove or reject up to 500 budgets and transfers: { action, budgetIds, transferIds, reason }; each item is checked on its ownper item, as above

Budget Lines, Alerts & Reporting

A budget can be built bottom-up from line items, each a flat amount or a driver (quantity × unit cost), and anyone who can see a budget can comment on it. A threshold sweep runs hourly and raises an alert when a budget reaches its warning threshold, is on pace to exceed its amount, or goes over; alerts are scoped to the caller the same way budgets are, and can be acknowledged. Every budget action writes to the finance audit trail, with its own filtered export.

MethodPathPurposeAuth
GET/api/parser/finance/budgets/{id}/linesLine items of a budgetany budget read permission
POST/api/parser/finance/budgets/{id}/linesAdd a line (PATCH / DELETE …/lines/{line_id} to edit or remove)any budget write permission
GET/api/parser/finance/budgets/{id}/commentsComments on a budget (POST { comment } to add one)any budget read permission
GET/api/parser/finance/budgets/{id}/actualsThe spend that makes up this budget’s actualany budget read permission
GET/api/parser/finance/budgets/{id}/forecast-accuracyForecast vs outcome for the budgetany budget read permission
GET/api/parser/finance/budgets/alertsOpen alerts; ?includeAck=true adds acknowledged onesany budget read permission
POST/api/parser/finance/budgets/alerts/{alert_id}/ackAcknowledge an alert (404 if not yours to see)any budget read permission
POST/api/parser/finance/budgets/alerts/sweepRun the threshold sweep nowmanage_finance | manage_all_finance_budgets | view_finance_consolidated
GET/api/parser/finance/budgets/summaryOrganisation-wide rollup by department; optional ?period=view_finance | manage_finance | view_finance_consolidated | manage_all_finance_budgets
GET/api/parser/finance/budgets/export.csvBudget vs actual vs committed, scoped to the callerany budget read permission
GET/api/parser/finance/budgets/auditBudget action trail (audit.csv for the CSV export)view_finance | manage_finance | view_finance_consolidated | manage_all_finance_budgets | close_finance

Spend Requests — Asking Before You Spend

A spend request asks for budget before the purchase happens. While it is Pending or Approved its amount is held against the budget: it is subtracted from Available, so the invoice and PO checks above see the decision before any invoice exists. Rejecting it, or marking it Fulfilled once the real PO or invoice has taken over, releases the hold. Anyone who can see a budget can raise a request against it; the approver follows the same rule as a budget approval, and a requester cannot approve their own request (403). The administrator role is exempt from both checks, so an administrator can approve a request they raised themselves.

MethodPathPurposeAuth
GET/api/parser/finance/spend-requestsOpen requests in the caller’s scope; ?includeClosed=true adds closed onesany budget read permission
POST/api/parser/finance/spend-requestsCreate: { budgetId, amount, currency, vendor, description }. 400 on a non-positive amount or a currency with no rate; 403 for a budget outside your scopeany budget read permission
POST/api/parser/finance/spend-requests/{id}/approvePending → Approved (409 if not Pending)manage_finance | manage_all_finance_budgets | approve_finance_budget
POST/api/parser/finance/spend-requests/{id}/rejectPending → Rejected; optional { reason }manage_finance | manage_all_finance_budgets | approve_finance_budget
POST/api/parser/finance/spend-requests/{id}/fulfillApproved → Fulfilled; releases the holdmanage_finance | manage_all_finance_budgets

Budget Transfers

A transfer moves budget from one budget to another and normally takes two people: the requester cannot approve or reject their own transfer (403). The number of distinct approvers comes from the same amount-tiered approval rules that govern invoices. A department-level mover may only transfer between budgets inside their own scope; a transfer at or above the high-value floor must be approved by someone holding manage_finance or manage_all_finance_budgets. The administrator role is exempt from both the requester check and the high-value rule, so an administrator can approve or reject a transfer they requested themselves. A locked budget cannot be changed by a transfer (reforecast it instead), and a transfer the source cannot fund returns 409. Budgets in different currencies are converted at the configured rates; a missing rate returns 400.

MethodPathPurposeAuth
GET/api/parser/finance/transfersTransfers the caller may seetransfer_finance_budget | manage_all_finance_budgets
POST/api/parser/finance/transfersRequest: { fromBudgetId, toBudgetId, amount, currency, reason }transfer_finance_budget | manage_all_finance_budgets
POST/api/parser/finance/transfers/{id}/approveRecord an approval; the money moves when the required number of distinct approvers is mettransfer_finance_budget | manage_all_finance_budgets
POST/api/parser/finance/transfers/{id}/rejectReject; optional { reason }transfer_finance_budget | manage_all_finance_budgets

Systems & Cloud Cost Against A Budget

On the Systems tab a budget owner can claim a system from a connected provider and, optionally, attach it to one of their budgets. A budget whose authoritativeSource is cloud or both then counts that system’s billed cost toward its actual. Cloud cost anomalies are flagged when a system’s cost rises month over month by more than the finance.cloud_anomaly_pct setting (default 30%), whether or not a budget threshold was crossed.

MethodPathPurposeAuth
GET/api/parser/finance/systemsSystems in the caller’s scope, with cost where the provider supplies itany budget read permission
GET/api/parser/finance/systems/providersConnected providers a system can be claimed fromany budget read permission
POST/api/parser/finance/systemsClaim a system; attaching it to a budget needs write access to that budget (403 otherwise)any budget write permission
DELETE/api/parser/finance/systems/{id}Unlink a system (404 if not found)any budget write permission
GET/api/parser/finance/cloud/anomaliesMonth-over-month cost spikes across the caller’s systemsany budget read permission

What Cloud Cost Covers Today

Live billed cost comes from AWS and Azure connections only. Another provider being connected for sign-in or provisioning does not mean its spend is flowing into Finance. The cost comparison is monthly (this month against last), grouped by service; there is no day-by-day or per-tag breakdown.

Chargeback (Spend By Dimension)

GET /api/parser/finance/chargeback (gate: view_finance, manage_finance, view_finance_consolidated or manage_all_finance_budgets) allocates base-currency spend across a dimension. It is an organisation-wide view, so a department manager holding only the scoped budget permissions cannot call it. Like every other roll-up it counts committed spend only — invoices that are Draft, Submitted, Rejected or held for review are left out, so the totals tie to the dashboard and to budget actuals. The dimension query param is one of cost_center (default), department, project, category; an unrecognized value falls back to cost_center. An optional period (YYYY-MM / YYYY) narrows the window. Rows with no value on that dimension are grouped under Unallocated.

curl 'https://<your-instance>-api.zonitix.com/api/v1/parser/finance/chargeback?dimension=department&period=2026' \

-H 'Authorization: Bearer <token>'

// Response:

{ "dimension": "department", "period": "2026", "allocations": [

{ "label": "Eng", "amount": 120400.0, "invoiceCount": 34, "sharePct": 62.1 },

{ "label": "Unallocated", "amount": 73500.0, "invoiceCount": 18, "sharePct": 37.9 } ] }

Vendors & Vendor Merge

Raw OCR vendor strings collapse onto canonical vendor records (matched by canonical name or alias, auto-created on first sight). Merge folds one vendor into another: it repoints the loser’s invoices to the keeper, absorbs the loser’s aliases (plus its old canonical name as a new alias), and moves the loser’s vendor-scoped budgets onto the keeper (dropping one where the keeper already has a budget for that period). Closed periods are never rewritten: an invoice dated in a locked accounting period keeps its original vendor and is reported as skipped. If any were skipped, the loser row is kept and marked as merged into the keeper, so new documents still land on the keeper; otherwise the loser row is deleted.

MethodPathPurposeAuth
GET/api/parser/finance/vendorsCanonical vendors with rolled-up base-currency spend (totalSpend)view_finance | manage_finance
PATCH/api/parser/finance/vendors/{id}Edit canonicalName / category / owner / ownerUserId / contractRef / contractRenewal (ISO date, empty to clear) / notes / aliases / customFields (404 if not found)manage_finance
POST/api/parser/finance/vendors/bulk-updateSet category and/or owner on many vendors: { ids, patch }; per-vendor result, a bad id is skippedmanage_finance
POST/api/parser/finance/vendors/mergeMerge mergeId into keepId; both required (400 if missing / invalid ids)manage_finance

// Merge duplicate vendor #9 into the canonical #3.

curl -X POST https://<your-instance>-api.zonitix.com/api/v1/parser/finance/vendors/merge \

-H 'Authorization: Bearer <token>' -H 'Content-Type: application/json' \

-d '{ "keepId": 3, "mergeId": 9 }'

# → 200 { "success": true, "keptVendorId": 3, "repointed": 12,

# "skippedLocked": [], "loserRetained": false }

// skippedLocked lists { id, period, status } for invoices left in a locked period;

// loserRetained is true when #9 had to be kept for them.

Base Currency & FX Recompute

Every invoice stores its native amount + currency; amount_base is the normalized value all roll-ups sum. FX rates are set by an administrator on your instance (no external FX API). POST /api/parser/finance/currency (gate: manage_finance_policy — manage_finance alone returns 403, and the refused attempt is audited) can set the base currency (baseCurrency) and/or upsert a rate (code + rateToBase), then recomputes normalized totals. A non-positive, non-finite, or out-of-band (outside 1e-6…1e6) rate is rejected with 400 before anything is persisted — a bad rate would otherwise re-value the whole ledger.

MethodPathPurposeAuth
GET/api/parser/finance/currencyBase currency + rate tableview_finance | manage_finance | manage_finance_policy
POST/api/parser/finance/currencySet the base currency and/or upsert a rate; recomputes non-frozen invoices (400 on a bad rate)manage_finance_policy
DELETE/api/parser/finance/currency/{code}Delete a rate. 404 if there is none; 400 for the base currency; 409 while any invoice, PO, budget or spend request is still in that currencymanage_finance_policy

Recompute Skips Committed Invoices

A base-currency or rate change recomputes amount_base for every invoice still outside the workflow — Draft, Rejected or NeedsReview — not literally every invoice. Rows already in Submitted, Approved or Paid are frozen: their amount_base was locked when they entered the workflow, so a later FX edit does not retroactively re-value committed spend or shift historical budget roll-ups. This is the same ledger-freeze invariant the allocation endpoint enforces.

// Caller holds manage_finance_policy.

curl -X POST https://<your-instance>-api.zonitix.com/api/v1/parser/finance/currency \

-H 'Authorization: Bearer <token>' -H 'Content-Type: application/json' \

-d '{ "baseCurrency": "USD", "code": "EUR", "rateToBase": 1.08 }'

// Response is the full rate table:

{ "baseCurrency": "USD", "rates": [

{ "code": "EUR", "rateToBase": 1.08, "updatedAt": "2026-08-09T…" },

{ "code": "USD", "rateToBase": 1.0, "updatedAt": "2026-08-09T…" } ] }

// GET /api/parser/finance/currency returns the same shape (read-only).

Trends, Open Commitments & SaaS Spend

Three read-only views sit on top of the ledger, on the Trends and SaaS Spend tabs of the Finance workspace. All of them are computed from data already on your instance, in base currency, from committed spend.

  • Variance and category trend: this month against last month and against the same month last year, the categories that moved most, and committed spend per category over the last 3 to 24 months.
  • Open commitments: the value on purchase orders that are not closed or cancelled and have not been invoiced yet — what is committed but not yet billed.
  • SaaS spend: committed spend per app (canonical vendor), its owner, seats and cost per seat where usage is known, and a renewal timeline grouped into the next 30, 31–60 and 61–90 days. Renewal dates come from the contractRenewal date you set on the vendor; an app with no date is not on the timeline. An annual budget set on an app is an ordinary vendor-scoped budget, so it also appears on the Budgets tab and takes part in the budget block.

The connectors list shows which providers can supply billing data. Only AWS and Azure feed billing data today. The other providers in the list are shown as available, not as a working feed, and a provider connected only for sign-in or provisioning is not reported as supplying spend.

MethodPathPurposeAuth
GET/api/parser/finance/varianceThis month vs last month vs same month last year, with category moversview_finance | manage_finance
GET/api/parser/finance/category-trendCommitted spend per category; ?months= (3 to 24)view_finance | manage_finance
GET/api/parser/finance/commitmentsUninvoiced value on open purchase ordersview_finance | manage_finance
GET/api/parser/finance/saas-spendPer-app spend, seats, owner, renewal timeline and annual budget vs year to dateview_finance | manage_finance
PUT/api/parser/finance/saas/{vendor_id}/budgetSet an app’s annual budget for the current year: { amount } (zero or less clears it)manage_finance
GET/api/parser/finance/connectorsBilling connectors and whether each is connected and feeding billing dataview_finance | manage_finance

General Ledger, Month-End Close & Journal Export

Committed spend is coded to a general ledger against a chart of accounts (GET / POST /api/parser/finance/gl-accounts; a duplicate account code returns 409). Locking an accounting period (POST /api/parser/finance/close/{period}, period as YYYY-MM) generates balanced double-entry journals for that period’s committed invoices — a debit to expense, an input-tax leg where applicable, and a credit to accounts payable — and marking an invoice paid posts a settlement journal that debits AP back down, so the AP balance nets to zero rather than growing without bound.

Each invoice posts to the GL code set on it through the allocation endpoint (glCode, validated against your chart of accounts once one exists). An invoice with no GL code posts to the catch-all expense account 6000; the other default accounts are 2000 (accounts payable), 1000 (cash) and 1400 (input tax). The close status reports how many committed invoices are still uncoded — an advisory, not a blocker. The settlement journal is a cash event, so it books to the current period, and marking an invoice paid is deliberately still allowed after the invoice’s own period has been locked.

Close is fail-safe: it refuses to lock a dirty period — one that still holds NeedsReview invoices, invoices awaiting approval, or open match exceptions — unless the lock is explicitly forced with { "force": true, "reason": "…" }. A forced close without a written reason returns 400, and the reason lands on the audit trail. Closing needs the dedicated close_finance permission; manage_finance alone returns 403, which keeps locking a period separate from approving and paying. Once locked, no write may touch an invoice dated in that period (the same freeze the allocation and FX endpoints rely on). The period’s journals export as a CSV (GET /api/parser/finance/close/{period}/journals.csv) for import into an external accounting system. The format query parameter picks the layout: generic (default), quickbooks (QuickBooks General Journal import columns) or xero (Xero Manual Journal import columns).

A locked period is never reopened. A correction after close is posted as a balanced adjusting entry into the current open period through POST /api/parser/finance/adjustments with debitGl, creditGl, amount and a mandatory reason. It returns 400 for a missing reason, identical or unknown accounts, or a non-positive amount, and 409 if the current period is itself locked. GET /api/parser/finance/reconciliation?period= compares invoice actuals with what was posted to the GL for a period.

MethodPathPurposeAuth
GET/api/parser/finance/gl-accountsChart of accountsview_finance | manage_finance
POST/api/parser/finance/gl-accountsAdd a GL account (409 on duplicate code)manage_finance
GET/api/parser/finance/close/{period}Close status for a period (locked? blocking items?)view_finance | manage_finance
POST/api/parser/finance/close/{period}Lock the period + generate journals; refuses a dirty period unless { force: true, reason } (400 if forced without a reason)close_finance
GET/api/parser/finance/close/{period}/journals.csvExport that period’s journal entries as CSV; ?format=generic | quickbooks | xeroview_finance | manage_finance
POST/api/parser/finance/adjustmentsPost a balanced adjusting entry to the current open period: { debitGl, creditGl, amount, reason }manage_finance | close_finance
GET/api/parser/finance/reconciliation?period= — invoice actuals vs GL posted for the periodview_finance | manage_finance | view_finance_consolidated | manage_all_finance_budgets | close_finance

GL sync today is file-based. The sync endpoints (GET /api/parser/finance/gl-sync; set with PUT /api/parser/finance/gl-sync/connector; run a period with POST /api/parser/finance/gl-sync/{period}; the two writes need manage_finance, the status read also allows view_finance) work with the manual connector, which is the default: running it stamps each unsynced journal with an external_ref and marks it exported, and you load the journals CSV into your accounting system yourself. Nothing is sent over the network. The connector picker also lists quickbooks, netsuite and xero, but those API connectors are not built: selecting one and running a sync returns 400 and pushes nothing, rather than silently doing nothing. What exists for QuickBooks and Xero is the journal CSV in their import layout (the format parameter above). The run is idempotent: a journal that already carries an external_ref is never stamped again, so a re-run can’t mark or export the same journal twice.

No Disbursement Rail

The GL and its export record accounting entries; they do not move money. There is no ACH or bill-payment rail: the AP workflow ends at a Paid status a user marks, and the resulting journal is exported as a file — nothing here instructs a payment.

Ask The Ledger — SQL-Grounded, Local LLM Fallback

POST /api/parser/finance/ask (gate: view_finance / manage_finance) answers a natural-language question about your spend. A deterministic SQL layer answers the common numeric questions exactly against committed spend in base currency — so a dollar figure is never hallucinated by a small local model. Only when no exact aggregate applies does it fall back to the local Ollama model on your instance; either way, no invoice or spend data is ever sent to an external model. A missing question returns 400. In the app this is the AI Insights tab of the Finance workspace: type the question in the box and click Ask.

SQL First, So Numbers Are Exact

A grounded answer comes straight from a SQL aggregate and returns aiUsed: false, grounded: true. The LLM is only reached for questions the deterministic layer can’t answer, and the prompt is still built from your own ledger and sent only to the Ollama endpoint on your instance (default http://ollama:11434).

Honest When It Can't Answer

If it falls through to the model and local AI is disabled (ENABLE_LOCAL_AI != true), the model isn’t pulled, or Ollama is unreachable, the response returns aiUsed: false with a plain explanation rather than a fabricated figure. Check grounded / aiUsed before trusting the answer.

curl -X POST https://<your-instance>-api.zonitix.com/api/v1/parser/finance/ask \

-H 'Authorization: Bearer <token>' -H 'Content-Type: application/json' \

-d '{ "question": "How much did we spend last month?" }'

// Numeric question answered from an exact SQL aggregate:

{ "answer": "You spent $128,400.00 …", "aiUsed": false, "grounded": true }

Audit Trail & Digest

Every workflow action, budget change, vendor edit, currency change, allocation and delete writes an append-only finance audit entry, and each is also mirrored into the app-wide Audit & System Logs as a FINANCE_<ACTION> event. The audit endpoint is the record an inspector reads back to confirm who did what.

MethodPathPurposeAuth
GET/api/parser/finance/auditFinance action trail (filter by doc_id; limit clamped 1..500, default 200)view_finance | manage_finance
GET/api/parser/finance/digestFinance digest summaryview_finance | manage_finance
POST/api/parser/finance/digest/sendQueue the digest now; optional recipients (400 if it cannot be queued); auditedmanage_finance
GET/api/parser/finance/audit.csvControls-evidence export of the trail as CSV; optional doc_id and action filters. The export is itself auditedview_finance | manage_finance

The scheduled digest email is off by default. It is sent only when three finance settings say so: finance.digest_enabled, finance.digest_recipients and finance.digest_interval_hours. It goes out through the email transport configured on your instance, and only to recipients who are active users.

// The full trail for one invoice, newest first:

curl 'https://<your-instance>-api.zonitix.com/api/v1/parser/finance/audit?doc_id=42' \

-H 'Authorization: Bearer <token>'

// Response:

{ "entries": [

{ "id": 88, "docId": 42, "action": "paid",

"actorId": 5, "actorName": "pat@company.cc",

"detail": "Marked Cloudflare paid", "at": "2026-08-09T…" },

{ "id": 87, "docId": 42, "action": "budget_override", … } ] }

What The Trail Guarantees — And What It Doesn't

The trail records the acting user id and name captured from the gateway-verified session on each action, so an approve or pay is attributable. Append-only is enforced below the application, too: a database-level trigger guards the finance audit log, the posted journal_entries (financial fields frozen — only sync columns such as external_ref may be stamped), and the per-approver approval_actions, so an after-the-fact UPDATE or DELETE is rejected at the database. This is tamper-evidence — alteration is detectable and blocked at the row, not a claim the record can never be attacked. Because the central-log mirror shares the caller’s transaction, a mirror-write failure is swallowed (best-effort) rather than failing the finance action — so treat the finance-service /finance/audit as the primary record and the central mirror as a convenience view.

Bulk Operations, Import & Export

One action can be applied to many invoices in a single call. POST /api/parser/finance/documents/bulk takes an action (submit, approve, reject, pay, clear_review, allocate or delete) and up to 500 ids. Bulk is not a shortcut around the controls: every invoice is processed on its own with the same permission, segregation-of-duties, budget, match, period-lock and allocation-freeze checks as the single-invoice endpoints. There is no all-or-nothing transaction — one failing row never undoes the others — and the response lists the outcome of every row, so a partial success is visible.

Invoices can also arrive as a CSV file rather than one document at a time. Each row needs a vendor (header vendor, supplier, payee or name) and an amount (amount, total, amount (base) or cost); a row missing either is skipped. The optional columns are currency (or ccy), date (or invoice date), category, cost center, department (or dept), project, the vendor’s invoice number, and a display name (filename, invoice id or reference). Headers are matched case-insensitively, and a semicolon-delimited file is detected from its header row. status is not an import column: whatever a file puts there is never applied (a value other than pending or draft is counted in the audit entry), and every imported invoice goes through submit, approve and pay like any other.

  • Invoice number. Read from a column headed invoice number, invoice_number, invoicenumber, invoice no, invoice no., invoice #, invoice num, invoice id, invoice_id, invoiceid, bill number, bill no or bill no. (an invoice id column therefore sets both the number and the display name). A row whose vendor + invoice number matches an invoice already on file that is not rejected, or an earlier row of the same file, is skipped and reported as a duplicate, so importing the same file twice does not double it.
  • Currency. A row with no currency is booked in your base currency and flagged. A row whose currency has no exchange rate is skipped.
  • Date. An empty date cell becomes the import date, with no flag. A date that is present but cannot be read, or whose day/month order is ambiguous, imports the row held in review; an unreadable date uses the import date as a placeholder until a reviewer confirms it.
  • Likely duplicates. A row with the same vendor, amount and date as an invoice already on file, or as an earlier dated row of the same file, is imported held in review as a likely duplicate, not dropped. A row that carries an invoice number is compared only with invoices that have none.
  • Starting state. A row is held in NeedsReview when its amount separator is ambiguous, its date is flagged, or it is a likely duplicate. Every other row starts as a Draft.
  • Response. imported, skipped, errors (the first 50), currencyDefaulted, amountAmbiguous, dateHeld, duplicatesSkipped and duplicatesHeld.
MethodPathPurposeAuth
POST/api/parser/finance/documents/bulk{ action, ids, reason, budget_override, override_reason, allocation }; returns { ok, failed, results }coarse gate by action, then per invoice as the single endpoints
GET/api/parser/finance/approvals-queueSubmitted invoices, oldest first (the 200 oldest at most), with approvals given vs required, age, match state and high-value flagview_finance | manage_finance | approve_invoice
POST/api/parser/invoices/importCSV bulk import of invoices (multipart file); 400 for a file that fails validationmanage_finance
GET/api/parser/invoices/exportThe (filtered) invoice ledger as CSV; export.xlsx and export.pdf for the other formatsview_finance | manage_finance
GET/api/parser/finance/audit.csvThe finance audit trail as a controls-evidence CSVview_finance | manage_finance
GET/api/parser/finance/allocation-custom-fieldsYour own extra allocation fields (POST { fields } replaces the list; at most 20)view_finance | manage_finance (read); manage_finance (write)
GET/api/parser/finance/vendor-custom-fieldsYour own extra vendor fields (POST { fields } replaces the list; at most 20)view_finance | manage_finance (read); manage_finance (write)

Data Objects (Typed Fields)

In plain words: a call to the API answers with objects such as an invoice, a vendor, a purchase order or a budget. This section lists what is inside each one: every field, its type, and whether it can be empty. Open an object to see its fields. The routes that return them are in the API reference under Finance & Document Parser and the three groups whose names start with Finance:, where a capitalised name in a row’s returns line is one of these objects.

  • Types. integer, number, string, boolean, object and arrays written as string[]. string (date-time) is ISO 8601; string (date) is YYYY-MM-DD. A type followed by a colon and values separated by | takes only those values. A capitalised type is another object in this list.
  • The third column. “nullable” means the field can be null; “optional” means the key can be missing; “no” means neither. Do not fail on a field you do not recognise: a reply can carry fields beyond these. A card marked “request body” is what you send, not what comes back: its third column is headed Required, where “required” means the field must be sent and every other field is optional.
  • Money is a number here. The finance service sends every amount as a JSON number, such as 1250.0. In other modules check the type on each object: some send a stored decimal as a string.
  • Two date forms. Invoice.date and Invoice.renewalDate are display text (“Oct 10, 2026”, or “N/A”); read Invoice.dateIso in code. PurchaseOrder.orderedDate is display text too, or null, and the list has no ISO form of it.
Invoicein a reply31 fields

One row of GET /parser/invoices.

FieldTypeNull / optional / read-onlyNote
idintegerno
db_idintegernoThe same number as id; the path parameter of every route that takes :doc_id.
displayIdstringnoINV- followed by the id, padded to three digits.
filenamestringnullable
vendorstringnullableThe vendor name as read from the document.
canonicalVendorstringnullableThe name of the vendor record it is linked to; otherwise the same as vendor.
vendorIdintegernullable
amountnumbernoIn the invoice's own currency; 0 when none was read.
currencystringnoA 3-letter code; USD when none is stored.
currencyDefaultedbooleannotrue when no currency was on the document and your base currency was assumed.
amountBasenumbernoThe amount in your base currency; the entered amount when no conversion is stored.
datestringnoA display date such as "Oct 10, 2026", or "N/A". Use dateIso in code.
dateIsostring (date-time)no
renewalDatestringnoA display date, or "N/A".
categorystringnullable
costCenterstringnullable
departmentstringnullable
projectstringnullable
statusstringnoThe payment status; Pending when none is stored.
approvalStatusstringnoDraft when none is stored. See the approval states on the Finance Operations page.
extractionConfidencenumbernullable
invoiceNumberstringnullable
taxAmountnumbernullable
poNumberstringnullable
glCodestringnullable
customFieldsobjectnoYour own allocation fields; {} when none is set.
matchStatusstringnounmatched when no match has run.
submittedByintegernullable
approvedByintegernullable
paidByintegernullable
rejectionReasonstringnullable
InvoicePagein a reply6 fields

The reply of GET /parser/invoices. Page-numbered: page and limit, no offset.

FieldTypeNull / optional / read-onlyNote
itemsInvoice[]no
totalintegernoEvery invoice matching the filters, not the page.
pageintegernoStarts at 1.
limitintegernoDefault 25, 1 to 200.
pagesintegerno
filteredTotalAmountnumbernoThe sum of every matching invoice in your base currency.
Vendorin a reply11 fields
FieldTypeNull / optional / read-onlyNote
idintegerno
canonicalNamestringno
aliasesstring[]noOther spellings that are matched to this vendor.
categorystringnullable
ownerstringnullable
contractRefstringnullable
contractRenewalstring (date-time)nullable
notesstringnullable
customFieldsobjectno{} when none is set.
totalSpendnumbernoTo two places.
mergedIntoIdintegernullableSet on a vendor that was merged into another.
PurchaseOrderin a reply14 fields

One row of GET /parser/finance/purchase-orders.

FieldTypeNull / optional / read-onlyNote
idintegerno
poNumberstringno
vendorstringnullable
currencystringnoA 3-letter code.
totalAmountnumbernoIn the order's own currency.
totalAmountBasenumbernoIn your base currency: the figure budgets and matching use.
baseConvertedbooleannofalse when no converted value is stored; totalAmountBase is then the entered figure.
statusstringnoopen until the order is closed.
departmentstringnullable
costCenterstringnullable
projectstringnullable
lineCountintegernoIn the list only.
grnCountintegernoIn the list only: goods receipts recorded against the order.
orderedDatestringnullableA display date such as "Oct 10, 2026".
MatchExceptionin a reply14 fields
FieldTypeNull / optional / read-onlyNote
idintegerno
docIdintegernoThe invoice.
displayIdstringno
vendorstringnullable
poNumberstringnullable
exceptionTypestringno
severitystringno
statusstring: open | waived | resolvedno
expectednumbernullable
actualnumbernullable
variancePctnumbernullable
tolerancePctnumbernullable
reasonstringnullable
createdAtstring (date-time)nullable
ApprovalQueueItemin a reply14 fields

One submitted invoice in GET /parser/finance/approvals-queue.

FieldTypeNull / optional / read-onlyNote
db_idintegernoThe invoice id.
displayIdstringno
vendorstringnullable
amountnumberno
amountBasenumberno
currencystringno
matchStatusstringno
poNumberstringnullable
approvalsCountintegernoApprovals recorded so far.
approvalsRequiredintegernoWhat the approval rule for this amount asks for.
approversstring[]noThe names of the people who have approved.
ageDaysintegernullableDays since it was submitted.
submittedAtstring (date-time)nullable
highValuebooleanno
ApprovalRulein a reply6 fields

How many approvers an invoice of a given amount needs.

FieldTypeNull / optional / read-onlyNote
idintegerno
namestringno
minAmountnumbernoIn your base currency.
maxAmountnumbernullablenull for no upper limit.
requiredApprovalsintegerno
isActivebooleanno
GLAccountin a reply5 fields
FieldTypeNull / optional / read-onlyNote
idintegerno
codestringno
namestringno
typestringnullable
isActivebooleanno
Budgetin a reply62 fields

All the money fields are in your base currency except budgetEntered.

FieldTypeNull / optional / read-onlyNote
idintegerno
scopeTypestringnoglobal when the budget covers all spend.
scopeValuestringnullable
labelstringnoscopeValue, or "All spend".
namestringnullable
displayNamestringno
descriptionstringnullable
budgetTypestringno
startDatestring (date-time)nullable
endDatestring (date-time)nullable
periodKindstringnullable
authoritativeSourcestring: invoice | cloud | bothnoWhich actuals drive consumedPct.
currencystringnullableThe currency the budget was entered in.
tagsarrayno
glAccountIdintegernullable
recurrencestringnullable
recurrenceIntervalstringnullable
warnPctnumbernullableThis budget's own warning threshold, as a fraction.
overPctnumbernullable
warnThresholdnumbernoThe threshold in force, as a fraction.
overThresholdnumberno
ownerEmailstringnullable
ownerUserIdintegernullable
ownerDepartmentstringnullable
periodstringno
budgetnumbernoThe budget amount in base currency.
budgetEnterednumbernoAs entered, in currency.
baseCurrencystringno
actualnumbernoPer authoritativeSource.
invoiceActualnumberno
cloudActualnumberno
cloudSyncedbooleanno
cloudSystemCountintegerno
committednumbernoOpen purchase-order value that has not been invoiced yet.
softCommittednumbernoPending and approved spend requests.
availablenumbernobudget minus actual, committed and softCommitted.
remainingnumbernobudget minus actual.
variancenumberno
baselineBudgetnumbernoThe first version of this budget.
varianceVsBaselinenumberno
varianceTypestring: favorable | unfavorableno
consumedPctnumbernoA percentage to one place.
projectednumberno
projectedPctnumberno
onPaceToExceedbooleanno
statusstring: OK | Warning | OvernoThe consumption status.
lifecycleStatusstringnoDraft, Submitted, Approved or Rejected.
pendingReapprovalbooleanno
approvedAmountBasenumbernullable
reapprovalEditorstringnullable
submittedByintegernullable
approvedByintegernullable
rejectionReasonstringnullable
versionNointegerno
parentBudgetIdintegernullable
isCurrentbooleanno
lockedbooleanno
versionLabelstringnullable
methodstringno
hasLinesbooleanno
frozenbooleanno
frozenReasonstringnullable
BudgetLinein a reply12 fields
FieldTypeNull / optional / read-onlyNote
idintegerno
budgetIdintegerno
labelstringno
glAccountIdintegernullable
categorystringnullable
costCenterstringnullable
amountnumberno
justificationstringnullable
driverKeystringnullable
driverQtynumbernullable
unitCostnumbernullable
sortOrderintegerno
BudgetCommentin a reply6 fields
FieldTypeNull / optional / read-onlyNote
idintegerno
budgetIdintegerno
authorIdintegernullable
authorstringnullable
commentstringno
createdAtstring (date-time)nullable
BudgetTransferin a reply22 fields
FieldTypeNull / optional / read-onlyNote
idintegerno
amountnumberno
reasonstringnullable
statusstringno
currencystringnullable
amountBasenumberno
fromAmountnumbernullableWhat leaves the source budget, in its own currency.
fromCurrencystringnullable
toAmountnumbernullable
toCurrencystringnullable
fromBudgetIdintegerno
toBudgetIdintegerno
fromLabelstringno
toLabelstringno
requestedByintegernullable
requestedByEmailstringnullable
requestedAtstring (date-time)nullable
approvedByintegernullable
approvedAtstring (date-time)nullable
rejectionReasonstringnullable
approvalsHaveintegernoDistinct approvers recorded so far.
approvalsNeedintegernoWhat a transfer of this amount requires.
SpendRequestin a reply14 fields
FieldTypeNull / optional / read-onlyNote
idintegerno
requesterEmailstringnullable
departmentstringnullable
budgetIdintegernullable
budgetNamestringnullable
amountnumberno
amountBasenumbernullable
currencystringnullable
descriptionstringnullable
vendorstringnullable
statusstringno
createdAtstring (date-time)nullable
approvedAtstring (date-time)nullable
rejectionReasonstringnullable
BudgetAlertin a reply9 fields
FieldTypeNull / optional / read-onlyNote
idintegerno
budgetIdintegerno
tierstringno
messagestringnullable
consumedPctnumbernullable
ownerEmailstringnullable
ownerDepartmentstringnullable
createdAtstring (date-time)nullable
acknowledgedAtstring (date-time)nullable
InvoiceDetailin a reply26 fields

One invoice as the review screen reads it. Not the Invoice object of the list: the field names differ.

FieldTypeNull / optional / read-onlyNote
idintegerno
displayIdstringnoINV- and the id, padded to three digits.
filenamestringnullable
hasFilebooleannoWhether the uploaded file is stored.
fileContentTypestringnullable
vendorstringnullable
amountnumbernoIn currency.
currencystringnoUSD when none was read.
currencyDefaultedbooleannotrue when the currency was filled from the instance default.
amountAmbiguousbooleannotrue when amountReviewNote is set.
amountReviewNotestringnullable
dateReviewNotestringnullable
submitterNotestringnullable
baseCurrencystringno
amountBasenumbernullable
dateIsostring (date-time)nullable
categorystringnullable
statusstringnullable
approvalStatusstringnoDraft when none is stored.
extractionConfidencenumbernullable
invoiceNumberstringnullable
taxAmountnumbernullable
poNumberstringnullable
matchStatusstringnounmatched when none is stored.
lineItemsobject[]noIn line order, each { lineNo:integer, description:string|null, quantity:number, unitPrice:number, amount:number }.
rawTextstringnoThe text read from the file; empty when there is none.
PurchaseOrderDetailin a reply15 fields

One purchase order with its lines and goods receipts.

FieldTypeNull / optional / read-onlyNote
idintegerno
poNumberstringno
vendorstringnullable
currencystringno
totalAmountnumbernoIn currency.
totalAmountBasenumbernoIn base currency.
baseConvertedbooleannofalse when no base amount was stored for the order.
statusstringnoopen, partially_received, received, closed or cancelled.
departmentstringnullable
costCenterstringnullable
projectstringnullable
notesstringnullable
orderedDatestringnullableDisplay text such as "Oct 10, 2026".
linesobject[]noIn line order, each { id:integer, lineNo:integer, description:string|null, quantity:number, unitPrice:number, amount:number, qtyReceived:number, qtyInvoiced:number }.
goodsReceiptsobject[]noNewest first, each { id:integer, grnNumber:string|null, receivedDate:string|null (display text) }.
OwnedSystemin a reply13 fields

A system linked to an owner and, optionally, a budget, with the cost its provider reports.

FieldTypeNull / optional / read-onlyNote
idintegerno
labelstringno
providerTypestringno
kindstringnullable
systemRefstringnullable
budgetIdintegernullable
meteringstring: live | derived | discoveryno
ownerEmailstringnullable
ownerDepartmentstringnullable
costnumberno0 when the provider could not be read.
forecastnumberno
isSyncedbooleanno
lastSyncstringnullable
BulkOutcomeItemin a reply5 fields
FieldTypeNull / optional / read-onlyNote
idanynoThe id as an integer, or the value you sent when it could not be read as one.
okbooleanno
errorstringnoPresent only when ok is false: the reason this item was not done.
approvalStatusstringnoPresent only in the invoice bulk reply, on an item that was done by submit, approve, reject, pay or clear_review: the invoice's approval status after the action.
kindstring: budget | transfernoPresent only in the budget-approvals bulk reply.
BulkOutcomein a reply4 fields

What a bulk call did. Each item is handled and saved on its own, so a failed item never undoes the others. A valid id sent twice appears once; a value that cannot be read as an id is reported each time it is sent.

FieldTypeNull / optional / read-onlyNote
actionstringnoThe action you sent. Present only in the invoice bulk reply and the budget-approvals bulk reply.
okintegernoHow many items were done.
failedintegernoHow many were not.
resultsBulkOutcomeItem[]noOne entry per distinct valid id, plus one for each value that could not be read as an id.
InvoiceImportSummaryin a reply8 fields
FieldTypeNull / optional / read-onlyNote
importedintegernoRows stored as invoices.
skippedintegernoRows not stored.
errorsstring[]noOne message per skipped row; the first 50 are returned.
currencyDefaultedintegernoRows that named no currency and were given the base currency.
amountAmbiguousintegernoRows held for review because the amount could be read two ways.
dateHeldintegernoRows held for review because the date could not be read with certainty.
duplicatesSkippedintegernoRows skipped because the same vendor and invoice number are already on file or repeated in the file.
duplicatesHeldintegernoRows stored but held for review because another invoice has the same vendor, amount and date.
CurrencyRatein a reply3 fields
FieldTypeNull / optional / read-onlyNote
codestringnoThree letters.
rateToBasenumbernoHow many units of the base currency one unit of code is worth.
updatedAtstring (date-time)noUTC.
CurrencyTablein a reply3 fields
FieldTypeNull / optional / read-onlyNote
baseCurrencystringno
missingRatesarraynoCurrencies in use on invoices, purchase orders, budgets or spend requests that have no rate, ordered by code; each entry carries code, base and a count for documents, purchaseOrders, budgets and spendRequests.
ratesCurrencyRate[]noEvery stored rate, ordered by code.
VendorMergeResultin a reply5 fields
FieldTypeNull / optional / read-onlyNote
successbooleannoAlways true.
keptVendorIdintegerno
repointedintegernoHow many invoices moved to the kept vendor.
skippedLockedarraynoThe invoices left on the merged vendor because their accounting period is locked; each entry carries id, period and status.
loserRetainedbooleannotrue when the merged vendor was kept because invoices in a locked period still point at it.
PeriodCloseResultin a reply5 fields
FieldTypeNull / optional / read-onlyNote
periodstringno
statusstringnoAlways locked.
journalsPostedintegernoInvoices that were journalled by this close; one already journalled is not posted again.
batchstringnoThe period, a hyphen and the UTC time of the close to the second.
forcedbooleannotrue when the period was locked over open items.
GLSyncRunin a reply4 fields
FieldTypeNull / optional / read-onlyNote
connectorstringno
pushedintegernoJournal lines marked as synced by this call.
livebooleannofalse for the manual connector.
notestringno
AlertSweepResultin a reply2 fields
FieldTypeNull / optional / read-onlyNote
evaluatedintegernoCurrent budgets looked at.
firedintegernoAlerts raised by this call.
InvoiceReviewrequest body8 fields

Corrections to what was read from an uploaded invoice. Only the keys you send are changed.

FieldTypeRequiredNote
vendorstringoptionalTrimmed; an empty value changes nothing.
categorystringoptionalTrimmed; an empty value changes nothing.
currencystringoptionalUpper-cased and cut to 3 letters.
datestring (date-time)optionalISO 8601; the first 19 characters are read and text that is not a date changes nothing. A date in a locked accounting period is refused.
amountnumberoptionalA number, or text in a recognised number format. An entry that could be read two ways is refused with 400.
po_numberstringoptional, null clears itTrimmed, upper-cased and cut to 64 characters.
notestringoptional, null clears itTrimmed and cut to 1000 characters.
commitbooleanoptionaltrue releases an invoice held for review to Draft. Ignored unless the caller holds manage_finance.
InvoiceAllocationrequest body6 fields

Where an invoice is charged. Only the keys you send are changed. Refused with 409 once the invoice is Submitted, Approved or Paid.

FieldTypeRequiredNote
costCenterstringoptional, null clears it
departmentstringoptional, null clears it
projectstringoptional, null clears it
glCodestringoptional, null clears itOnce a chart of accounts exists the code must be in it, or the answer is 400.
currencystringoptionalUpper-cased and cut to 3 letters; the base amount is worked out again. 400 when no exchange rate is set for it.
customFieldsobjectoptionalReplaces the stored set. Keys that are not in the allocation custom-field schema, and empty values, are dropped.
InvoiceActionrequest body4 fields

The body of an approval step. Which keys are read depends on the action in the path; submit and clear_review read none.

FieldTypeRequiredNote
reasonstringrequired for rejectBlank is refused with 400.
budget_overridebooleanoptionalapprove only: approve although the invoice takes a budget over its limit. Needs approve_invoice.
override_reasonstringrequired with budget_overrideBlank is refused with 400.
commentstringoptionalapprove only: stored with the approval record.
InvoiceBulkActionrequest body7 fields

One action for many invoices. Each invoice is handled on its own and reported in results; an id sent twice is handled once.

FieldTypeRequiredNote
actionstring: submit | approve | reject | pay | clear_review | allocate | deleterequired
idsinteger[]required1 to 500 invoice ids.
allocationInvoiceAllocationoptionalRead for allocate only; without it nothing is changed.
reasonstringrequired for rejectOne reason shared by every invoice.
budget_overridebooleanoptionalapprove only, as in InvoiceAction.
override_reasonstringrequired with budget_override
commentstringoptionalapprove only, as in InvoiceAction; one comment shared by every invoice.
VendorUpdaterequest body9 fields

Only the keys you send are changed.

FieldTypeRequiredNote
canonicalNamestringoptionalA name another vendor already has is refused with 409. Budgets scoped to the old name follow the new one.
categorystringoptional, null clears it
ownerstringoptional, null clears it
ownerUserIdintegeroptional, null clears it
contractRefstringoptional, null clears it
contractRenewalstring (date-time)optional, null clears itISO 8601; text that is not a date is stored as empty.
notesstringoptional, null clears it
aliasesstring[]optionalReplaces the alias list; each alias is normalised the way vendor names are matched.
customFieldsobjectoptionalReplaces the stored set. Keys that are not in the vendor custom-field schema, and empty values, are dropped.
CustomFieldDefinitionrequest body4 fields

One entry of a custom-field schema (vendor or allocation).

FieldTypeRequiredNote
keystringrequiredCharacters other than letters, digits and _ become _. An entry with an empty key, or a key already used in the same call (ignoring case), is skipped.
labelstringoptionalCut to 60 characters. Defaults to the key.
typestring: text | select | number | dateoptionalAny other value is stored as text.
optionsstring[]optionalRead for select only: each cut to 60 characters, duplicates dropped, the first 50 kept.
CurrencyPolicyUpdaterequest body3 fields

Sets the base currency, one exchange rate, or both. The base totals of invoices that are not yet in approval are then worked out again.

FieldTypeRequiredNote
baseCurrencystringoptionalThree letters. Refused with 400 once invoices, purchase orders or budgets exist, or while the new currency has no rate.
codestringrequired with rateToBaseUpper-cased and cut to 3 letters.
rateToBasenumberrequired with codeHow many units of the base currency one unit of code is worth. Greater than 0, between 0.000001 and 1000000.
FinanceSettingWriterequest body2 fields

One setting per call. The keys, their kinds and defaults are what GET /parser/finance/settings lists.

FieldTypeRequiredNote
keystring: enforce_sod | finance.high_value_floor | finance.review_confidence_floor | match.tolerance_pct | match.require_grn | finance.llm_extraction | finance.digest_enabled | finance.digest_recipients | finance.digest_interval_hours | finance.fiscal_year_start_month | finance.budget_warn_pct | finance.budget_over_pct | finance.cloud_anomaly_pctrequiredAny other key is refused with 400.
valueanyrequiredA yes/no setting stores true for true, 1, yes or on and false for anything else. A number setting refuses text with 400 and is brought inside its range: finance.review_confidence_floor 0 to 1, match.tolerance_pct 0 to 0.5, finance.fiscal_year_start_month a whole number 1 to 12.
PurchaseOrderLineInputrequest body3 fields
FieldTypeRequiredNote
descriptionstringoptional
quantitynumberoptionalDefault 0.
unit_pricenumberoptionalDefault 0. The line amount is quantity times unit_price; an amount you send is not read.
PurchaseOrderCreaterequest body9 fields
FieldTypeRequiredNote
po_numberstringrequiredTrimmed and upper-cased. A number that already exists is refused with 409.
vendorstringoptionalTrimmed and matched to a known vendor where one exists.
currencystringoptionalDefault USD. Upper-cased and cut to 3 letters; 400 when no exchange rate is set for it.
linesPurchaseOrderLineInput[]optionalWhen present, the order total is the sum of the lines.
total_amountnumberoptionalRead only when lines is empty. Default 0.
cost_centerstringoptional
departmentstringoptional
projectstringoptional
budget_overridebooleanoptionaltrue creates the order although it takes a budget over its limit; without it that case answers 409.
GoodsReceiptLineInputrequest body2 fields
FieldTypeRequiredNote
po_line_idintegeroptionalThe purchase-order line received against.
quantity_receivednumberoptionalDefault 0.
GoodsReceiptCreaterequest body3 fields
FieldTypeRequiredNote
po_idintegerrequired404 when the purchase order does not exist.
grn_numberstringoptional
linesGoodsReceiptLineInput[]optional
ApprovalRuleWriterequest body5 fields

Create and update take the same keys; an update changes only the keys you send. Amounts are in your base currency.

FieldTypeRequiredNote
namestringoptionalAt most 80 characters. Default "Rule".
minAmountnumberoptionalDefault 0. Not negative; at most 999999999999.99.
maxAmountnumberoptional, null clears itnull means no ceiling. Otherwise greater than minAmount and at most 999999999999.99.
requiredApprovalsintegeroptionalDefault 1. A whole number from 1 to 10.
isActivebooleanoptionalDefault true.
AdjustmentCreaterequest body4 fields

An adjusting journal entry in the current open period: one debit and one credit of the same amount, in base currency.

FieldTypeRequiredNote
reasonstringrequiredBlank is refused with 400.
debitGlstringrequiredA general-ledger code. Once a chart of accounts exists it must be in it.
creditGlstringrequiredMust differ from debitGl.
amountnumberrequiredGreater than 0; rounded to 2 places.
BudgetWriterequest body19 fields

Create and update take the same keys. An update changes only the keys you send. The owner is set by the server and is not read from the body.

FieldTypeRequiredNote
periodstringrequired on createYYYY-MM or YYYY.
scopeTypestringoptionalDefault global. A caller without a finance-wide budget permission may send only department, and it is set to their own department.
scopeValuestringoptional, null clears itThe department, cost centre or vendor the budget covers.
amountnumberoptionalDefault 0. A negative number or text that is not a number is stored as 0.
currencystringoptional, null clears itThe currency amount is entered in; empty means the base currency. 400 when no exchange rate is set for it.
namestringoptional, null clears it
descriptionstringoptional, null clears it
budgetTypestringoptionalDefault opex.
methodstringoptionalDefault top_down.
startDatestring (date-time)optional, null clears itISO 8601; text that is not a date is stored as empty.
endDatestring (date-time)optional, null clears it
periodKindstringoptional, null clears it
authoritativeSourcestring: invoice | cloud | bothoptionalDefault invoice. An update ignores any other value.
tagsarrayoptionalRead only when it is a list.
glAccountIdintegeroptional, null clears it
recurrencestringoptional, null clears it
recurrenceIntervalstringoptional, null clears it
warnPctnumberoptional, null clears itA fraction; brought inside 0 to 1.
overPctnumberoptional, null clears itA fraction; brought inside 0 to 1.
BudgetLineWriterequest body10 fields

Create and update take the same keys; an update changes only the keys you send. The line amount is driverQty times unitCost when both are known, otherwise amount.

FieldTypeRequiredNote
labelstringoptionalDefault "Line".
amountnumberoptionalRounded to 2 places. Not read when driverQty and unitCost are both set.
driverKeystringoptionalWith headcount or fte and no driverQty, the quantity is the head count of the budget's department where the directory holds one.
driverQtynumberoptional
unitCostnumberoptional
glAccountIdintegeroptional
categorystringoptional
costCenterstringoptional
justificationstringoptional
sortOrderintegeroptionalDefault 0.
BudgetTransferCreaterequest body5 fields
FieldTypeRequiredNote
fromBudgetIdintegerrequired
toBudgetIdintegerrequiredMust differ from fromBudgetId.
amountnumberrequiredGreater than 0.
currencystringoptionalThe currency amount is entered in; empty means the base currency. 400 when an exchange rate is missing for any side.
reasonstringoptional
BudgetApprovalsBulkrequest body4 fields

Approve or reject budgets and transfers in one call. Each item is decided on its own and reported in results.

FieldTypeRequiredNote
actionstring: approve | rejectrequired
budgetIdsinteger[]optionalTogether with transferIds: at least 1 and at most 500 items.
transferIdsinteger[]optional
reasonstringrequired to reject a budgetStored as the rejection reason. Without it each budget in a reject is reported in results as failed; a transfer can be rejected without one.
SpendRequestCreaterequest body5 fields
FieldTypeRequiredNote
budgetIdintegerrequiredA budget in your scope: 404 when it does not exist, 403 when it is outside your scope.
amountnumberrequiredGreater than 0.
currencystringoptionalEmpty means the base currency. 400 when no exchange rate is set for it.
descriptionstringoptional
vendorstringoptional
OwnedSystemClaimrequest body7 fields

Attaches a discovered system to an owner and, optionally, a budget.

FieldTypeRequiredNote
providerTypestringoptionalUpper-cased. AWS, AZURE and AZURE_AD are metered live; GOOGLE_WORKSPACE and GITHUB are derived; any other value is recorded as discovery.
externalProviderIdintegeroptionalA provider id from GET /parser/finance/systems/providers.
systemRefstringoptional
labelstringoptionalDefaults to systemRef, then providerType, then "System".
kindstringoptional
budgetIdintegeroptionalA budget you can edit: 404 when it does not exist, 403 when it is not yours to edit.
ownerEmailstringoptionalDefaults to you. Ignored for a caller without a finance-wide budget permission, who always becomes the owner.
DocumentStatusWriterequest body1 field
FieldTypeRequiredNote
statusstringrequiredStored as sent. paid and approved (in any letter case) are refused with 403: those are reached through the approval steps.
FinanceQuestionrequest body1 field
FieldTypeRequiredNote
questionstringrequiredTrimmed; blank is refused with 400.
AppBudgetWriterequest body1 field

The yearly budget of one app (vendor), for the current calendar year.

FieldTypeRequiredNote
amountnumberoptionalGreater than 0 sets the budget. Zero, a negative number, a missing key or text that is not a number clears it.
VendorBulkPatchrequest body2 fields

At least one of the two keys. Any other key is not read.

FieldTypeRequiredNote
categorystringoptional
ownerstringoptional
VendorBulkUpdaterequest body2 fields
FieldTypeRequiredNote
idsinteger[]required1 to 500 vendor ids; the limit of 500 is counted before duplicates are removed. A valid id sent twice is handled once.
patchVendorBulkPatchrequired
VendorMergerequest body2 fields
FieldTypeRequiredNote
keepIdintegerrequiredThe vendor that stays.
mergeIdintegerrequiredThe vendor whose invoices and aliases move to keepId.
DigestSendrequest body1 field
FieldTypeRequiredNote
recipientsstring[]optionalEmail addresses. Without it the configured digest recipients are used.
PurchaseOrderStatusWriterequest body1 field
FieldTypeRequiredNote
statusstring: closed | cancelled | openoptionalDefault closed. Any other value is refused with 400.
PurchaseOrderBulkStatusrequest body2 fields
FieldTypeRequiredNote
idsinteger[]required1 to 500 purchase-order ids; the limit of 500 is counted before duplicates are removed. A valid id sent twice is handled once.
statusstring: closed | cancelled | openoptionalDefault closed.
MatchExceptionWaiverequest body1 field
FieldTypeRequiredNote
reasonstringrequiredTrimmed; blank is refused with 400.
MatchExceptionBulkWaiverequest body2 fields
FieldTypeRequiredNote
idsinteger[]required1 to 500 match-exception ids; the limit of 500 is counted before duplicates are removed. A valid id sent twice is handled once.
reasonstringrequiredOne reason shared by every exception.
GLAccountCreaterequest body3 fields
FieldTypeRequiredNote
codestringrequiredTrimmed. A code that already exists is refused with 409.
namestringoptionalDefaults to the code.
typestringoptionalThe account type, stored as sent.
PeriodCloserequest body2 fields
FieldTypeRequiredNote
forcebooleanoptionaltrue locks the period although it still has open items.
reasonstringrequired with forceTrimmed; blank with force is refused with 400.
GLConnectorWriterequest body1 field
FieldTypeRequiredNote
connectorstring: manual | quickbooks | netsuite | xerorequiredTrimmed and lower-cased. Any other value is refused with 400.
BudgetRejectrequest body1 field
FieldTypeRequiredNote
reasonstringrequiredTrimmed; blank is refused with 400.
BudgetLockrequest body1 field
FieldTypeRequiredNote
lockedbooleanoptionalDefault true. false unlocks, which needs manage_finance_policy.
BudgetFreezerequest body2 fields
FieldTypeRequiredNote
frozenbooleanoptionalDefault true. false lifts the freeze.
reasonstringoptionalStored with a freeze; not kept when the freeze is lifted.
BudgetCopyrequest body1 field
FieldTypeRequiredNote
periodstringrequiredThe period of the copy: YYYY-MM or YYYY.
BudgetCommentCreaterequest body1 field
FieldTypeRequiredNote
commentstringrequiredBlank is refused with 400.
TransferRejectrequest body1 field
FieldTypeRequiredNote
reasonstringoptionalTrimmed; stored as the rejection reason.
SpendRequestDecisionrequest body1 field

Read for the reject and cancel actions only; approve and fulfill read no body.

FieldTypeRequiredNote
reasonstringoptional

Related reference