"""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, XeroContactLink @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) @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, } 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 def build_invoice_payload( order: Order, customer: ClientAccount, link: XeroContactLink | None = None ) -> 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, link), "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, link: XeroContactLink | None = None ) -> XeroSubmissionResult: """Submit a confirmed order to Xero, or stub it when unconfigured. 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. """ config = XeroConfig.from_env() payload = build_invoice_payload(order, customer, link) 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 ], }