Developer Handover Pack
Everything needed to build the automation from scratch: current state, full agent specs, data flow, and the build stack.
Developer Handover Pack
Expense Management Automation
[YourCompany.com] · Finance Department · Prepared by FullSpec · [Today's Date]
This document is the authoritative technical reference for the FullSpec team building the Expense Management automation. It covers the current-state process map with bottleneck identification, full specifications for each of the three agents, end-to-end data flow, and the recommended build stack. Everything in this pack has been derived from the confirmed process template and agreed tooling list. The FullSpec team owns all build, integration, and testing activity described here. Your team's responsibilities are limited to providing API credentials, confirming the GL mapping table and expense policy rules, and signing off on test outputs before go-live.
01Process snapshot
02Agent specifications
This agent handles the first stage of the automated pipeline. On each new Expensify claim submission it reads the attached receipt image or PDF using OCR, extracts the structured fields (amount, date, merchant name, currency, and expense category), and immediately runs each field through the configured expense policy rule set. Claims that pass all rules are marked as verified and handed off to the next agent. Claims that fail are flagged with a specific machine-readable reason code and a human-readable description, and an automated receipt chase email is dispatched via Gmail. This agent eliminates the manual receipt-checking, chasing, and policy-review steps that currently consume the largest share of finance admin time. Estimated build time: 10 hours. Complexity: Moderate.
// Input
expensify.claim {
claim_id: string,
employee_email: string,
submission_timestamp: ISO8601,
amount: float,
currency: string,
merchant_name: string,
merchant_category: string,
receipt_url: string | null,
description: string
}
// Output (passing claim)
verified_claim {
claim_id: string,
employee_email: string,
amount_usd: float,
merchant_name: string,
merchant_category: string,
receipt_gdrive_url: string,
policy_verdict: 'pass',
extracted_date: ISO8601
}
// Output (failing claim)
flagged_claim {
claim_id: string,
employee_email: string,
policy_verdict: 'fail',
flag_reason_code: string, // e.g. 'MISSING_RECEIPT' | 'OVER_LIMIT' | 'CATEGORY_DISALLOWED'
flag_reason_human: string,
chase_email_sent: boolean,
chase_timestamp: ISO8601
}- Expensify API access requires the Expensify Control plan or higher for full API reporting and webhook support. Confirm the client's current plan before build starts. If on a lower tier, a polling-based approach using the Export Reports endpoint must be used instead.
- OCR extraction should be performed via the automation platform's built-in document parsing node or a connected document intelligence service. Do not rely on Expensify's native OCR output as the sole source; validate extracted fields against the raw receipt where possible.
- The expense policy rule set must be received as a structured document (JSON or spreadsheet) before this agent is built. Ambiguous rules will produce inconsistent verdicts and must be resolved with the finance manager before configuration.
- Receipt files must be downloaded from Expensify and stored in a designated Google Drive folder (path: /ExpenseReceipts/{YYYY-MM}/{claim_id}) before policy evaluation, to ensure a persistent copy exists independent of Expensify retention settings.
- Chase emails must use a templated Gmail draft with a fixed reply-to address monitored by the automation platform. Implement a maximum of three chase attempts with a 48-hour interval, then escalate to finance admin via Slack after the third failed attempt.
- Deduplicate incoming claims by claim_id before processing. If a duplicate claim_id is detected, log the event and suppress further processing without sending a second chase email.
- Fallback: if OCR extraction confidence is below the threshold configured in the policy (recommended: 85%), treat the claim as a manual review item and route to finance admin rather than auto-rejecting.
This agent receives a verified claim record from the Receipt Extraction and Policy Agent and performs two tasks: it maps the merchant category and description to the correct Xero general ledger code using a pre-built merchant-to-GL mapping table, then creates and populates a draft spend money record in Xero with all fields pre-filled and the receipt file attached. The agent replaces the manual GL coding step and the manual Xero data entry step, which together account for 11 minutes of admin time per claim. The mapping table is maintained as a configuration file outside the agent logic so it can be updated by the finance team without modifying the build. Estimated build time: 8 hours. Complexity: Moderate.
// Input
verified_claim {
claim_id: string,
employee_email: string,
amount_usd: float,
merchant_name: string,
merchant_category: string,
receipt_gdrive_url: string,
policy_verdict: 'pass',
extracted_date: ISO8601
}
// GL mapping lookup
gl_mapping_table[merchant_category] -> {
xero_account_code: string, // e.g. '420' for Travel, '425' for Meals
xero_account_name: string,
tax_type: string // e.g. 'NONE' | 'TAX'
}
// Output
xero_draft_record {
xero_transaction_id: string,
claim_id: string,
status: 'DRAFT',
contact_name: string, // employee full name from Expensify profile
date: ISO8601,
amount: float,
account_code: string,
account_name: string,
description: string,
receipt_attachment_url: string,
reference: string // claim_id as Xero reference field
}- Xero integration requires OAuth 2.0 with the accounting.transactions scope (read and write) and the files scope for receipt attachment. Confirm that the Xero organisation ID is available and that the connecting user has Standard or Adviser role.
- The merchant-to-GL mapping table must be provided by the finance manager as a confirmed, reviewed spreadsheet before this agent is built. Store it as an external configuration object (JSON or a dedicated config table) so the finance team can update mappings without a code change.
- If a merchant category cannot be matched in the mapping table, do not guess. Route the claim to finance admin for manual GL assignment and log the unmapped category for table maintenance.
- Xero draft records must use status 'DRAFT' (not 'AUTHORISED') so a human can still review before finalisation. The Approval Routing and Notification Agent is responsible for moving the record to 'AUTHORISED' status.
- Receipt attachment to the Xero transaction must use the Xero Files API. Upload the file from the Google Drive URL stored in the previous step. Do not re-download from Expensify at this stage.
- The spreadsheet log (step 8 in the manual process) is eliminated by using the Xero draft record as the system of record. Confirm with the finance manager that the Google Drive spreadsheet can be retired post-go-live.
This agent takes a confirmed Xero draft record and manages the full approval lifecycle: it identifies the correct approving manager based on a configurable manager assignment matrix (by department or cost centre), sends a structured Slack message with claim details and interactive approve or reject buttons, fires a 24-hour reminder if no action is taken, finalises the Xero record to 'AUTHORISED' status on approval, adds the reimbursement line to the employee's next Gusto payroll run, and sends a confirmation email to the employee via Gmail. On rejection, it posts the rejection reason back to the finance admin via Slack and sends the employee a rejection notification via Gmail. Estimated build time: 10 hours. Complexity: Moderate.
// Input
xero_draft_record {
xero_transaction_id: string,
claim_id: string,
contact_name: string,
employee_email: string,
date: ISO8601,
amount: float,
account_code: string,
account_name: string,
description: string,
receipt_attachment_url: string
}
// Manager assignment lookup
manager_matrix[employee_email | department] -> {
manager_slack_id: string,
manager_email: string
}
// On approval
approval_payload {
claim_id: string,
xero_transaction_id: string,
approved_by_slack_id: string,
approval_timestamp: ISO8601
}
-> xero.updateTransaction(status: 'AUTHORISED')
-> gusto.addReimbursementLine {
employee_id: string,
amount: float,
description: string, // 'Expense reimbursement: {claim_id}'
payroll_period: string
}
-> gmail.send { to: employee_email, template: 'claim_approved' }
// On rejection
rejection_payload {
claim_id: string,
rejected_by_slack_id: string,
rejection_reason: string,
rejection_timestamp: ISO8601
}
-> slack.notify { channel: finance_admin_slack_id, message: rejection_summary }
-> gmail.send { to: employee_email, template: 'claim_rejected', reason: rejection_reason }- Slack integration requires a custom Slack app with bot token scopes: chat:write, im:write, and the ability to post interactive messages using Block Kit with button actions. Confirm the Slack workspace plan supports interactive components (available on Pro and above).
- The manager assignment matrix must be provided before build and stored as an external configuration object. It must map either employee email or department/cost centre to a Slack user ID and email address. Edge cases (e.g. the manager is the claimant) must be defined in the matrix.
- The 24-hour reminder must be implemented as a scheduled re-check on the claim's pending status, not as a duplicate Slack message thread. Use the original message's Slack ts value to post the reminder as a thread reply.
- Gusto reimbursement API integration requires Gusto admin credentials with payroll:write scope. FullSpec recommends a manual review of the first two to three reimbursement batches before finalisation to confirm payroll period alignment.
- Gmail notifications must use templated drafts stored in the automation platform. The 'claim_approved' template must include the payroll date, amount, and a reference to the claim_id. The 'claim_rejected' template must include the rejection reason verbatim.
- Xero status transition from 'DRAFT' to 'AUTHORISED' must only occur after the Slack approval payload is received and validated. Never auto-authorise on a timeout.
- If the Slack approval action is not received within 48 hours after the reminder, escalate to the finance admin via a direct Slack message listing all pending approvals. Do not finalise or reject automatically.
03End-to-end data flow
// ─── TRIGGER ────────────────────────────────────────────────────────────────
// Employee submits expense claim via Expensify
expensify.webhook -> automation_platform.receive {
claim_id: 'EXP-{YYYYMMDD}-{seq}',
employee_email: string,
submission_timestamp: ISO8601,
amount: float,
currency: string,
merchant_name: string,
merchant_category: string, // Expensify-assigned category
receipt_url: string | null,
description: string
}
// ─── DEDUPLICATE ─────────────────────────────────────────────────────────────
IF claim_id EXISTS IN processed_claims_log -> HALT (suppress duplicate)
ELSE -> CONTINUE, write claim_id to processed_claims_log
// ─── AGENT 1: Receipt Extraction and Policy Agent ────────────────────────────
// Step A: Download and store receipt
GET expensify.receipt_url
-> store to Google Drive: /ExpenseReceipts/{YYYY-MM}/{claim_id}.{ext}
-> receipt_gdrive_url: string
// Step B: OCR extraction
ocr_engine.extract(receipt_gdrive_url) -> {
ocr_amount: float,
ocr_date: ISO8601,
ocr_merchant: string,
ocr_confidence: float // 0.0 - 1.0
}
// Step C: Confidence gate
IF ocr_confidence < 0.85
-> route to finance_admin (manual review), HALT agent pipeline
// Step D: Policy check
policy_engine.evaluate {
amount: ocr_amount,
merchant_category: merchant_category,
receipt_present: receipt_url != null,
policy_rules: config.expense_policy_rules
} -> {
policy_verdict: 'pass' | 'fail',
flag_reason_code: string | null,
flag_reason_human: string | null
}
// Step E: Fail path - send receipt chase via Gmail
IF policy_verdict == 'fail'
-> gmail.send {
to: employee_email,
template: 'receipt_chase',
reason: flag_reason_human,
reply_to: 'expenses-intake@[YourCompany.com]',
attempt_number: int // max 3, interval 48h
}
-> write chase_log { claim_id, attempt_number, chase_timestamp }
-> HALT (await resubmission)
// Step F: Pass path - emit verified_claim
IF policy_verdict == 'pass'
-> verified_claim {
claim_id, employee_email, amount_usd: ocr_amount,
merchant_name: ocr_merchant, merchant_category,
receipt_gdrive_url, policy_verdict: 'pass',
extracted_date: ocr_date
}
// ─── AGENT 2: GL Coding and Xero Sync Agent ─────────────────────────────────
// Step G: GL mapping lookup
gl_mapping_table.lookup(merchant_category) -> {
xero_account_code: string,
xero_account_name: string,
tax_type: string
}
IF no match found
-> route to finance_admin (unmapped category), log unmapped_category_event, HALT
// Step H: Create Xero draft spend money record
xero.createSpendMoneyTransaction {
status: 'DRAFT',
contact_name: expensify.employee_full_name,
date: extracted_date,
amount: amount_usd,
account_code: xero_account_code,
description: description,
reference: claim_id,
tax_type: tax_type
} -> xero_transaction_id: string
// Step I: Attach receipt to Xero record
xero.files.upload {
transaction_id: xero_transaction_id,
file_url: receipt_gdrive_url,
filename: '{claim_id}_receipt.{ext}'
}
// Step J: Emit xero_draft_record
xero_draft_record {
xero_transaction_id, claim_id,
contact_name, employee_email,
date: extracted_date, amount: amount_usd,
account_code: xero_account_code,
account_name: xero_account_name,
description, receipt_attachment_url: receipt_gdrive_url
}
// ─── AGENT 3: Approval Routing and Notification Agent ───────────────────────
// Step K: Manager lookup
manager_matrix.lookup(employee_email) -> {
manager_slack_id: string,
manager_email: string
}
// Step L: Send Slack approval request
slack.postMessage {
channel: manager_slack_id,
blocks: [
{ type: 'section', text: 'Expense claim pending approval' },
{ type: 'fields', fields: [amount, merchant_name, account_name, date] },
{ type: 'actions', elements: [
{ action_id: 'approve_claim', text: 'Approve' },
{ action_id: 'reject_claim', text: 'Reject' }
]
}
],
metadata: { claim_id, xero_transaction_id }
} -> slack_message_ts: string
// Step M: 24-hour reminder (scheduled)
IF no action after 24h
-> slack.postMessage (thread_ts: slack_message_ts) { text: 'Reminder: claim awaiting approval' }
IF no action after 48h
-> slack.postMessage { channel: finance_admin_slack_id, text: 'Escalation: claim {claim_id} unanswered' }
// Step N: Approval path
ON slack.action(action_id: 'approve_claim') -> {
approved_by_slack_id, approval_timestamp
}
-> xero.updateTransaction { id: xero_transaction_id, status: 'AUTHORISED' }
-> gusto.addReimbursementLine {
employee_id: gusto.lookupByEmail(employee_email),
amount: amount_usd,
description: 'Expense reimbursement: {claim_id}',
payroll_period: gusto.currentOpenPeriod
}
-> gmail.send { to: employee_email, template: 'claim_approved',
vars: { amount: amount_usd, payroll_date: gusto.nextPayrollDate } }
-> write approval_log { claim_id, xero_transaction_id, approved_by_slack_id, approval_timestamp }
// Step O: Rejection path
ON slack.action(action_id: 'reject_claim') -> {
rejected_by_slack_id, rejection_reason, rejection_timestamp
}
-> slack.postMessage { channel: finance_admin_slack_id,
text: 'Claim {claim_id} rejected: {rejection_reason}' }
-> gmail.send { to: employee_email, template: 'claim_rejected',
vars: { reason: rejection_reason } }
-> write rejection_log { claim_id, rejected_by_slack_id, rejection_reason, rejection_timestamp }
// ─── END OF PIPELINE ─────────────────────────────────────────────────────────04Recommended build stack
More documents for this process
Every document generated for Expense Management.