Maverix / Studio
Client Document Portal
← All Documents
Internal · Technical

Xero Sync — Implementation

Technical SOP · Bounce & Co Weekly Financial Sync
Audience: Developers / Sub-agents · July 2026 · v1.0

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

ErrorCauseFix
401 UnauthorizedToken expiredRun tools/xero-token-refresh-bounce.js, retry
404 Not FoundEndpoint/resource missingLog and skip
500 Server ErrorXero API downRetry up to 3× with 10s backoff
Rate limitToo many requestsWait 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

Related Files