Files
data-entry-app/backend/app/services/xero_service.py
T
2026-06-11 23:56:02 +12:00

182 lines
6.6 KiB
Python

"""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
from app.models.ordering import Order
@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)
def map_customer_to_contact(customer: ClientAccount) -> dict:
"""Map a customer account onto a Xero contact payload."""
return {
# TODO: persist and reuse a real Xero ContactID once the contact has
# been created/matched in Xero. For now we key on the client code.
"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
def build_invoice_payload(order: Order, customer: ClientAccount) -> dict:
"""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",
"Contact": map_customer_to_contact(customer),
"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", []),
)
def submit_order_to_xero(order: Order, customer: ClientAccount) -> XeroSubmissionResult:
"""Submit a confirmed order to Xero, or stub it when unconfigured.
Never raises — failures are returned as ``status="failed"`` results so the
order lifecycle can record the attempt and continue.
"""
config = XeroConfig.from_env()
payload = build_invoice_payload(order, customer)
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
],
}