Xero and CRM Invoice Integration
Snapshot date: 2026-05-28. Updated: 2026-07-03 for yearly seat and prorate invoice-line behavior.
This document describes the current Xero custom-connection implementation and
the MSP invoice automation path. The current production target is to generate
monthly MSP invoices from CRM MSP_Invoice_Lines, create the CRM invoice
record, create a Xero draft invoice, and keep the two records linked.
Scope
Implemented:
- Xero Accounting API client using a Custom Connection.
- Zoho CRM invoice creation from recurring
MSP_Invoice_Lines. - Xero draft invoice creation through the Flask/Deluge apply path.
- Flask routes for Zoho CRM buttons to generate one plan or all plans.
- Draft refresh for previously generated CRM/Xero invoices while Xero is still
DRAFT. - Middleware reconciliation routes for CRM invoice-line and plan-level workflows.
- CRM update with
Xero_Invoice_ID,Xero_Invoice_Number, andXero_Sync_Status. - Preview reports before write operations.
- Read-only audit reports comparing generated CRM previews to referenced Xero invoice lines.
- Duplicate guard using
MSP_Invoice_Key. - Recurring annual seat renewal and one-off seat prorate lines represented in
MSP_Invoice_Lines. - Preview-only CLI for report generation. Live CRM/Xero writes use the same Flask routes that Zoho Deluge calls.
Not implemented yet:
- Scheduled monthly batch generation or an
MSP Billing Runsmodule. - Approval or sending of Xero invoices.
- Bi-directional state sync after the initial draft create.
- Dynamic device counts.
Code Map
-
xero_sync/auth.pyLoads Xero settings, defaults to Custom Connection auth, fetchesclient_credentialstokens, and writes.xero_token_cache.json. -
xero_sync/client.pyThin Accounting API client for invoices and connection discovery. In Custom Connection mode it omitsXero-Tenant-Id; Xero binds the token to the single organisation behind the custom connection. -
xero_sync/invoice_sync.pyReconciliation helpers for matching existing Zoho CRM invoices to Xero invoices. This is separate from the MSP invoice generator. -
xero_sync/cli.pyOperational CLI for Xero checks and invoice reconciliation experiments. -
msp_imports/msp_invoices/MSP invoice generator package. It reads CRM Managed Services Plans, MSP Seats, MSP Invoice Lines, Accounts, Products, and existing Invoices. It produces preview rows for the CLI and live CRM/Xero writes for the Flask/Deluge apply path.msp_imports/generate_msp_invoices.pyremains the preview CLI shim forpython -m msp_imports.generate_msp_invoices. -
msp_imports/audit_msp_xero_invoices.pyRead-only reference audit command. It reads an exported MSP Plans CSV, follows the stored Xero invoice reference, classifies invoice lines, and writes CSV and Markdown audit reports. -
app/blueprints/msp_invoices.pyToken-protected Flask endpoints used by Zoho CRM custom buttons and invoice-line workflows. -
msp_imports/msp_invoices/reconcile.pyIdempotent reconciliation forMSP_Invoice_Lines: date state transitions, Xero account-code calculation, and plan-level account propagation. -
zoho_crm/deluge/standalone.generate_msp_invoice_for_plan.dgReference Deluge function that calls the one-plan Flask route. -
zoho_crm/deluge/standalone.generate_monthly_msp_invoices.dgReference Deluge function that calls the all-plans Flask route. -
zoho_crm/deluge/button.call_generate_msp_invoice_for_plan.dgThin call function for the plan-level CRM button. -
zoho_crm/deluge/button.call_generate_monthly_msp_invoices.dgThin call function for the list-level CRM button. -
zoho_crm/deluge/standalone.reconcile_msp_invoice_line.dgReference Deluge function that calls the invoice-line reconcile route. -
zoho_crm/deluge/standalone.reconcile_msp_plan_invoice_lines.dgReference Deluge function that calls the plan invoice-line reconcile route. -
zoho_crm/deluge/automation.call_reconcile_msp_invoice_line.dgThin CRM workflow wrapper for anMSP_Invoice_Linescreate/edit event. -
zoho_crm/deluge/automation.call_reconcile_msp_plan_invoice_lines.dgLegacy thin CRM workflow wrapper for the plan invoice-line reconcile route. The route is retained for compatibility and does not apply billing rules fromPlan_Type.
Authentication
The Xero integration uses a Custom Connection. XERO_AUTH_MODE is optional and
defaults to custom_connection; do not prefix commands with
env XERO_AUTH_MODE=custom_connection.
Required environment variables:
XERO_CLIENT_ID=...
XERO_CLIENT_SECRET=...
Optional environment variables:
XERO_AUTH_MODE=custom_connection
XERO_SCOPES=accounting.invoices
Defaults:
- Auth mode:
custom_connection - Scope:
accounting.invoices - Xero account code: from
MSP_Invoice_Lines.Xero_Account_Code_Overridewhen filled, otherwise from ProductXero_Account_Code - Xero tax type:
OUTPUT - Xero item code:
003/ Consulting
The token cache is local runtime state and must not be committed:
.xero_token_cache.json
CRM Setup
Account Fields
Accounts must have:
Xero_Contact_ID
The generator uses this ID as the Xero invoice contact:
{"Contact": {"ContactID": "<Xero_Contact_ID>"}}
Invoice Fields
Invoices must have:
MSP_Planlookup toManaged_Services_PlansMSP_Billing_Period_StartdateMSP_Billing_Period_EnddateMSP_Invoice_Keysingle lineXero_Invoice_IDsingle lineXero_Invoice_Numbersingle lineXero_Sync_Statuspicklist or single lineupdate_sourcesingle line
The current source marker is:
msp_invoice_automation
The invoice key format is:
<managed_services_plan_id>|YYYY-MM
Example:
42659000014776835|2026-05
Product Setup
The generator expects these Zoho CRM Products:
| Product Code | Purpose | Default_Invoice_Label example |
|---|---|---|
MSP-SEAT-SB / MSP-SEAT-L1 / MSP-SEAT-L2 / MSP-SEAT-L3 |
Package-specific monthly seat fee | Solopreneur Blend |
MSP-SEAT-ANNUAL-SB / MSP-SEAT-ANNUAL-L1 / MSP-SEAT-ANNUAL-L2 / MSP-SEAT-ANNUAL-L3 |
Package-specific yearly seat fee; quantity comes from MSP Seats and the line recurs yearly from its Start_Date renewal anchor |
Annual Premium Security |
MSP-PRORATE-SEAT |
One-off prorated annual seat addition; quantity stays on the invoice line | Prorated annual seat charge |
MSP-BASE |
Base monthly infrastructure fee | Base Infrastructure |
MSP-DEVICE |
Extra device fee | Extra Device |
MSP-DISCOUNT |
Regular MSP discount | Discount |
MSP-NFP-DISCOUNT |
NFP discount | NFP Discount |
MSP-ADJUSTMENT |
Recurring MSP adjustment foundation | MSP Adjustment |
These products are used to satisfy Zoho CRM inventory-module requirements and to
make invoice lines readable in CRM. They do not need to be synced as Xero Items.
The Xero invoice lines are sent with descriptions, quantities, unit amounts,
tax type, account code, and the default ItemCode 003 / Consulting.
Invoice description labels come from CRM invoice-line records:
MSP_Invoice_Lines.Description_Override, when filled.Products.Default_Invoice_Label, when the line override is blank.Products.Product_Name, as the final fallback.
Unit prices come from MSP_Invoice_Lines.Unit_Price_Override when filled,
otherwise from Product Unit_Price. Xero revenue account codes come from
MSP_Invoice_Lines.Xero_Account_Code_Override when filled, otherwise from
Product Xero_Account_Code. Each generated line must have a linked Product.
Missing quantity or resolved account code values are reported as warnings and
passed through as blank values so the draft can still be generated for finance
review. A missing resolved unit price is reported as a warning and sent as
0.00 so Xero cannot fill an item default price.
Managed Services Plan Plan_Type is metadata only. Operators may add or edit
plan-type labels without changing invoice pricing, Product selection, seat
quantity, or Xero account-code rules.
Data Mapping
CRM Preview Inputs
Managed Services Plan fields:
idNameAccountDealPlan_StatusPlan_TypemetadataBilling_StartBilling_EndInclude_Users_InvoiceMay_2026_Invoice_Ref
MSP Invoice Line fields:
idNamePlanStart_DateEnd_DateSort_OrderProductQuantityUnit_Price_OverrideDescription_OverrideXero_Account_Code_Override
MSP Seat fields:
idPlanBilling_StatusBilling_StartBilling_End
Account fields:
idAccount_NameXero_Contact_ID
Product fields:
idProduct_NameProduct_CodeDefault_Invoice_LabelUnit_PriceXero_Account_Code
Existing invoice fields:
idInvoice_NumberSubjectStatusMSP_Invoice_KeyXero_Invoice_IDXero_Invoice_NumberXero_Sync_Status
Invoice Lines
Current invoices generate one line for each applicable MSP_Invoice_Lines
record. Non-annual lines are applicable when their Start_Date / End_Date
range overlaps the invoice month. Annual seat lines are applicable only in the
first renewal invoice month and each 12-month anniversary after that. Internal
line behavior is derived from the linked Product code; MSP_Invoice_Lines no
longer needs a Line_Type field.
Plan type is not used to classify invoice lines.
The MSP invoice path is for recurring managed-service items. One-off
onboarding, project, or deal invoices should be generated outside this path
through Deal-led invoicing.
Managed Services Plan Plan_Status does not gate recurring invoice generation.
The plan's Billing_Start / Billing_End range gates whether the plan is in
scope for a month.
Plans missing Billing_Start need review before generation.
Seat count is based on MSP_Seats records whose Billing_Start and
Billing_End range overlaps the target month. Billing_Status does not drive
invoicing at this stage. Seats missing Billing_Start are excluded and
reported as a review issue.
Seat-line quantity is calculated from MSP Seats when the linked Product code
starts with MSP-SEAT-. The current monthly seat Products are MSP-SEAT-SB,
MSP-SEAT-L1, MSP-SEAT-L2, and MSP-SEAT-L3.
Annual seat Products are the exact recurring annual set
MSP-SEAT-ANNUAL-SB, MSP-SEAT-ANNUAL-L1, MSP-SEAT-ANNUAL-L2, and
MSP-SEAT-ANNUAL-L3. For those Products, Start_Date is the renewal-month
anchor and must be the first day of that invoice month. With a blank
End_Date, the line appears in the first renewal invoice month and then every
12 months. If the line should stop, set End_Date before the next unwanted
renewal month. The generated quantity comes from billing-eligible MSP Seats in
the renewal invoice month, and the generator appends the covered annual period
to the line description.
One-off annual seat prorates use MSP-PRORATE-SEAT. That Product code does not
start with MSP-SEAT-, so the generated quantity comes from
MSP_Invoice_Lines.Quantity, normally 1, and the prorate amount comes from
Unit_Price_Override.
One-off annual seat prorate lines should be bounded to one invoice month by
setting both Start_Date and End_Date inside that month. The generated
monthly invoice is still keyed by plan and month; the prorate line description
should state the covered annual period.
Line descriptions are not month-suffixed automatically. The invoice subject and billing period fields carry the month:
Base Infrastructure Charge
Premium Monthly Support
Additional Device:
1. Training laptop
Billing-eligible seat names are listed on the seat line description only when
Managed_Services_Plans.Include_Users_Invoice is true. Blank or false means the
invoice shows only the quantity, not the user list. When user names are enabled,
the generator uses the MSP_Seats.Contact display name first. If a legacy seat
does not have a Contact lookup value in the fetched data, it falls back to the
MSP Seat Name with the plan suffix stripped.
If an imported seat invoice-line description already contains a trailing numbered staff list, the generator removes that historical list before appending the current billing-eligible seat list. The preview report records this as a non-blocking warning so staff can check the CRM seat records.
The preview report also compares each seat line's imported invoice-line
Quantity with the current billing-eligible seat count, even when user names
are not listed on the invoice. Differences are recorded in seat_quantity_gap
for audit visibility, but they do not appear as generation warnings.
Device details should be stored directly in the device invoice line's
Description_Override when they need to appear on the invoice.
Zoho CRM Invoice Payload
The generator creates CRM invoices with:
Subject:<Account Name> - MSP - <Month YYYY>Account_Name: CRM Account lookupInvoice_Date: first day of billing monthDue_Date: last day of billing monthStatus:CreatedMSP_Plan: Managed Services Plan lookupMSP_Billing_Period_Start: first day of billing monthMSP_Billing_Period_End: last day of billing monthMSP_Invoice_Key: duplicate guard keyupdate_source:msp_invoice_automationInvoiced_Items: CRM product line items
Zoho CRM inventory taxes require both the invoice-level $line_tax declaration
and each line's Line_Tax. The generator sends Sales Tax at 10% on both
levels so CRM totals match Xero GST totals.
Xero Invoice Payload
The generator creates Xero invoices with:
Type:ACCRECStatus:DRAFTContact.ContactID: AccountXero_Contact_IDDate: first day of billing monthDueDate: last day of billing monthLineAmountTypes:ExclusiveReference:Peppermint iTLineItems: generated MSP base, seat, prorate, device, license, subscription, discount, and adjustment lines
Each Xero line gets:
DescriptionQuantityUnitAmountAccountCode: fromMSP_Invoice_Lines.Xero_Account_Code_Overridewhen filled, otherwise from ProductXero_Account_CodeTaxType:OUTPUTby defaultItemCode:003/ Consulting by default
The Xero idempotency key is:
msp-invoice-<managed_services_plan_id>-YYYY-MM
Operational Commands
Run commands from the repo root.
Default Billing Month
HTTP/Deluge-backed generation defaults the billing month in Australia/Sydney
time:
- days
1-20: current calendar month - days
21+: next calendar month
Examples:
2026-06-20->2026-062026-06-21->2026-072026-12-21->2027-01
The preview CLI defaults to the current calendar month if --month is omitted.
Live HTTP calls can omit billing_month to use the billing-cycle rule above.
Preview One Plan
python -m msp_imports.generate_msp_invoices \
--plan-id 42659000014776835 \
--month 2026-05
This writes a CSV report under msp_imports/reports/ and does not mutate CRM or
Xero.
Audit Existing Xero References
Use this when reviewing whether current Managed Services Plan fields can regenerate existing invoices closely. The command is read-only against CRM and Xero. It writes CSV and Markdown reports and does not create or update invoices.
python -m msp_imports.audit_msp_xero_invoices \
--plans-csv "/Users/felix/Downloads/MSP Plans for Invoice (1).csv" \
--month 2026-05
To inspect only the Xero references without fetching CRM-generated previews:
python -m msp_imports.audit_msp_xero_invoices \
--plans-csv "/Users/felix/Downloads/MSP Plans for Invoice (1).csv" \
--month 2026-05 \
--skip-crm-preview
The audit classifies referenced Xero lines as base, seats, devices,
discounts, adjustments, or license. It reports:
- generated MSP subtotal from CRM previews when available
- Xero MSP subtotal from classified recurring MSP, license, subscription, discount, and adjustment lines
- preview issues from the invoice-line generator
- recommendations for recurring discount or adjustment invoice-line records
--create-drafts exists as a future-safe flag, but currently fails closed with
a clear error. Draft license invoice creation must wait until license products,
account-code rules, and invoice grouping rules are configured.
Live Local Test
Live CRM/Xero writes use the same HTTP routes as Zoho Deluge. The local server
defaults to port 5050.
For a local one-plan test with an explicit billing month:
curl -sS -X POST "http://localhost:5050/zoho/msp-invoices/generate-plan" \
-H "Authorization: Bearer $INBOUND_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{"plan_id":"42659000014776835","billing_month":"2026-06"}' | jq
For a local one-plan test using the default billing month:
curl -sS -X POST "http://localhost:5050/zoho/msp-invoices/generate-plan" \
-H "Authorization: Bearer $INBOUND_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{"plan_id":"42659000014776835"}' | jq
For a local all-plans test with an explicit billing month:
curl -sS -X POST "http://localhost:5050/zoho/msp-invoices/generate-all" \
-H "Authorization: Bearer $INBOUND_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{"billing_month":"2026-06"}' | jq
For a local all-plans test using the default billing month:
curl -sS -X POST "http://localhost:5050/zoho/msp-invoices/generate-all" \
-H "Authorization: Bearer $INBOUND_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{}' | jq
The HTTP route is the only live apply path. It creates missing CRM/Xero draft
invoices, refreshes existing generated invoices while the linked Xero invoice is
still DRAFT, and returns locked once Xero is no longer editable.
Flask Routes for CRM Buttons
Both routes use the normal inbound bearer token protection:
Authorization: Bearer <INBOUND_API_TOKEN>
Zoho Deluge should not calculate the billing month. If the request omits
billing_month, Flask applies the default cycle rule above.
Generate or Refresh One Plan
POST /zoho/msp-invoices/generate-plan
Payload:
{
"plan_id": "42659000014776835",
"billing_month": "2026-06"
}
billing_month is optional.
Reconcile One Invoice Line
POST /zoho/msp-invoices/reconcile-line
Payload:
{
"invoice_line_id": "42659000015269037"
}
This endpoint is called by the MSP_Invoice_Lines create/edit workflow. It is
currently a compatibility no-op for date, price, and Xero account-code fields
so operator-entered values stay unchanged and blank overrides can continue to
use Product defaults.
Reconcile Plan Invoice Lines
POST /zoho/msp-invoices/reconcile-plan-lines
Payload:
{
"plan_id": "42659000014776835"
}
This compatibility endpoint no longer updates invoice-line account-code overrides and does not read plan type values. Blank override values use Product defaults during invoice generation.
Generate or Refresh All Plans
POST /zoho/msp-invoices/generate-all
Payload:
{
"billing_month": "2026-06"
}
billing_month is optional.
Response shape:
{
"billing_month": "2026-06",
"mode": "apply",
"summary": {
"created": 1,
"updated": 0,
"locked": 0,
"skipped": 0,
"needs_review": 0,
"failed": 0
},
"results": [
{
"plan_id": "42659000014776835",
"invoice_key": "42659000014776835|2026-06",
"action": "created",
"crm_invoice_id": "42659000014966008",
"xero_invoice_id": "3f48f675-b4d7-4086-8d98-4e1bac588909",
"xero_status": "DRAFT",
"message": "Created CRM invoice and Xero draft."
}
]
}
Actions:
created: no existing CRM invoice existed; CRM invoice and Xero draft were created and linked.updated: existing generated invoice was refreshed because the linked Xero invoice is stillDRAFT.locked: existing linked Xero invoice is notDRAFT, so neither CRM nor Xero was changed.skipped: reserved response bucket; current generation rules do not skip plans through a plan-level manual-generation flag.needs_review: structural setup needs manual review before any live write.failed: unexpected fetch or write failure.
Zoho CRM Buttons
Create two CRM custom buttons that call the button.call_* Deluge
functions:
| Button | Module | Placement | Deluge function |
|---|---|---|---|
Generate / Refresh MSP Invoice |
Managed_Services_Plans |
record detail page | button.call_generate_msp_invoice_for_plan |
Generate Monthly MSP Invoices |
Managed_Services_Plans |
list utility menu | button.call_generate_monthly_msp_invoices |
The standalone.* functions read these CRM Variables by API name:
MIDDLEWARE_BASE_URLMIDDLEWARE_API_TOKEN
The button.call_* functions are the CRM button entrypoints and only delegate
to the standalone functions. The functions intentionally send only the plan ID
or an empty payload plus a message-response flag. Flask owns the billing cycle
default. The CRM popup displays the plain-text message response; structured
JSON remains available when the routes are called without that flag.
Create two CRM workflow function wrappers:
| Workflow | Module | Deluge function |
|---|---|---|
| Reconcile MSP invoice line | MSP_Invoice_Lines |
automation.call_reconcile_msp_invoice_line |
| Reconcile MSP plan invoice lines | Managed_Services_Plans |
automation.call_reconcile_msp_plan_invoice_lines |
These wrappers delegate to standalone.reconcile_msp_invoice_line and
standalone.reconcile_msp_plan_invoice_lines, which call the middleware.
Validation and Duplicate Guards
The generator refuses live writes when structural preview issues remain. Warnings are reported in the response message and preview report but do not block create or refresh. CRM button popups show only the warning text when warnings exist.
Common needs_review causes:
- Account is missing
Xero_Contact_ID - Plan is missing
Billing_Start - No applicable
MSP_Invoice_Linesrecords exist for the month - An applicable invoice line is missing Product or references a Product that was not found
Common warning causes:
- MSP seats missing
Billing_Startwere excluded - MSP invoice lines missing
Start_Datewere excluded - An applicable invoice line is missing Quantity, a resolved Unit Price, or a resolved Xero account code
Invoice generation does not use a plan-level manual-generation flag. A plan can still generate a monthly invoice when it has applicable, in-range invoice lines, even if other one-off or pre-billed lines are out of range.
Preview report warnings are non-blocking. They call out cleanup gaps such as blank pricing/account-code setup and imported seat descriptions that contained a historical numbered staff list which was replaced with the current billing-eligible MSP seat list.
The Flask flow treats a skip_existing preview as a refresh request:
- If the CRM invoice has a linked Xero invoice and Xero is
DRAFT, it rebuilds the generated invoice lines from current CRM data and updates both records. - If Xero is no longer
DRAFT, it returnslockedand does not mutate CRM or Xero. - If the existing CRM invoice line product-id order still matches the generated invoice-line preview, CRM line item IDs are preserved and updated in place.
- If the existing CRM invoice line product-id order has changed, the refresh
deletes the old generated CRM line items and adds the regenerated line items.
This supports the normal draft workflow: generate, review, edit
MSP_Invoice_Lines, and generate again until the linked Xero invoice moves pastDRAFT. - If the existing CRM invoice has no Xero ID, it creates and links a Xero draft only when the CRM invoice is still in the generated editable state.
Verification Commands
Run focused tests:
python -m pytest \
tests/test_generate_msp_invoices.py \
tests/test_msp_invoice_preview.py \
tests/test_xero_reference_audit.py \
tests/test_xero_client.py \
-q
Run all tests:
python -m pytest -q
Check the effective Xero auth mode and scopes:
python -c "from xero_sync.auth import load_config; c=load_config(); print(c.auth_mode); print(' '.join(c.scopes))"
Expected output:
custom_connection
accounting.invoices
Historical Live Smoke Test
The first live Sharps test used Managed Services Plan 42659000014776835 for
May 2026.
Created records:
- Xero draft:
INV-0136 - Xero Invoice ID:
3f48f675-b4d7-4086-8d98-4e1bac588909 - CRM Invoice ID:
42659000014966008 - CRM Invoice Number:
42659000014966011
Verified totals:
- Base fee:
1 x 500 - Seats:
27 x 219 - Subtotal:
6413 - GST:
641.30 - Total:
7054.30
Design Notes
- CRM remains the operational source of truth for MSP plans, seat counts, and generated invoice records.
- Xero remains the accounting system of record. The automation creates Xero
invoices in
DRAFTso finance can review before approval or sending. - Zoho Billing is intentionally not in the path.
- Products are standard CRM invoice products, not per-client products.
- Contract pricing, device lines, discounts, adjustments, licenses, and
subscriptions come from
MSP_Invoice_Lines. - Seat quantity remains dynamic from
MSP_SeatswhoseBilling_StartandBilling_Endrange applies to the invoice month.Billing_Statusdoes not drive invoicing at this stage; the invoice-lineQuantityvalue on seat lines is import/reference data only. - Licenses, voice, and other subscriptions remain on the main MSP invoice.
External References
- Xero OAuth 2.0 overview: https://developer.xero.com/documentation/guides/oauth2/overview
- Xero Custom Connections: https://developer.xero.com/documentation/guides/oauth2/custom-connections/
- Xero Accounting API Invoices: https://developer.xero.com/documentation/api/accounting/invoices
- Xero invoice creation best practices: https://developer.xero.com/documentation/best-practices/data-integrity/creating-invoices
- Zoho CRM API v8 Insert Records: https://www.zoho.com/crm/developer/docs/api/v8/insert-records.html