Skip to content

Tenant API v1 — Expenses ​

Base path: /api/tenant/v1

Middleware: auth:tenant-api, tenant.user, not.suspended, verified, module:expenses, plus permission middleware / policies.

No hard module_dependencies — Expenses installs standalone. vendor_id and purchase_order_id are optional; supplying either requires the corresponding module (vendors / purchase-orders) to be entitled on the workspace, enforced by LinkableVendor / LinkablePurchaseOrder.

Assignee scoping: without expenses.assign (and not superadmin), list/stats/view/update/submit/cancel only include expenses where assigned_to is the current user. approve/reject/pay are not assignee-scoped — any actor holding the specific permission may act on any expense they can otherwise view.

Stats ​

GET /expenses/stats ​

Same filters as list (minus pagination/sort). Response:

json
{
  "total_expenses": 0,
  "my_expenses": 0,
  "draft": 0,
  "submitted": 0,
  "approved": 0,
  "rejected": 0,
  "paid": 0,
  "cancelled": 0,
  "approved_value": 0,
  "paid_value": 0,
  "scope": "org | mine"
}

Expenses CRUD ​

GET /expenses ​

Query: search (matches title or number), status (draft|submitted|approved|rejected|paid|cancelled), category_id (header category or any line category), vendor_id, purchase_order_id, assigned_to (unassigned or user id), my_expenses, trashed (true|only), sort, direction, page, per_page.

List items include status, category_id, embedded category ({ id, name, slug } when loaded), amount, tax_amount, lines (description, amount, tax_amount, category), currency, expense_date, vendor/purchase_order refs (when linked), assignee/creator refs, and latest_note.

POST /expenses ​

Body: title (required), category_id (optional — tenant expense_categories id, must be active; defaults to the seeded Other category when lines is omitted), amount (required when lines is omitted), tax_amount (optional, default 0), lines (optional, 1–50 items of description, amount, tax_amount, category_id, sort_order). When lines is present the server stores header amount and tax_amount as the sums and sets category_id only when every line shares one category. currency (3-letter, default USD), expense_date, notes, vendor_id (optional — must belong to the tenant and the Vendors module must be entitled), purchase_order_id (optional — must belong to the tenant and the Purchase Orders module must be entitled), assigned_to.

Optional multipart field receipt (file, max 5120 KB): jpg/jpeg/png/gif/webp/pdf/txt/log. Stored as ExpenseAttachment; bytes count toward workspace Storage quota (WorkspaceStorageService::assertCanStore). Returns attachments array on the expense resource.

Status always starts at draft; number is auto-generated (EXP-00001, configurable via the expenses_number_prefix tenant setting). Without lines, amount and tax_amount are stored as given. With lines, those header fields are recomputed. Pay still debits one expense account for amount + tax.

GET /expenses/{id} ​

Includes vendor, purchase order, assignee, creator, notes, timeline activities, and attachments (uuid, original_name, mime, size_bytes, created_at). Embedded notes and timeline/domain activities are newest-first (created_at DESC, then id DESC).

PUT /expenses/{id} ​

Partial update of draft expenses only. Non-draft expenses return 422 on status (Only draft expenses can be edited.). Assignment after submit uses POST /expenses/{id}/assign.

Optional receipt on draft update uses the same validation as create. For multipart file upload, use POST /expenses/{id} (POST twin) — PHP does not populate uploaded files on true HTTP PUT.

GET /expenses/attachments/{uuid}/download ​

Permission: parent expense view via ExpenseAttachmentPolicy. Returns the stored file as a download (original_name). 404 if the file is missing on disk.

DELETE /expenses/{id} ​

Soft delete. Permission: expenses.delete.

POST /expenses/{id}/restore ​

Permission: expenses.restore.

DELETE /expenses/{id}/force ​

Permanently delete a soft-deleted expense. Permission: expenses.force.delete (owner/superadmin only by default).

Actions ​

POST /expenses/{id}/assign ​

{ "assigned_to": number|null }

Permission: expenses.assign.

POST /expenses/{id}/submit ​

Transitions draft → submitted. Permission: expenses.submit (assignee-scoped unless the actor has expenses.assign or is superadmin).

POST /expenses/{id}/approve ​

Transitions submitted → approved. Permission: expenses.approve (not assignee-scoped).

POST /expenses/{id}/reject ​

Transitions submitted → rejected (terminal). Permission: expenses.reject (not assignee-scoped).

POST /expenses/{id}/pay ​

Transitions approved → paid. Permission: expenses.pay (not assignee-scoped).

Body (optional unless Accounting is entitled): paid_from_account_id (required active cash/bank when Accounting installed), expense_account_id (optional expense-type account; defaults to system Operating Expenses 6000). When Accounting is entitled, creates a posted journal (Dr expense / Cr paid-from) for amount + tax_amount and sets journal_entry_id.

POST /expenses/{id}/unpay ​

Transitions paid → approved. Permission: expenses.pay (not assignee-scoped). Clears journal_entry_id, paid_from_account_id, and expense_account_id. When Accounting is entitled and a payment journal exists, that journal is voided (Expense unpaid). A locked fiscal period returns 422 on entry_date and leaves the expense paid. A second unpay returns 422 on status.

POST /expenses/{id}/cancel ​

Transitions draft|submitted → cancelled (terminal). Permission: expenses.cancel (assignee-scoped unless the actor has expenses.assign or is superadmin).

POST /expenses/{id}/status ​

{ "status": "draft"|"submitted"|"approved"|"rejected"|"paid"|"cancelled" }

Authorization depends on the target status:

  • submitted → expenses.submit
  • approved → expenses.approve, or expenses.pay when the expense is already paid (same void as POST …/unpay)
  • rejected → expenses.reject
  • paid → expenses.pay
  • cancelled → expenses.cancel
  • other → expenses.update

Rejects disallowed transitions with a 422 validation error on status. Records a status_changed timeline entry.

POST /expenses/{id}/notes ​

{ "body": string }

Permission: expenses.update.

GET /expenses/{id}/timeline ​

Domain timeline entries (created, updated, assigned, status_changed, note_added, deleted, restored).

Expense categories ​

Categories use the same module:expenses gate and expenses.* permissions (no separate permission family). Listing lazy-seeds Travel / Office / Software / Utilities / Other when missing. Starter slugs (travel|office|software|utilities|other) are not changed on rename. Listing does not restore a soft-deleted starter except Other.

  • GET /expense-categories — list (expenses.view)
  • POST /expense-categories — create (expenses.create). Body: name (required); optional slug, sort_order, is_active
  • GET|PUT|DELETE /expense-categories/{expenseCategory} — view, update, soft-delete (view / update / delete)
  • POST /expense-categories/{expenseCategory}/restore — restore (expenses.restore)
  • DELETE /expense-categories/{expenseCategory}/force — permanently delete a soft-deleted category (expenses.force.delete)

Delete and force-delete return 422 if the category slug is other, or if any expenses (including trashed, for force) still reference the category.

POST /purchase-orders/{id}/convert ​

Documented under Tenant Purchase Orders — Convert to expense. Requires the Expenses module to be entitled (soft check — 422 if not) and the purchase-orders.convert permission. Returns the newly created draft Expense (201).

Official documentation for the EloSync SaaS Platform.