Expenses
Expense intake, approvals, reimbursement handling, and the workflow that keeps spend reviewable as volume grows.
Scope
Expense handling is one of the first places companies feel avoidable process drag. This public guide keeps the operating workflow and control model while excluding private implementation detail.
Expense tracking, vendor management, expense policy automation, receipt attachments, and bills (accounts payable).
Expenses and Vendors
Vendor Management
Vendors are companies or individuals you pay.
List vendors:
The expense create/edit modal uses this endpoint as a searchable chooser, so operators type a vendor name instead of scrolling a bounded select when the tenant has a large vendor set.
Vendor detail:
The vendor detail summary exposes Last Payment as the latest payment across both direct expense payments (Expense.payment_date) and AP bill payments (BillPayment.payment_date).
Create vendor:
Fields:
- name (required)
- category – vendor category
- payment_terms_days – default: 30
- notes
Update vendor:
If you send a blank vendor_number, Keystone normalizes it to null instead of persisting an empty string that can collide with tenant uniqueness rules.
Expense Tracking
Create an expense:
Fields:
- description (required)
- amount_cents (required, > 0)
- expense_date (required)
- due_date – optional override; if blank, Keystone defaults it from the vendor’s payment terms or the expense date
- category – expense category
- tax_category – for tax deduction classification
- is_tax_deductible – boolean
- payment_method
- notes
- controller exception metadata is stored on the row as exception_type, exception_evidence, hold metadata, request-changes metadata, and resubmission metadata
- linked AP bill summaries are exposed on the row as linked_bills, so the same screen can show downstream bill state without sending operators to the accounting Bills workspace first
The Expenses create modal also has a receipt-assisted draft helper with two intake paths:
- File intake: choose a JPEG, PNG, or PDF and Keystone calls the backend upload-assist path, which attempts PDF text extraction or OCR before falling back to filename heuristics.
- Forwarded intake: paste forwarded receipt subject/body text (and optional sender) from a receipt inbox flow without selecting a local file first.
Keystone stores the resulting receipt_assist_source, confidence, and evidence on the expense row. Draft suggestions may be marked review_required when extraction is weak or incomplete.
You can keep the suggested fields, correct them inline, or use Discard extracted fields to clear the extracted values without losing the selected receipt. On a brand-new draft, that reset returns amount, vendor, category, and date to truly blank fields instead of leaving a synthetic 0.00 amount behind. If receipt analysis fails, Keystone keeps the file selected, shows the failure inline, and lets you retry analysis or continue with manual entry. The selected receipt still uploads after the expense is saved. The same draft modal is used for both new expenses and edits, so receipt-assist, manual fallback, and receipt-upload retry behave the same way in both cases.
When the Expenses page has many rows, it no longer stops at the first slice. The expense list exposes a Load more expenses control, and the approval queue uses page controls with a visible Showing X-Y of Z range so operators can tell what is loaded and what still exists.
The expense table also has a spend-control column that shows linked AP bill status, bill number, and vendor evidence when those records exist. If an expense has no downstream bill yet, the row says so plainly instead of pretending the AP step is already handled.
New expenses do not always stay pending. On creation, Keystone evaluates any active expense policies:
auto_approveonly commits if the GL posting succeeds; if the GL posting fails, the expense is not createdauto_rejectmarks the expenserejectedand stores the policy reason on the expense row- if no policy matches, the expense remains
pending - if a policy uses
require_approval, Keystone persists approval-routing metadata so the item can be owned, claimed, and escalated instead of floating as an anonymous pending record require_approvalcan be multi-stage; expenses move through each configured stage in order before terminal approval
Receipt-assisted creates are intentionally forced into review (require_approval) even when a matching policy would normally auto-approve or auto-reject. This keeps AI/OCR-assisted drafts in the same human exception queue before posting.
Duplicate detection can auto-hold an expense when the backend finds an earlier matching expense. Receipt/transaction mismatch handling can move an expense to changes_requested when the saved expense does not match the receipt-assist evidence snapshot.
Every expense keeps the policy snapshot that produced its current state:
applied_policy_namepolicy_actionpolicy_reasonpolicy_evaluated_at
Edit an expense:
Operators can edit eligible expenses directly from the Expenses screen. Paid expenses remain immutable.
Approve an expense:
Creates GL journal entries: debit Expense account, credit Accounts Payable. The creator of an expense cannot approve their own expense.
Claim an approval:
The same queue also includes held and changes_requested items, and the operator surface exposes hold, request-changes, and resubmit actions for controller review. Queue rows now show richer requester/approver context and current/next stage route hints for controller handoffs. Approve actions from the queue still go through a confirmation modal before the stage advances or the expense reaches terminal approval.
Reject an expense:
Requires a rejection reason and leaves the expense in the terminal rejected state.
Hold an expense:
Use this when you need to pause an expense without creating a second review queue. The row stays visible, the hold reason is stored, and SLA tracking pauses on the held item.
Request changes on an expense:
Use this when the submitter needs to fix the original expense row. The row becomes changes_requested, the reason is stored, and the same item stays in the controller queue.
The same expense row continues to carry the linked AP truth through hold/request-changes/resubmit, so operators do not have to reconstruct the spend-control state from another surface.
Resubmit an expense:
Mark expense as paid:
Creates GL journal entries: debit Accounts Payable, credit Cash.
Upload receipt:
Upload a receipt image (JPEG, PNG) or PDF. Max 10 MB.
If you already used the receipt-assisted draft helper, this step is still the final attachment step. The draft helper does not replace the normal receipt upload path.
Download receipt:
In the web UI, receipt retrieval uses the authenticated API client and a blob response. Keystone does not put bearer tokens in receipt URLs.
Expense Policies
Expense policies let finance teams automate low-risk approvals and reject clearly disallowed spend without reviewing every item by hand.
The Expenses screen now includes an Expense Policies tab for creating, editing, and deactivating these rules without backend tooling. The expense list shows which policy snapshot produced each row’s current state.
List policies:
Create policy:
Common policy fields:
- name (required)
- priority – higher numbers run first
- max_amount_cents and min_amount_cents
- allowed_categories and blocked_categories
- approval_required_from – optional approval route, either single-stage or multi-stage
Update policy:
Deactivate policy:
Policies are soft-deactivated rather than hard-deleted.
Expense Summary
Returns period totals by status, category, and vendor.
Expense Categories
Custom expense categories for organizing spending.
AP Aging (Expenses)
Accounts payable aging report from unpaid expenses, bucketed by age.
The aging buckets use each expense’s due_date. If a historical expense predates the due-date column, Keystone falls back to expense_date + vendor.payment_terms_days.
Expense-domain data is tenant-scoped. Vendors, expenses, expense policies, categories, receipts, summary views, and AP aging only return rows for the signed-in Portal tenant; foreign IDs are treated as not found.
Bills (Accounts Payable)
Bills are vendor invoices you receive. They have a more structured workflow than simple expenses and integrate directly with the general ledger.
Bill Lifecycle
Create a Bill
Fields:
- bill_date (required)
- due_date (required)
- bill_number – auto-generated (BILL-NNNNNN) if not provided
- tax_cents – tax amount (default: 0)
- lines (required, at least one):
- description (required)
- quantity – default: 1.0
- unit_price_cents
- amount_cents – must equal quantity * unit_price_cents (validated)
Update a Bill
Only draft bills can be edited. Supports updating lines (delete-and-replace).
Approve a Bill
Record Bill Payment
Fields:
- amount_cents (required, > 0) – cannot exceed remaining balance
- payment_date – defaults to today
- reference_number
Partial payments are supported. Status auto-transitions to partially_paid or paid based on amount.
GL auto-posting: debit Accounts Payable, credit Cash/Bank.
Void a Bill
Only approved or partially_paid bills can be voided. Creates reversing journal entries for both the approval posting and any payments.
AP Aging (Accounting)
Accounts payable aging from GL sub-ledger data (entity_type=’vendor’). Falls back to bill-based aging if GL data is not yet available. Grouped by vendor with current, 30-day, 60-day, and 90+ day buckets.