Xero Sync — Implementation
Context
The weekly financial sync cron (tools/weekly-xero-sync.js) runs every Monday at 6:00 AM PT with Sonnet engaged. This document covers implementation details for each of the 7 steps: API patterns, error handling, and troubleshooting.
Status: Implemented and end-to-end verified (2026-07-31). Steps 1 and 5 delegate to existing production-tested scripts rather than duplicating logic.
General Patterns
Xero API Access
const tokens = JSON.parse(fs.readFileSync("config/xero-tokens-bounce.json", "utf8"));
const options = {
hostname: "api.xero.com",
path: "/api.xro/2.0/[endpoint]",
method: "GET" | "PUT",
headers: {
"Authorization": "Bearer " + tokens.access_token,
"Xero-tenant-id": "[redacted]",
"Accept": "application/json"
}
};
Error Handling
| Error | Cause | Fix |
|---|---|---|
| 401 Unauthorized | Token expired | Run tools/xero-token-refresh-bounce.js, retry |
| 404 Not Found | Endpoint/resource missing | Log and skip |
| 500 Server Error | Xero API down | Retry up to 3× with 10s backoff |
| Rate limit | Too many requests | Wait 60s, retry |
Known Gotcha — Token Race Condition
Xero refresh tokens are single-use and rotate on every refresh. Running multiple scripts back-to-back (each doing its own refresh) races and kills the chain — invalid_request on refresh, 401 on API calls. The keep-alive cron refreshes every 20 minutes; never run a manual refresh concurrently with it. If debugging, disable the cron first, do one clean refresh, re-enable after.
Step Implementation Notes
1 Stripe Sync
Delegates to tools/xero-stripe-sync.js — do not duplicate this logic. Reads Stripe charges/invoices, maps to Xero service items via PRODUCT_MAP, posts invoices. Unmatched line items block the invoice loudly (printed as ❌ BLOCKED) rather than silently dropping — this was a real bug fixed in production (a $9.50 "Stairs Access Fee" line was once silently dropped, causing a payment mismatch downstream).
2 BofA Bank Feed Check
Paginates GET /api.xro/2.0/BankTransactions filtered to the BofA account (1020), tallies by status (AUTHORISED / RECONCILED / DELETED / DRAFT / UNRECONCILED). Xero returns dates wrapped as /Date(unixms±tz)/ — must be parsed with a regex, not passed directly to new Date().
3 & 4 Allocate Known / Flag Unknown
Candidate transactions (status UNRECONCILED or DRAFT) are matched against config/xero-expense-mappings.json vendor regex patterns. Matches under the $150 auto-allocate threshold are logged as allocated. Any match over $150 is routed to the unknown/flagged report instead — recognized vendor, still requires sign-off above threshold. Non-matches get a keyword-based account suggestion (e.g. "facebook/google/ad" → Advertising, "render/hosting" → Software) plus a confidence rating. All flagged items are written to a full-detail report at /tmp/unknown-allocations-YYYYMMDD.txt — never auto-posted.
5 Reconcile Stripe + BofA
Delegates to tools/xero-stripe-reconcile.js. Three internal steps: (1) apply invoice payments to the Stripe Clearing account (1015, not the Stripe Balance bank account — this was the fix for a real double-counting bug found and resolved 2026-07-31), (2) rebuild the cash ledger from Stripe balance_transactions as source of truth (charges, fees, refunds), (3) record successful payouts as bank transfers to BofA.
6 Generate Financials
Pulls GET /api.xro/2.0/Reports/ProfitAndLoss and /Reports/BalanceSheet for the trailing 7 days. Note: the token's scope does not include Reports/BankSummary — don't call it, it will 401 even with a valid token.
7 Email to Mailing List
Sends via tools/email.js send --to= --subject= --body= --attachment= (Graph API, phineas@maverixstudio.com). Recipients and detail level configured in config/xero-mailing-list.json.
Environment Variable Gotcha
Sub-scripts (xero-stripe-sync.js, xero-stripe-reconcile.js) expect STRIPE_BOUNCE_RESTRICTED_KEY directly in process.env and do not call dotenv.config() themselves. When the orchestrator shells out to them via execSync, the parent's dotenv-loaded environment is not automatically inherited by default — must explicitly source .env.phineas in the subprocess invocation:
execSync(`cd ${WORKSPACE} && set -a && . ./.env.phineas && set +a && node tools/xero-stripe-sync.js`, { shell: '/bin/bash' });
Testing Checklist
- Verify
config/xero-tokens-bounce.jsonis valid and fresh before a manual run - Run the orchestrator directly:
node tools/weekly-xero-sync.js - Check
/tmp/weekly-sync-YYYYMMDD.logfor the full step-by-step output - Confirm the summary block at the end shows
"stripe": "ok"and"reconcile": "ok" - Caution: running the script live sends a real email. There is currently no dry-run flag — a real test email was sent to Darren during development (2026-07-31) and flagged as a test artifact. Consider adding a
--dry-runflag before the next round of testing.
Related Files
tools/weekly-xero-sync.js— orchestratortools/xero-stripe-sync.js,tools/xero-stripe-reconcile.js— underlying production logic (do not duplicate)tools/xero-token-refresh-bounce.js— token refreshconfig/xero-expense-mappings.json,config/xero-mailing-list.json
Business workflow overview
View Weekly Sync SOP →