2026-06-11 23:56:02 +12:00
|
|
|
"""Xero integration layer for the ordering portal.
|
|
|
|
|
|
|
|
|
|
This is a **clean, stubbed interface**. No Xero credentials are hard-coded — all
|
|
|
|
|
configuration comes from environment variables (``XERO_*``). When credentials
|
|
|
|
|
are absent (the default in dev/alpha), the service runs in "stub" mode: it
|
|
|
|
|
records what *would* be sent and returns a deterministic fake invoice id so the
|
|
|
|
|
rest of the order lifecycle can be exercised end-to-end.
|
|
|
|
|
|
|
|
|
|
To go live, implement the real HTTP calls inside :func:`_submit_to_xero_api`
|
|
|
|
|
(see the TODOs) — no caller needs to change.
|
|
|
|
|
|
|
|
|
|
Service responsibilities (per spec):
|
|
|
|
|
* Map a customer (ClientAccount) to a Xero contact.
|
|
|
|
|
* Map catalogue products to Xero item codes.
|
|
|
|
|
* Create a draft invoice equivalent for a confirmed order.
|
|
|
|
|
* Record Xero response ids/status (via XeroSyncLog, written by the caller).
|
|
|
|
|
* Handle failures gracefully (never raise into the request path).
|
|
|
|
|
"""
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import os
|
|
|
|
|
from dataclasses import dataclass, field
|
|
|
|
|
from datetime import datetime
|
|
|
|
|
|
|
|
|
|
from app.models.client_access import ClientAccount
|
2026-06-13 10:00:15 +12:00
|
|
|
from app.models.ordering import Order, XeroContactLink
|
2026-06-11 23:56:02 +12:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass
|
|
|
|
|
class XeroConfig:
|
|
|
|
|
client_id: str | None = None
|
|
|
|
|
client_secret: str | None = None
|
|
|
|
|
tenant_id: str | None = None
|
|
|
|
|
base_url: str = "https://api.xero.com/api.xro/2.0"
|
|
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
def configured(self) -> bool:
|
|
|
|
|
return bool(self.client_id and self.client_secret and self.tenant_id)
|
|
|
|
|
|
|
|
|
|
@classmethod
|
|
|
|
|
def from_env(cls) -> "XeroConfig":
|
|
|
|
|
return cls(
|
|
|
|
|
client_id=os.getenv("XERO_CLIENT_ID") or None,
|
|
|
|
|
client_secret=os.getenv("XERO_CLIENT_SECRET") or None,
|
|
|
|
|
tenant_id=os.getenv("XERO_TENANT_ID") or None,
|
|
|
|
|
base_url=os.getenv("XERO_API_BASE_URL", "https://api.xero.com/api.xro/2.0"),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass
|
|
|
|
|
class XeroSubmissionResult:
|
|
|
|
|
status: str # "success" | "failed"
|
|
|
|
|
xero_invoice_id: str | None
|
|
|
|
|
message: str
|
|
|
|
|
request_summary: str
|
|
|
|
|
stubbed: bool = False
|
|
|
|
|
line_items: list[dict] = field(default_factory=list)
|
|
|
|
|
|
|
|
|
|
|
2026-06-13 10:00:15 +12:00
|
|
|
@dataclass
|
|
|
|
|
class XeroContact:
|
|
|
|
|
"""A Xero contact available to link a customer against."""
|
|
|
|
|
|
|
|
|
|
contact_id: str
|
|
|
|
|
name: str
|
|
|
|
|
email: str | None = None
|
|
|
|
|
status: str = "ACTIVE"
|
|
|
|
|
|
|
|
|
|
def as_dict(self) -> dict:
|
|
|
|
|
return {
|
|
|
|
|
"contact_id": self.contact_id,
|
|
|
|
|
"name": self.name,
|
|
|
|
|
"email": self.email,
|
|
|
|
|
"status": self.status,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# Deterministic sample contacts used while running in stub mode (no Xero
|
|
|
|
|
# credentials). They stand in for "what's in Xero" so the customer→contact
|
|
|
|
|
# mapping UI is usable before the live API is wired. Ids mimic Xero GUIDs.
|
|
|
|
|
_STUB_CONTACTS: tuple[XeroContact, ...] = (
|
|
|
|
|
XeroContact("STUB-CON-0001", "Hunter Premium Produce", "accounts@hunterpremium.example", "ACTIVE"),
|
|
|
|
|
XeroContact("STUB-CON-0002", "Mayreef Pty Ltd", "ap@mayreef.example", "ACTIVE"),
|
|
|
|
|
XeroContact("STUB-CON-0003", "Ian McKay Stock Feeds", "ian@mckayfeeds.example", "ACTIVE"),
|
|
|
|
|
XeroContact("STUB-CON-0004", "Peckish Bird Foods", "orders@peckish.example", "ACTIVE"),
|
|
|
|
|
XeroContact("STUB-CON-0005", "Hay & Straw Co", "info@hayandstraw.example", "ACTIVE"),
|
|
|
|
|
XeroContact("STUB-CON-0006", "PHF Horse Mixes", "accounts@phfhorse.example", "ACTIVE"),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _fetch_contacts_from_api(config: XeroConfig) -> list[XeroContact]:
|
|
|
|
|
"""Live contact fetch. Stubbed until credentials/endpoints are wired.
|
|
|
|
|
|
|
|
|
|
TODO (go-live): GET ``{config.base_url}/Contacts`` with the
|
|
|
|
|
``Xero-tenant-id`` header, page through ``Contacts[]`` and map each onto a
|
|
|
|
|
:class:`XeroContact` (``ContactID``/``Name``/``EmailAddress``/``ContactStatus``).
|
|
|
|
|
"""
|
|
|
|
|
raise NotImplementedError("Live Xero contact fetch is not implemented yet.")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def list_xero_contacts(config: XeroConfig | None = None) -> tuple[list[XeroContact], bool]:
|
|
|
|
|
"""Return the Xero contacts available for linking and whether they're stubbed.
|
|
|
|
|
|
|
|
|
|
Never raises — on a live-mode error it returns an empty list so the mapping
|
|
|
|
|
console still renders.
|
|
|
|
|
"""
|
|
|
|
|
config = config or XeroConfig.from_env()
|
|
|
|
|
if not config.configured:
|
|
|
|
|
return list(_STUB_CONTACTS), True
|
|
|
|
|
try:
|
|
|
|
|
return _fetch_contacts_from_api(config), False
|
|
|
|
|
except Exception: # pragma: no cover - defensive: never break the request path
|
|
|
|
|
return [], False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def map_customer_to_contact(customer: ClientAccount, link: XeroContactLink | None = None) -> dict:
|
|
|
|
|
"""Map a customer account onto a Xero contact payload.
|
|
|
|
|
|
|
|
|
|
When the customer has been linked to a Xero contact we send the real
|
|
|
|
|
``ContactID`` so Xero attaches the invoice to the existing contact. Without a
|
|
|
|
|
link we fall back to keying on the client code (Xero will match-or-create).
|
|
|
|
|
"""
|
|
|
|
|
if link is not None and link.xero_contact_id:
|
|
|
|
|
return {
|
|
|
|
|
"ContactID": link.xero_contact_id,
|
|
|
|
|
"Name": link.xero_contact_name or customer.name,
|
|
|
|
|
}
|
2026-06-11 23:56:02 +12:00
|
|
|
return {
|
|
|
|
|
"ContactNumber": customer.client_code,
|
|
|
|
|
"Name": customer.name,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def map_product_to_item_code(product_sku: str) -> str:
|
|
|
|
|
"""Map a catalogue SKU onto a Xero item code.
|
|
|
|
|
|
|
|
|
|
TODO: support an explicit SKU→Xero item-code mapping table if the codes
|
|
|
|
|
diverge. Today the SKU is used directly.
|
|
|
|
|
"""
|
|
|
|
|
return product_sku
|
|
|
|
|
|
|
|
|
|
|
2026-06-13 10:00:15 +12:00
|
|
|
def build_invoice_payload(
|
|
|
|
|
order: Order, customer: ClientAccount, link: XeroContactLink | None = None
|
|
|
|
|
) -> dict:
|
2026-06-11 23:56:02 +12:00
|
|
|
"""Build the Xero draft-invoice payload for a confirmed order."""
|
|
|
|
|
line_items = []
|
|
|
|
|
for line in order.lines:
|
|
|
|
|
if line.requires_quote or line.unit_price is None:
|
|
|
|
|
# Quote-only lines can't carry a price; skip until quoted.
|
|
|
|
|
continue
|
|
|
|
|
unit_price = line.admin_override_price if line.admin_override_price is not None else line.unit_price
|
|
|
|
|
line_items.append(
|
|
|
|
|
{
|
|
|
|
|
"ItemCode": map_product_to_item_code(line.product_sku),
|
|
|
|
|
"Description": line.product_name,
|
|
|
|
|
"Quantity": line.quantity,
|
|
|
|
|
"UnitAmount": unit_price,
|
|
|
|
|
"AccountCode": os.getenv("XERO_SALES_ACCOUNT_CODE", "200"),
|
|
|
|
|
# Prices are GST-exclusive throughout the platform.
|
|
|
|
|
"TaxType": os.getenv("XERO_TAX_TYPE", "OUTPUT"),
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
return {
|
|
|
|
|
"Type": "ACCREC",
|
|
|
|
|
"Status": "DRAFT",
|
2026-06-13 10:00:15 +12:00
|
|
|
"Contact": map_customer_to_contact(customer, link),
|
2026-06-11 23:56:02 +12:00
|
|
|
"Reference": order.purchase_order_number or order.order_number or f"Order {order.id}",
|
|
|
|
|
"LineAmountTypes": "Exclusive",
|
|
|
|
|
"LineItems": line_items,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _submit_to_xero_api(config: XeroConfig, payload: dict) -> XeroSubmissionResult:
|
|
|
|
|
"""Real Xero submission. Stubbed until credentials/endpoints are wired.
|
|
|
|
|
|
|
|
|
|
TODO (go-live):
|
|
|
|
|
1. Obtain an OAuth2 token (client-credentials or stored refresh token).
|
|
|
|
|
2. POST ``payload`` to ``{config.base_url}/Invoices`` with the
|
|
|
|
|
``Xero-tenant-id`` header set to ``config.tenant_id``.
|
|
|
|
|
3. Parse ``Invoices[0].InvoiceID`` from the response.
|
|
|
|
|
4. Map non-2xx responses onto ``status="failed"`` with the error body.
|
|
|
|
|
"""
|
|
|
|
|
summary = f"{len(payload.get('LineItems', []))} line(s) for {payload['Contact']['Name']}"
|
|
|
|
|
# Real call would go here. Intentionally not implemented yet.
|
|
|
|
|
return XeroSubmissionResult(
|
|
|
|
|
status="failed",
|
|
|
|
|
xero_invoice_id=None,
|
|
|
|
|
message="Xero live submission is not implemented yet (stub interface).",
|
|
|
|
|
request_summary=summary,
|
|
|
|
|
stubbed=False,
|
|
|
|
|
line_items=payload.get("LineItems", []),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-06-13 10:00:15 +12:00
|
|
|
def submit_order_to_xero(
|
|
|
|
|
order: Order, customer: ClientAccount, link: XeroContactLink | None = None
|
|
|
|
|
) -> XeroSubmissionResult:
|
2026-06-11 23:56:02 +12:00
|
|
|
"""Submit a confirmed order to Xero, or stub it when unconfigured.
|
|
|
|
|
|
2026-06-13 10:00:15 +12:00
|
|
|
Pass ``link`` to invoice against the customer's mapped Xero contact. Never
|
|
|
|
|
raises — failures are returned as ``status="failed"`` results so the order
|
|
|
|
|
lifecycle can record the attempt and continue.
|
2026-06-11 23:56:02 +12:00
|
|
|
"""
|
|
|
|
|
config = XeroConfig.from_env()
|
2026-06-13 10:00:15 +12:00
|
|
|
payload = build_invoice_payload(order, customer, link)
|
2026-06-11 23:56:02 +12:00
|
|
|
summary = f"{len(payload['LineItems'])} line(s) for {payload['Contact']['Name']}"
|
|
|
|
|
|
|
|
|
|
if not config.configured:
|
|
|
|
|
# Stub mode: deterministic fake invoice id so downstream flows work.
|
|
|
|
|
fake_id = f"STUB-INV-{order.id:06d}"
|
|
|
|
|
return XeroSubmissionResult(
|
|
|
|
|
status="success",
|
|
|
|
|
xero_invoice_id=fake_id,
|
|
|
|
|
message="Xero not configured — order recorded in stub mode.",
|
|
|
|
|
request_summary=summary,
|
|
|
|
|
stubbed=True,
|
|
|
|
|
line_items=payload["LineItems"],
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
return _submit_to_xero_api(config, payload)
|
|
|
|
|
except Exception as exc: # pragma: no cover - defensive: never break the request path
|
|
|
|
|
return XeroSubmissionResult(
|
|
|
|
|
status="failed",
|
|
|
|
|
xero_invoice_id=None,
|
|
|
|
|
message=f"Xero submission error: {exc}",
|
|
|
|
|
request_summary=summary,
|
|
|
|
|
stubbed=False,
|
|
|
|
|
line_items=payload["LineItems"],
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def xero_status_snapshot() -> dict:
|
|
|
|
|
config = XeroConfig.from_env()
|
|
|
|
|
return {
|
|
|
|
|
"configured": config.configured,
|
|
|
|
|
"mode": "live" if config.configured else "stub",
|
|
|
|
|
"base_url": config.base_url,
|
|
|
|
|
"checked_at": datetime.utcnow().isoformat(),
|
|
|
|
|
"missing_env": [
|
|
|
|
|
name
|
|
|
|
|
for name, value in (
|
|
|
|
|
("XERO_CLIENT_ID", config.client_id),
|
|
|
|
|
("XERO_CLIENT_SECRET", config.client_secret),
|
|
|
|
|
("XERO_TENANT_ID", config.tenant_id),
|
|
|
|
|
)
|
|
|
|
|
if not value
|
|
|
|
|
],
|
|
|
|
|
}
|