On this page0
Unfamiliar word? See Glossary: words used in these docs. Looking for the tasks in order? Begin at Start Here.
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
Budgets Block, They Don't Just Colour
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.
| Tab | What it is for | Shown to | Documented under |
|---|---|---|---|
| Overview | Spend figures and charts for the whole ledger. | everyone who can open the workspace | Trends, Open Commitments & SaaS Spend |
| Bills | Upload bills and see what was read from each. | manage_finance | Extraction & The Human Review Gate |
| Review | Bills held for review, with the original beside the fields that were read. | manage_finance | Extraction & The Human Review Gate |
| Ledger | Every invoice, with its approval actions. | everyone who can open the workspace | Invoices: List, Detail & Self-Service |
| Match | The purchase-order register and the match exceptions that block approval. | manage_finance | 2-/3-Way PO Matching Blocks Approval |
| Approvals | The queue of submitted bills waiting for an approver. | manage_finance · approve_invoice · pay_invoice | The Workflow Endpoint & Who May Do Each |
| Trends | Spend over time. | everyone who can open the workspace | Trends, Open Commitments & SaaS Spend |
| Budgets & Chargeback | Budgets against actuals, budget approvals, chargeback, spend requests and budget transfers. | everyone who can open the workspace | Budgets (CRUD) and the sections after it |
| Systems | The systems whose cost is tracked. | everyone who can open the workspace | Systems & Cloud Cost Against A Budget |
| SaaS Spend | Spend per application. | everyone who can open the workspace | Trends, Open Commitments & SaaS Spend |
| Vendors | The vendor list: edit a vendor, or merge one vendor into another. | manage_finance | Vendors & Vendor Merge |
| Close | The month-end checklist, the period lock, the journal export and adjusting entries. | manage_finance · close_finance | General Ledger, Month-End Close & Journal Export |
| AI Insights | Ask a question about your spend in plain words. | everyone who can open the workspace | Ask The Ledger |
| Audit | The finance audit trail. | everyone who can open the workspace | Audit Trail & Digest |
| Settings | Finance settings, exchange rates and approval rules. | manage_finance · manage_finance_policy | Finance 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": "…" }.
| Method | Path | Purpose | Auth |
|---|---|---|---|
| POST | /api/parser/finance/documents/{id}/submit | Draft or Rejected → Submitted; stamps submitted_by / submitted_at, clears any rejection reason | manage_finance | approve_invoice | pay_invoice |
| POST | /api/parser/finance/documents/{id}/approve | Submitted → Approved; runs SoD + budget checks; stamps approved_by | approve_invoice (>= high-value); else manage_finance | approve_invoice |
| POST | /api/parser/finance/documents/{id}/reject | Submitted → Rejected; requires a non-empty reason in the body (400 without one) | manage_finance | approve_invoice |
| POST | /api/parser/finance/documents/{id}/pay | Approved → Paid; stamps paid_by / paid_at; syncs legacy status=Paid | pay_invoice (>= high-value); else manage_finance | pay_invoice |
| POST | /api/parser/finance/documents/{id}/clear_review | NeedsReview → Draft without editing fields; 409 while an ambiguous amount or a flagged invoice date is unconfirmed, 400 while the currency has no rate | manage_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.
| Method | Path | Purpose | Auth |
|---|---|---|---|
| GET | /api/parser/invoices | The 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, search | view_finance | manage_finance |
| GET | /api/parser/stats | The spend totals behind the Overview tab | view_finance | manage_finance |
| GET | /api/parser/finance/insights | Duplicate-invoice and price-increase alerts, computed on the instance | view_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}/file | The 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 it | view_finance | manage_finance | submit_invoice |
| GET | /api/parser/my-documents | The 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}/timeline | Where 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 period | manage_finance | submit_invoice |
| GET | /api/parser/finance/categories | The category names offered in the category picker | view_finance | manage_finance | submit_invoice |
| GET | /api/parser/finance/allocation-values | Cost centres, projects and departments already in use, for the allocation pickers | view_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 fromuserId(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 fromsubmitted_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.
| Method | Path | Purpose | Auth |
|---|---|---|---|
| GET | /api/parser/finance/settings | The editable finance settings with current value, default and whether each is a critical control | view_finance | manage_finance |
| PUT | /api/parser/finance/settings | Set one setting: { key, value }. Numbers are clamped to the setting’s allowed range; audited | manage_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.
| Method | Path | Purpose | Auth |
|---|---|---|---|
| GET | /api/parser/finance/approval-rules | List 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 needs | view_finance | manage_finance | manage_finance_policy |
| POST | /api/parser/finance/approval-rules | Create 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 band | manage_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.
| Method | Path | Purpose | Auth |
|---|---|---|---|
| POST | /api/parser/finance/purchase-orders | Create a PO (with optional line items); 409 if the PO number already exists or the budget cannot cover it | manage_finance |
| GET | /api/parser/finance/purchase-orders | PO register; optional ?status= filter | view_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}/close | Set status: closed (default), cancelled or open; audited | manage_finance |
| POST | /api/parser/finance/purchase-orders/bulk-close | Same for up to 500 POs: { ids, status }; per-PO result, a bad id never aborts the batch | manage_finance |
| POST | /api/parser/finance/goods-receipts | Record 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 change | manage_finance |
| GET | /api/parser/finance/match-exceptions | Open + resolved match exceptions (expected/actual/variance per row) | view_finance | manage_finance |
| POST | /api/parser/finance/match-exceptions/{exc_id}/waive | Waive one exception with a required reason (audited); unblocks approve | manage_finance |
| POST | /api/parser/finance/match-exceptions/bulk-waive | Waive up to 500 exceptions with one shared reason: { ids, reason }; an ineligible one is skipped and reported | manage_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).
| Method | Path | Purpose | Auth |
|---|---|---|---|
| GET | /api/parser/finance/budgets | List the budgets the caller may see, with computed actual, committed, available, consumedPct, status. Filters: budgetType, status, period, scopeType, department, over, atRisk, sort | any budget read permission |
| POST | /api/parser/finance/budgets | Create a Draft budget; period is required (400 if missing); 403 for a scope outside the caller’s department | any 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
DraftorRejectedbudget, 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
Draftversion; earlier versions stay readable. - Unlock: needs
manage_finance_policy. Any edit after an unlock sends the budget back toSubmittedfor 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.
| Method | Path | Purpose | Auth |
|---|---|---|---|
| POST | /api/parser/finance/budgets/{id}/submit | Draft or Rejected → Submitted | manage_finance | manage_all_finance_budgets | manage_finance_budget | submit_finance_budget |
| POST | /api/parser/finance/budgets/{id}/approve | Submitted → 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}/reject | Submitted → Rejected; { reason } required | manage_finance | manage_all_finance_budgets | approve_finance_budget |
| POST | /api/parser/finance/budgets/{id}/reforecast | Create a new Draft version of your own budget | same as submit |
| POST | /api/parser/finance/budgets/{id}/lock | { locked: true | false }. Unlocking needs manage_finance_policy | manage_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}/versions | Version history of a budget | any budget read permission |
| POST | /api/parser/finance/budgets/{id}/copy | Copy the budget and its lines into a new Draft for another period: { period } | any budget write permission |
| GET | /api/parser/finance/budgets/approvals-inbox | Budgets and transfers waiting for the caller’s approval | any budget read permission |
| POST | /api/parser/finance/approvals/bulk | Approve or reject up to 500 budgets and transfers: { action, budgetIds, transferIds, reason }; each item is checked on its own | per 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.
| Method | Path | Purpose | Auth |
|---|---|---|---|
| GET | /api/parser/finance/budgets/{id}/lines | Line items of a budget | any budget read permission |
| POST | /api/parser/finance/budgets/{id}/lines | Add a line (PATCH / DELETE …/lines/{line_id} to edit or remove) | any budget write permission |
| GET | /api/parser/finance/budgets/{id}/comments | Comments on a budget (POST { comment } to add one) | any budget read permission |
| GET | /api/parser/finance/budgets/{id}/actuals | The spend that makes up this budget’s actual | any budget read permission |
| GET | /api/parser/finance/budgets/{id}/forecast-accuracy | Forecast vs outcome for the budget | any budget read permission |
| GET | /api/parser/finance/budgets/alerts | Open alerts; ?includeAck=true adds acknowledged ones | any budget read permission |
| POST | /api/parser/finance/budgets/alerts/{alert_id}/ack | Acknowledge an alert (404 if not yours to see) | any budget read permission |
| POST | /api/parser/finance/budgets/alerts/sweep | Run the threshold sweep now | manage_finance | manage_all_finance_budgets | view_finance_consolidated |
| GET | /api/parser/finance/budgets/summary | Organisation-wide rollup by department; optional ?period= | view_finance | manage_finance | view_finance_consolidated | manage_all_finance_budgets |
| GET | /api/parser/finance/budgets/export.csv | Budget vs actual vs committed, scoped to the caller | any budget read permission |
| GET | /api/parser/finance/budgets/audit | Budget 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.
| Method | Path | Purpose | Auth |
|---|---|---|---|
| GET | /api/parser/finance/spend-requests | Open requests in the caller’s scope; ?includeClosed=true adds closed ones | any budget read permission |
| POST | /api/parser/finance/spend-requests | Create: { budgetId, amount, currency, vendor, description }. 400 on a non-positive amount or a currency with no rate; 403 for a budget outside your scope | any budget read permission |
| POST | /api/parser/finance/spend-requests/{id}/approve | Pending → Approved (409 if not Pending) | manage_finance | manage_all_finance_budgets | approve_finance_budget |
| POST | /api/parser/finance/spend-requests/{id}/reject | Pending → Rejected; optional { reason } | manage_finance | manage_all_finance_budgets | approve_finance_budget |
| POST | /api/parser/finance/spend-requests/{id}/fulfill | Approved → Fulfilled; releases the hold | manage_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.
| Method | Path | Purpose | Auth |
|---|---|---|---|
| GET | /api/parser/finance/transfers | Transfers the caller may see | transfer_finance_budget | manage_all_finance_budgets |
| POST | /api/parser/finance/transfers | Request: { fromBudgetId, toBudgetId, amount, currency, reason } | transfer_finance_budget | manage_all_finance_budgets |
| POST | /api/parser/finance/transfers/{id}/approve | Record an approval; the money moves when the required number of distinct approvers is met | transfer_finance_budget | manage_all_finance_budgets |
| POST | /api/parser/finance/transfers/{id}/reject | Reject; 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.
| Method | Path | Purpose | Auth |
|---|---|---|---|
| GET | /api/parser/finance/systems | Systems in the caller’s scope, with cost where the provider supplies it | any budget read permission |
| GET | /api/parser/finance/systems/providers | Connected providers a system can be claimed from | any budget read permission |
| POST | /api/parser/finance/systems | Claim 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/anomalies | Month-over-month cost spikes across the caller’s systems | any 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.
| Method | Path | Purpose | Auth |
|---|---|---|---|
| GET | /api/parser/finance/vendors | Canonical 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-update | Set category and/or owner on many vendors: { ids, patch }; per-vendor result, a bad id is skipped | manage_finance |
| POST | /api/parser/finance/vendors/merge | Merge 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.
| Method | Path | Purpose | Auth |
|---|---|---|---|
| GET | /api/parser/finance/currency | Base currency + rate table | view_finance | manage_finance | manage_finance_policy |
| POST | /api/parser/finance/currency | Set 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 currency | manage_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
contractRenewaldate 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.
| Method | Path | Purpose | Auth |
|---|---|---|---|
| GET | /api/parser/finance/variance | This month vs last month vs same month last year, with category movers | view_finance | manage_finance |
| GET | /api/parser/finance/category-trend | Committed spend per category; ?months= (3 to 24) | view_finance | manage_finance |
| GET | /api/parser/finance/commitments | Uninvoiced value on open purchase orders | view_finance | manage_finance |
| GET | /api/parser/finance/saas-spend | Per-app spend, seats, owner, renewal timeline and annual budget vs year to date | view_finance | manage_finance |
| PUT | /api/parser/finance/saas/{vendor_id}/budget | Set an app’s annual budget for the current year: { amount } (zero or less clears it) | manage_finance |
| GET | /api/parser/finance/connectors | Billing connectors and whether each is connected and feeding billing data | view_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.
| Method | Path | Purpose | Auth |
|---|---|---|---|
| GET | /api/parser/finance/gl-accounts | Chart of accounts | view_finance | manage_finance |
| POST | /api/parser/finance/gl-accounts | Add 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.csv | Export that period’s journal entries as CSV; ?format=generic | quickbooks | xero | view_finance | manage_finance |
| POST | /api/parser/finance/adjustments | Post 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 period | view_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
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
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.
| Method | Path | Purpose | Auth |
|---|---|---|---|
| GET | /api/parser/finance/audit | Finance action trail (filter by doc_id; limit clamped 1..500, default 200) | view_finance | manage_finance |
| GET | /api/parser/finance/digest | Finance digest summary | view_finance | manage_finance |
| POST | /api/parser/finance/digest/send | Queue the digest now; optional recipients (400 if it cannot be queued); audited | manage_finance |
| GET | /api/parser/finance/audit.csv | Controls-evidence export of the trail as CSV; optional doc_id and action filters. The export is itself audited | view_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 noorbill no.(aninvoice idcolumn 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
NeedsReviewwhen its amount separator is ambiguous, its date is flagged, or it is a likely duplicate. Every other row starts as aDraft. - Response.
imported,skipped,errors(the first 50),currencyDefaulted,amountAmbiguous,dateHeld,duplicatesSkippedandduplicatesHeld.
| Method | Path | Purpose | Auth |
|---|---|---|---|
| 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-queue | Submitted invoices, oldest first (the 200 oldest at most), with approvals given vs required, age, match state and high-value flag | view_finance | manage_finance | approve_invoice |
| POST | /api/parser/invoices/import | CSV bulk import of invoices (multipart file); 400 for a file that fails validation | manage_finance |
| GET | /api/parser/invoices/export | The (filtered) invoice ledger as CSV; export.xlsx and export.pdf for the other formats | view_finance | manage_finance |
| GET | /api/parser/finance/audit.csv | The finance audit trail as a controls-evidence CSV | view_finance | manage_finance |
| GET | /api/parser/finance/allocation-custom-fields | Your 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-fields | Your 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,objectand arrays written asstring[].string (date-time)is ISO 8601;string (date)isYYYY-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.dateandInvoice.renewalDateare display text (“Oct 10, 2026”, or “N/A”); readInvoice.dateIsoin code.PurchaseOrder.orderedDateis display text too, ornull, and the list has no ISO form of it.
Invoicein a reply31 fields
One row of GET /parser/invoices.
| Field | Type | Null / optional / read-only | Note |
|---|---|---|---|
| id | integer | no | |
| db_id | integer | no | The same number as id; the path parameter of every route that takes :doc_id. |
| displayId | string | no | INV- followed by the id, padded to three digits. |
| filename | string | nullable | |
| vendor | string | nullable | The vendor name as read from the document. |
| canonicalVendor | string | nullable | The name of the vendor record it is linked to; otherwise the same as vendor. |
| vendorId | integer | nullable | |
| amount | number | no | In the invoice's own currency; 0 when none was read. |
| currency | string | no | A 3-letter code; USD when none is stored. |
| currencyDefaulted | boolean | no | true when no currency was on the document and your base currency was assumed. |
| amountBase | number | no | The amount in your base currency; the entered amount when no conversion is stored. |
| date | string | no | A display date such as "Oct 10, 2026", or "N/A". Use dateIso in code. |
| dateIso | string (date-time) | no | |
| renewalDate | string | no | A display date, or "N/A". |
| category | string | nullable | |
| costCenter | string | nullable | |
| department | string | nullable | |
| project | string | nullable | |
| status | string | no | The payment status; Pending when none is stored. |
| approvalStatus | string | no | Draft when none is stored. See the approval states on the Finance Operations page. |
| extractionConfidence | number | nullable | |
| invoiceNumber | string | nullable | |
| taxAmount | number | nullable | |
| poNumber | string | nullable | |
| glCode | string | nullable | |
| customFields | object | no | Your own allocation fields; {} when none is set. |
| matchStatus | string | no | unmatched when no match has run. |
| submittedBy | integer | nullable | |
| approvedBy | integer | nullable | |
| paidBy | integer | nullable | |
| rejectionReason | string | nullable |
InvoicePagein a reply6 fields
The reply of GET /parser/invoices. Page-numbered: page and limit, no offset.
| Field | Type | Null / optional / read-only | Note |
|---|---|---|---|
| items | Invoice[] | no | |
| total | integer | no | Every invoice matching the filters, not the page. |
| page | integer | no | Starts at 1. |
| limit | integer | no | Default 25, 1 to 200. |
| pages | integer | no | |
| filteredTotalAmount | number | no | The sum of every matching invoice in your base currency. |
Vendorin a reply11 fields
| Field | Type | Null / optional / read-only | Note |
|---|---|---|---|
| id | integer | no | |
| canonicalName | string | no | |
| aliases | string[] | no | Other spellings that are matched to this vendor. |
| category | string | nullable | |
| owner | string | nullable | |
| contractRef | string | nullable | |
| contractRenewal | string (date-time) | nullable | |
| notes | string | nullable | |
| customFields | object | no | {} when none is set. |
| totalSpend | number | no | To two places. |
| mergedIntoId | integer | nullable | Set on a vendor that was merged into another. |
PurchaseOrderin a reply14 fields
One row of GET /parser/finance/purchase-orders.
| Field | Type | Null / optional / read-only | Note |
|---|---|---|---|
| id | integer | no | |
| poNumber | string | no | |
| vendor | string | nullable | |
| currency | string | no | A 3-letter code. |
| totalAmount | number | no | In the order's own currency. |
| totalAmountBase | number | no | In your base currency: the figure budgets and matching use. |
| baseConverted | boolean | no | false when no converted value is stored; totalAmountBase is then the entered figure. |
| status | string | no | open until the order is closed. |
| department | string | nullable | |
| costCenter | string | nullable | |
| project | string | nullable | |
| lineCount | integer | no | In the list only. |
| grnCount | integer | no | In the list only: goods receipts recorded against the order. |
| orderedDate | string | nullable | A display date such as "Oct 10, 2026". |
MatchExceptionin a reply14 fields
| Field | Type | Null / optional / read-only | Note |
|---|---|---|---|
| id | integer | no | |
| docId | integer | no | The invoice. |
| displayId | string | no | |
| vendor | string | nullable | |
| poNumber | string | nullable | |
| exceptionType | string | no | |
| severity | string | no | |
| status | string: open | waived | resolved | no | |
| expected | number | nullable | |
| actual | number | nullable | |
| variancePct | number | nullable | |
| tolerancePct | number | nullable | |
| reason | string | nullable | |
| createdAt | string (date-time) | nullable |
ApprovalQueueItemin a reply14 fields
One submitted invoice in GET /parser/finance/approvals-queue.
| Field | Type | Null / optional / read-only | Note |
|---|---|---|---|
| db_id | integer | no | The invoice id. |
| displayId | string | no | |
| vendor | string | nullable | |
| amount | number | no | |
| amountBase | number | no | |
| currency | string | no | |
| matchStatus | string | no | |
| poNumber | string | nullable | |
| approvalsCount | integer | no | Approvals recorded so far. |
| approvalsRequired | integer | no | What the approval rule for this amount asks for. |
| approvers | string[] | no | The names of the people who have approved. |
| ageDays | integer | nullable | Days since it was submitted. |
| submittedAt | string (date-time) | nullable | |
| highValue | boolean | no |
ApprovalRulein a reply6 fields
How many approvers an invoice of a given amount needs.
| Field | Type | Null / optional / read-only | Note |
|---|---|---|---|
| id | integer | no | |
| name | string | no | |
| minAmount | number | no | In your base currency. |
| maxAmount | number | nullable | null for no upper limit. |
| requiredApprovals | integer | no | |
| isActive | boolean | no |
GLAccountin a reply5 fields
| Field | Type | Null / optional / read-only | Note |
|---|---|---|---|
| id | integer | no | |
| code | string | no | |
| name | string | no | |
| type | string | nullable | |
| isActive | boolean | no |
Budgetin a reply62 fields
All the money fields are in your base currency except budgetEntered.
| Field | Type | Null / optional / read-only | Note |
|---|---|---|---|
| id | integer | no | |
| scopeType | string | no | global when the budget covers all spend. |
| scopeValue | string | nullable | |
| label | string | no | scopeValue, or "All spend". |
| name | string | nullable | |
| displayName | string | no | |
| description | string | nullable | |
| budgetType | string | no | |
| startDate | string (date-time) | nullable | |
| endDate | string (date-time) | nullable | |
| periodKind | string | nullable | |
| authoritativeSource | string: invoice | cloud | both | no | Which actuals drive consumedPct. |
| currency | string | nullable | The currency the budget was entered in. |
| tags | array | no | |
| glAccountId | integer | nullable | |
| recurrence | string | nullable | |
| recurrenceInterval | string | nullable | |
| warnPct | number | nullable | This budget's own warning threshold, as a fraction. |
| overPct | number | nullable | |
| warnThreshold | number | no | The threshold in force, as a fraction. |
| overThreshold | number | no | |
| ownerEmail | string | nullable | |
| ownerUserId | integer | nullable | |
| ownerDepartment | string | nullable | |
| period | string | no | |
| budget | number | no | The budget amount in base currency. |
| budgetEntered | number | no | As entered, in currency. |
| baseCurrency | string | no | |
| actual | number | no | Per authoritativeSource. |
| invoiceActual | number | no | |
| cloudActual | number | no | |
| cloudSynced | boolean | no | |
| cloudSystemCount | integer | no | |
| committed | number | no | Open purchase-order value that has not been invoiced yet. |
| softCommitted | number | no | Pending and approved spend requests. |
| available | number | no | budget minus actual, committed and softCommitted. |
| remaining | number | no | budget minus actual. |
| variance | number | no | |
| baselineBudget | number | no | The first version of this budget. |
| varianceVsBaseline | number | no | |
| varianceType | string: favorable | unfavorable | no | |
| consumedPct | number | no | A percentage to one place. |
| projected | number | no | |
| projectedPct | number | no | |
| onPaceToExceed | boolean | no | |
| status | string: OK | Warning | Over | no | The consumption status. |
| lifecycleStatus | string | no | Draft, Submitted, Approved or Rejected. |
| pendingReapproval | boolean | no | |
| approvedAmountBase | number | nullable | |
| reapprovalEditor | string | nullable | |
| submittedBy | integer | nullable | |
| approvedBy | integer | nullable | |
| rejectionReason | string | nullable | |
| versionNo | integer | no | |
| parentBudgetId | integer | nullable | |
| isCurrent | boolean | no | |
| locked | boolean | no | |
| versionLabel | string | nullable | |
| method | string | no | |
| hasLines | boolean | no | |
| frozen | boolean | no | |
| frozenReason | string | nullable |
BudgetLinein a reply12 fields
| Field | Type | Null / optional / read-only | Note |
|---|---|---|---|
| id | integer | no | |
| budgetId | integer | no | |
| label | string | no | |
| glAccountId | integer | nullable | |
| category | string | nullable | |
| costCenter | string | nullable | |
| amount | number | no | |
| justification | string | nullable | |
| driverKey | string | nullable | |
| driverQty | number | nullable | |
| unitCost | number | nullable | |
| sortOrder | integer | no |
BudgetCommentin a reply6 fields
| Field | Type | Null / optional / read-only | Note |
|---|---|---|---|
| id | integer | no | |
| budgetId | integer | no | |
| authorId | integer | nullable | |
| author | string | nullable | |
| comment | string | no | |
| createdAt | string (date-time) | nullable |
BudgetTransferin a reply22 fields
| Field | Type | Null / optional / read-only | Note |
|---|---|---|---|
| id | integer | no | |
| amount | number | no | |
| reason | string | nullable | |
| status | string | no | |
| currency | string | nullable | |
| amountBase | number | no | |
| fromAmount | number | nullable | What leaves the source budget, in its own currency. |
| fromCurrency | string | nullable | |
| toAmount | number | nullable | |
| toCurrency | string | nullable | |
| fromBudgetId | integer | no | |
| toBudgetId | integer | no | |
| fromLabel | string | no | |
| toLabel | string | no | |
| requestedBy | integer | nullable | |
| requestedByEmail | string | nullable | |
| requestedAt | string (date-time) | nullable | |
| approvedBy | integer | nullable | |
| approvedAt | string (date-time) | nullable | |
| rejectionReason | string | nullable | |
| approvalsHave | integer | no | Distinct approvers recorded so far. |
| approvalsNeed | integer | no | What a transfer of this amount requires. |
SpendRequestin a reply14 fields
| Field | Type | Null / optional / read-only | Note |
|---|---|---|---|
| id | integer | no | |
| requesterEmail | string | nullable | |
| department | string | nullable | |
| budgetId | integer | nullable | |
| budgetName | string | nullable | |
| amount | number | no | |
| amountBase | number | nullable | |
| currency | string | nullable | |
| description | string | nullable | |
| vendor | string | nullable | |
| status | string | no | |
| createdAt | string (date-time) | nullable | |
| approvedAt | string (date-time) | nullable | |
| rejectionReason | string | nullable |
BudgetAlertin a reply9 fields
| Field | Type | Null / optional / read-only | Note |
|---|---|---|---|
| id | integer | no | |
| budgetId | integer | no | |
| tier | string | no | |
| message | string | nullable | |
| consumedPct | number | nullable | |
| ownerEmail | string | nullable | |
| ownerDepartment | string | nullable | |
| createdAt | string (date-time) | nullable | |
| acknowledgedAt | string (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.
| Field | Type | Null / optional / read-only | Note |
|---|---|---|---|
| id | integer | no | |
| displayId | string | no | INV- and the id, padded to three digits. |
| filename | string | nullable | |
| hasFile | boolean | no | Whether the uploaded file is stored. |
| fileContentType | string | nullable | |
| vendor | string | nullable | |
| amount | number | no | In currency. |
| currency | string | no | USD when none was read. |
| currencyDefaulted | boolean | no | true when the currency was filled from the instance default. |
| amountAmbiguous | boolean | no | true when amountReviewNote is set. |
| amountReviewNote | string | nullable | |
| dateReviewNote | string | nullable | |
| submitterNote | string | nullable | |
| baseCurrency | string | no | |
| amountBase | number | nullable | |
| dateIso | string (date-time) | nullable | |
| category | string | nullable | |
| status | string | nullable | |
| approvalStatus | string | no | Draft when none is stored. |
| extractionConfidence | number | nullable | |
| invoiceNumber | string | nullable | |
| taxAmount | number | nullable | |
| poNumber | string | nullable | |
| matchStatus | string | no | unmatched when none is stored. |
| lineItems | object[] | no | In line order, each { lineNo:integer, description:string|null, quantity:number, unitPrice:number, amount:number }. |
| rawText | string | no | The text read from the file; empty when there is none. |
PurchaseOrderDetailin a reply15 fields
One purchase order with its lines and goods receipts.
| Field | Type | Null / optional / read-only | Note |
|---|---|---|---|
| id | integer | no | |
| poNumber | string | no | |
| vendor | string | nullable | |
| currency | string | no | |
| totalAmount | number | no | In currency. |
| totalAmountBase | number | no | In base currency. |
| baseConverted | boolean | no | false when no base amount was stored for the order. |
| status | string | no | open, partially_received, received, closed or cancelled. |
| department | string | nullable | |
| costCenter | string | nullable | |
| project | string | nullable | |
| notes | string | nullable | |
| orderedDate | string | nullable | Display text such as "Oct 10, 2026". |
| lines | object[] | no | In line order, each { id:integer, lineNo:integer, description:string|null, quantity:number, unitPrice:number, amount:number, qtyReceived:number, qtyInvoiced:number }. |
| goodsReceipts | object[] | no | Newest 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.
| Field | Type | Null / optional / read-only | Note |
|---|---|---|---|
| id | integer | no | |
| label | string | no | |
| providerType | string | no | |
| kind | string | nullable | |
| systemRef | string | nullable | |
| budgetId | integer | nullable | |
| metering | string: live | derived | discovery | no | |
| ownerEmail | string | nullable | |
| ownerDepartment | string | nullable | |
| cost | number | no | 0 when the provider could not be read. |
| forecast | number | no | |
| isSynced | boolean | no | |
| lastSync | string | nullable |
BulkOutcomeItemin a reply5 fields
| Field | Type | Null / optional / read-only | Note |
|---|---|---|---|
| id | any | no | The id as an integer, or the value you sent when it could not be read as one. |
| ok | boolean | no | |
| error | string | no | Present only when ok is false: the reason this item was not done. |
| approvalStatus | string | no | Present 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. |
| kind | string: budget | transfer | no | Present 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.
| Field | Type | Null / optional / read-only | Note |
|---|---|---|---|
| action | string | no | The action you sent. Present only in the invoice bulk reply and the budget-approvals bulk reply. |
| ok | integer | no | How many items were done. |
| failed | integer | no | How many were not. |
| results | BulkOutcomeItem[] | no | One entry per distinct valid id, plus one for each value that could not be read as an id. |
InvoiceImportSummaryin a reply8 fields
| Field | Type | Null / optional / read-only | Note |
|---|---|---|---|
| imported | integer | no | Rows stored as invoices. |
| skipped | integer | no | Rows not stored. |
| errors | string[] | no | One message per skipped row; the first 50 are returned. |
| currencyDefaulted | integer | no | Rows that named no currency and were given the base currency. |
| amountAmbiguous | integer | no | Rows held for review because the amount could be read two ways. |
| dateHeld | integer | no | Rows held for review because the date could not be read with certainty. |
| duplicatesSkipped | integer | no | Rows skipped because the same vendor and invoice number are already on file or repeated in the file. |
| duplicatesHeld | integer | no | Rows stored but held for review because another invoice has the same vendor, amount and date. |
CurrencyRatein a reply3 fields
| Field | Type | Null / optional / read-only | Note |
|---|---|---|---|
| code | string | no | Three letters. |
| rateToBase | number | no | How many units of the base currency one unit of code is worth. |
| updatedAt | string (date-time) | no | UTC. |
CurrencyTablein a reply3 fields
| Field | Type | Null / optional / read-only | Note |
|---|---|---|---|
| baseCurrency | string | no | |
| missingRates | array | no | Currencies 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. |
| rates | CurrencyRate[] | no | Every stored rate, ordered by code. |
VendorMergeResultin a reply5 fields
| Field | Type | Null / optional / read-only | Note |
|---|---|---|---|
| success | boolean | no | Always true. |
| keptVendorId | integer | no | |
| repointed | integer | no | How many invoices moved to the kept vendor. |
| skippedLocked | array | no | The invoices left on the merged vendor because their accounting period is locked; each entry carries id, period and status. |
| loserRetained | boolean | no | true when the merged vendor was kept because invoices in a locked period still point at it. |
PeriodCloseResultin a reply5 fields
| Field | Type | Null / optional / read-only | Note |
|---|---|---|---|
| period | string | no | |
| status | string | no | Always locked. |
| journalsPosted | integer | no | Invoices that were journalled by this close; one already journalled is not posted again. |
| batch | string | no | The period, a hyphen and the UTC time of the close to the second. |
| forced | boolean | no | true when the period was locked over open items. |
GLSyncRunin a reply4 fields
| Field | Type | Null / optional / read-only | Note |
|---|---|---|---|
| connector | string | no | |
| pushed | integer | no | Journal lines marked as synced by this call. |
| live | boolean | no | false for the manual connector. |
| note | string | no |
AlertSweepResultin a reply2 fields
| Field | Type | Null / optional / read-only | Note |
|---|---|---|---|
| evaluated | integer | no | Current budgets looked at. |
| fired | integer | no | Alerts raised by this call. |
InvoiceReviewrequest body8 fields
Corrections to what was read from an uploaded invoice. Only the keys you send are changed.
| Field | Type | Required | Note |
|---|---|---|---|
| vendor | string | optional | Trimmed; an empty value changes nothing. |
| category | string | optional | Trimmed; an empty value changes nothing. |
| currency | string | optional | Upper-cased and cut to 3 letters. |
| date | string (date-time) | optional | ISO 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. |
| amount | number | optional | A number, or text in a recognised number format. An entry that could be read two ways is refused with 400. |
| po_number | string | optional, null clears it | Trimmed, upper-cased and cut to 64 characters. |
| note | string | optional, null clears it | Trimmed and cut to 1000 characters. |
| commit | boolean | optional | true 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.
| Field | Type | Required | Note |
|---|---|---|---|
| costCenter | string | optional, null clears it | |
| department | string | optional, null clears it | |
| project | string | optional, null clears it | |
| glCode | string | optional, null clears it | Once a chart of accounts exists the code must be in it, or the answer is 400. |
| currency | string | optional | Upper-cased and cut to 3 letters; the base amount is worked out again. 400 when no exchange rate is set for it. |
| customFields | object | optional | Replaces 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.
| Field | Type | Required | Note |
|---|---|---|---|
| reason | string | required for reject | Blank is refused with 400. |
| budget_override | boolean | optional | approve only: approve although the invoice takes a budget over its limit. Needs approve_invoice. |
| override_reason | string | required with budget_override | Blank is refused with 400. |
| comment | string | optional | approve 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.
| Field | Type | Required | Note |
|---|---|---|---|
| action | string: submit | approve | reject | pay | clear_review | allocate | delete | required | |
| ids | integer[] | required | 1 to 500 invoice ids. |
| allocation | InvoiceAllocation | optional | Read for allocate only; without it nothing is changed. |
| reason | string | required for reject | One reason shared by every invoice. |
| budget_override | boolean | optional | approve only, as in InvoiceAction. |
| override_reason | string | required with budget_override | |
| comment | string | optional | approve only, as in InvoiceAction; one comment shared by every invoice. |
VendorUpdaterequest body9 fields
Only the keys you send are changed.
| Field | Type | Required | Note |
|---|---|---|---|
| canonicalName | string | optional | A name another vendor already has is refused with 409. Budgets scoped to the old name follow the new one. |
| category | string | optional, null clears it | |
| owner | string | optional, null clears it | |
| ownerUserId | integer | optional, null clears it | |
| contractRef | string | optional, null clears it | |
| contractRenewal | string (date-time) | optional, null clears it | ISO 8601; text that is not a date is stored as empty. |
| notes | string | optional, null clears it | |
| aliases | string[] | optional | Replaces the alias list; each alias is normalised the way vendor names are matched. |
| customFields | object | optional | Replaces 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).
| Field | Type | Required | Note |
|---|---|---|---|
| key | string | required | Characters 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. |
| label | string | optional | Cut to 60 characters. Defaults to the key. |
| type | string: text | select | number | date | optional | Any other value is stored as text. |
| options | string[] | optional | Read 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.
| Field | Type | Required | Note |
|---|---|---|---|
| baseCurrency | string | optional | Three letters. Refused with 400 once invoices, purchase orders or budgets exist, or while the new currency has no rate. |
| code | string | required with rateToBase | Upper-cased and cut to 3 letters. |
| rateToBase | number | required with code | How 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.
| Field | Type | Required | Note |
|---|---|---|---|
| key | string: 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_pct | required | Any other key is refused with 400. |
| value | any | required | A 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
| Field | Type | Required | Note |
|---|---|---|---|
| description | string | optional | |
| quantity | number | optional | Default 0. |
| unit_price | number | optional | Default 0. The line amount is quantity times unit_price; an amount you send is not read. |
PurchaseOrderCreaterequest body9 fields
| Field | Type | Required | Note |
|---|---|---|---|
| po_number | string | required | Trimmed and upper-cased. A number that already exists is refused with 409. |
| vendor | string | optional | Trimmed and matched to a known vendor where one exists. |
| currency | string | optional | Default USD. Upper-cased and cut to 3 letters; 400 when no exchange rate is set for it. |
| lines | PurchaseOrderLineInput[] | optional | When present, the order total is the sum of the lines. |
| total_amount | number | optional | Read only when lines is empty. Default 0. |
| cost_center | string | optional | |
| department | string | optional | |
| project | string | optional | |
| budget_override | boolean | optional | true creates the order although it takes a budget over its limit; without it that case answers 409. |
GoodsReceiptLineInputrequest body2 fields
| Field | Type | Required | Note |
|---|---|---|---|
| po_line_id | integer | optional | The purchase-order line received against. |
| quantity_received | number | optional | Default 0. |
GoodsReceiptCreaterequest body3 fields
| Field | Type | Required | Note |
|---|---|---|---|
| po_id | integer | required | 404 when the purchase order does not exist. |
| grn_number | string | optional | |
| lines | GoodsReceiptLineInput[] | 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.
| Field | Type | Required | Note |
|---|---|---|---|
| name | string | optional | At most 80 characters. Default "Rule". |
| minAmount | number | optional | Default 0. Not negative; at most 999999999999.99. |
| maxAmount | number | optional, null clears it | null means no ceiling. Otherwise greater than minAmount and at most 999999999999.99. |
| requiredApprovals | integer | optional | Default 1. A whole number from 1 to 10. |
| isActive | boolean | optional | Default 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.
| Field | Type | Required | Note |
|---|---|---|---|
| reason | string | required | Blank is refused with 400. |
| debitGl | string | required | A general-ledger code. Once a chart of accounts exists it must be in it. |
| creditGl | string | required | Must differ from debitGl. |
| amount | number | required | Greater 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.
| Field | Type | Required | Note |
|---|---|---|---|
| period | string | required on create | YYYY-MM or YYYY. |
| scopeType | string | optional | Default global. A caller without a finance-wide budget permission may send only department, and it is set to their own department. |
| scopeValue | string | optional, null clears it | The department, cost centre or vendor the budget covers. |
| amount | number | optional | Default 0. A negative number or text that is not a number is stored as 0. |
| currency | string | optional, null clears it | The currency amount is entered in; empty means the base currency. 400 when no exchange rate is set for it. |
| name | string | optional, null clears it | |
| description | string | optional, null clears it | |
| budgetType | string | optional | Default opex. |
| method | string | optional | Default top_down. |
| startDate | string (date-time) | optional, null clears it | ISO 8601; text that is not a date is stored as empty. |
| endDate | string (date-time) | optional, null clears it | |
| periodKind | string | optional, null clears it | |
| authoritativeSource | string: invoice | cloud | both | optional | Default invoice. An update ignores any other value. |
| tags | array | optional | Read only when it is a list. |
| glAccountId | integer | optional, null clears it | |
| recurrence | string | optional, null clears it | |
| recurrenceInterval | string | optional, null clears it | |
| warnPct | number | optional, null clears it | A fraction; brought inside 0 to 1. |
| overPct | number | optional, null clears it | A 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.
| Field | Type | Required | Note |
|---|---|---|---|
| label | string | optional | Default "Line". |
| amount | number | optional | Rounded to 2 places. Not read when driverQty and unitCost are both set. |
| driverKey | string | optional | With headcount or fte and no driverQty, the quantity is the head count of the budget's department where the directory holds one. |
| driverQty | number | optional | |
| unitCost | number | optional | |
| glAccountId | integer | optional | |
| category | string | optional | |
| costCenter | string | optional | |
| justification | string | optional | |
| sortOrder | integer | optional | Default 0. |
BudgetTransferCreaterequest body5 fields
| Field | Type | Required | Note |
|---|---|---|---|
| fromBudgetId | integer | required | |
| toBudgetId | integer | required | Must differ from fromBudgetId. |
| amount | number | required | Greater than 0. |
| currency | string | optional | The currency amount is entered in; empty means the base currency. 400 when an exchange rate is missing for any side. |
| reason | string | optional |
BudgetApprovalsBulkrequest body4 fields
Approve or reject budgets and transfers in one call. Each item is decided on its own and reported in results.
| Field | Type | Required | Note |
|---|---|---|---|
| action | string: approve | reject | required | |
| budgetIds | integer[] | optional | Together with transferIds: at least 1 and at most 500 items. |
| transferIds | integer[] | optional | |
| reason | string | required to reject a budget | Stored 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
| Field | Type | Required | Note |
|---|---|---|---|
| budgetId | integer | required | A budget in your scope: 404 when it does not exist, 403 when it is outside your scope. |
| amount | number | required | Greater than 0. |
| currency | string | optional | Empty means the base currency. 400 when no exchange rate is set for it. |
| description | string | optional | |
| vendor | string | optional |
OwnedSystemClaimrequest body7 fields
Attaches a discovered system to an owner and, optionally, a budget.
| Field | Type | Required | Note |
|---|---|---|---|
| providerType | string | optional | Upper-cased. AWS, AZURE and AZURE_AD are metered live; GOOGLE_WORKSPACE and GITHUB are derived; any other value is recorded as discovery. |
| externalProviderId | integer | optional | A provider id from GET /parser/finance/systems/providers. |
| systemRef | string | optional | |
| label | string | optional | Defaults to systemRef, then providerType, then "System". |
| kind | string | optional | |
| budgetId | integer | optional | A budget you can edit: 404 when it does not exist, 403 when it is not yours to edit. |
| ownerEmail | string | optional | Defaults to you. Ignored for a caller without a finance-wide budget permission, who always becomes the owner. |
DocumentStatusWriterequest body1 field
| Field | Type | Required | Note |
|---|---|---|---|
| status | string | required | Stored as sent. paid and approved (in any letter case) are refused with 403: those are reached through the approval steps. |
FinanceQuestionrequest body1 field
| Field | Type | Required | Note |
|---|---|---|---|
| question | string | required | Trimmed; blank is refused with 400. |
AppBudgetWriterequest body1 field
The yearly budget of one app (vendor), for the current calendar year.
| Field | Type | Required | Note |
|---|---|---|---|
| amount | number | optional | Greater 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.
| Field | Type | Required | Note |
|---|---|---|---|
| category | string | optional | |
| owner | string | optional |
VendorBulkUpdaterequest body2 fields
| Field | Type | Required | Note |
|---|---|---|---|
| ids | integer[] | required | 1 to 500 vendor ids; the limit of 500 is counted before duplicates are removed. A valid id sent twice is handled once. |
| patch | VendorBulkPatch | required |
VendorMergerequest body2 fields
| Field | Type | Required | Note |
|---|---|---|---|
| keepId | integer | required | The vendor that stays. |
| mergeId | integer | required | The vendor whose invoices and aliases move to keepId. |
DigestSendrequest body1 field
| Field | Type | Required | Note |
|---|---|---|---|
| recipients | string[] | optional | Email addresses. Without it the configured digest recipients are used. |
PurchaseOrderStatusWriterequest body1 field
| Field | Type | Required | Note |
|---|---|---|---|
| status | string: closed | cancelled | open | optional | Default closed. Any other value is refused with 400. |
PurchaseOrderBulkStatusrequest body2 fields
| Field | Type | Required | Note |
|---|---|---|---|
| ids | integer[] | required | 1 to 500 purchase-order ids; the limit of 500 is counted before duplicates are removed. A valid id sent twice is handled once. |
| status | string: closed | cancelled | open | optional | Default closed. |
MatchExceptionWaiverequest body1 field
| Field | Type | Required | Note |
|---|---|---|---|
| reason | string | required | Trimmed; blank is refused with 400. |
MatchExceptionBulkWaiverequest body2 fields
| Field | Type | Required | Note |
|---|---|---|---|
| ids | integer[] | required | 1 to 500 match-exception ids; the limit of 500 is counted before duplicates are removed. A valid id sent twice is handled once. |
| reason | string | required | One reason shared by every exception. |
GLAccountCreaterequest body3 fields
| Field | Type | Required | Note |
|---|---|---|---|
| code | string | required | Trimmed. A code that already exists is refused with 409. |
| name | string | optional | Defaults to the code. |
| type | string | optional | The account type, stored as sent. |
PeriodCloserequest body2 fields
| Field | Type | Required | Note |
|---|---|---|---|
| force | boolean | optional | true locks the period although it still has open items. |
| reason | string | required with force | Trimmed; blank with force is refused with 400. |
GLConnectorWriterequest body1 field
| Field | Type | Required | Note |
|---|---|---|---|
| connector | string: manual | quickbooks | netsuite | xero | required | Trimmed and lower-cased. Any other value is refused with 400. |
BudgetRejectrequest body1 field
| Field | Type | Required | Note |
|---|---|---|---|
| reason | string | required | Trimmed; blank is refused with 400. |
BudgetLockrequest body1 field
| Field | Type | Required | Note |
|---|---|---|---|
| locked | boolean | optional | Default true. false unlocks, which needs manage_finance_policy. |
BudgetFreezerequest body2 fields
| Field | Type | Required | Note |
|---|---|---|---|
| frozen | boolean | optional | Default true. false lifts the freeze. |
| reason | string | optional | Stored with a freeze; not kept when the freeze is lifted. |
BudgetCopyrequest body1 field
| Field | Type | Required | Note |
|---|---|---|---|
| period | string | required | The period of the copy: YYYY-MM or YYYY. |
BudgetCommentCreaterequest body1 field
| Field | Type | Required | Note |
|---|---|---|---|
| comment | string | required | Blank is refused with 400. |
TransferRejectrequest body1 field
| Field | Type | Required | Note |
|---|---|---|---|
| reason | string | optional | Trimmed; stored as the rejection reason. |
SpendRequestDecisionrequest body1 field
Read for the reject and cancel actions only; approve and fulfill read no body.
| Field | Type | Required | Note |
|---|---|---|---|
| reason | string | optional |