"""MYOB AccountRight Live integration — create customer contacts. Used when the owner ticks "Also create in MYOB" while adding a new client in the control panel. The integration is optional: when the required environment variables are not set, :func:`is_configured` returns ``False`` and the caller skips MYOB entirely (the local client is still created). Auth model (MYOB OAuth 2.0): * A developer key/secret (``client_id`` / ``client_secret``) identifies this app to MYOB. * A long-lived refresh token mints short-lived bearer access tokens. We cache the access token in memory and refresh it shortly before it expires. * Each AccountRight company file lives at its own base URI and is gated by a company-file token: ``base64("username:password")`` sent as the ``x-myobapi-cftoken`` header (password may be blank). All HTTP calls are synchronous (``requests``); the async app invokes the public helpers via ``asyncio.to_thread`` so the event loop is never blocked. """ from __future__ import annotations import base64 import threading import time import requests from mail_api.config import ( MYOB_API_KEY, MYOB_API_SECRET, MYOB_CF_PASSWORD, MYOB_CF_USERNAME, MYOB_COMPANY_FILE_URI, MYOB_ENABLED, MYOB_HTTP_TIMEOUT_SECONDS, MYOB_REFRESH_TOKEN, MYOB_TOKEN_URL, logger, ) class MyobError(Exception): """Raised when a MYOB API call cannot be completed.""" # Access tokens are short-lived (MYOB returns ~20 min). Cache the current token # and its expiry so back-to-back client creations reuse a single refresh. _token_lock = threading.Lock() _cached_token: str = "" _cached_token_expires_at: float = 0.0 # Refresh a little early so a token never expires mid-request. _TOKEN_EXPIRY_SKEW_SECONDS = 60 def is_configured() -> bool: """True when every credential needed to talk to MYOB is present.""" return MYOB_ENABLED def _cf_token() -> str: raw = f"{MYOB_CF_USERNAME}:{MYOB_CF_PASSWORD}".encode("utf-8") return base64.b64encode(raw).decode("ascii") def _refresh_access_token() -> str: """Exchange the stored refresh token for a fresh access token. Caches the result in module state. Raises :class:`MyobError` on failure. """ global _cached_token, _cached_token_expires_at try: resp = requests.post( MYOB_TOKEN_URL, data={ "client_id": MYOB_API_KEY, "client_secret": MYOB_API_SECRET, "grant_type": "refresh_token", "refresh_token": MYOB_REFRESH_TOKEN, }, headers={"Content-Type": "application/x-www-form-urlencoded"}, timeout=MYOB_HTTP_TIMEOUT_SECONDS, ) except requests.RequestException as exc: raise MyobError(f"Could not reach the MYOB token service: {exc}") from exc if resp.status_code != 200: raise MyobError( f"MYOB token refresh failed (HTTP {resp.status_code}). " "Check MYOB_API_KEY / MYOB_API_SECRET / MYOB_REFRESH_TOKEN." ) try: payload = resp.json() except ValueError as exc: raise MyobError("MYOB token service returned an unreadable response.") from exc token = str(payload.get("access_token") or "").strip() if not token: raise MyobError("MYOB token service did not return an access token.") try: expires_in = int(payload.get("expires_in", 1200)) except (TypeError, ValueError): expires_in = 1200 _cached_token = token _cached_token_expires_at = time.monotonic() + max(0, expires_in - _TOKEN_EXPIRY_SKEW_SECONDS) logger.info("MYOB: access token refreshed (valid ~%ds)", expires_in) return token def _access_token() -> str: with _token_lock: if _cached_token and time.monotonic() < _cached_token_expires_at: return _cached_token return _refresh_access_token() def _api_headers() -> dict[str, str]: return { "Authorization": f"Bearer {_access_token()}", "x-myobapi-key": MYOB_API_KEY, "x-myobapi-version": "v2", "x-myobapi-cftoken": _cf_token(), "Content-Type": "application/json", "Accept": "application/json", } def _split_name(full_name: str) -> tuple[str, str]: """Best-effort split of a single owner name into first/last for MYOB.""" parts = full_name.split() if not parts: return ("", "") if len(parts) == 1: return (parts[0], "") return (parts[0], " ".join(parts[1:])) def create_customer( *, email: str, full_name: str, phone: str = "", street: str = "", source: str = "", ) -> dict[str, str]: """Create an individual customer contact in MYOB. Returns ``{"uid": ..., "uri": ...}`` parsed from the ``Location`` header MYOB returns on a successful create. Raises :class:`MyobError` on any failure so the caller can record it without aborting the local client creation. """ if not is_configured(): raise MyobError("MYOB integration is not configured.") first_name, last_name = _split_name(full_name) body = { "IsIndividual": True, "FirstName": first_name, "LastName": last_name, "IsActive": True, "Addresses": [ { "Location": 1, "Email": email, "Phone1": phone, "Street": street, } ], } if source: body["Notes"] = f"Added via Goodwalk control panel — source: {source}" url = f"{MYOB_COMPANY_FILE_URI}/Contact/Customer" try: resp = requests.post( url, json=body, headers=_api_headers(), timeout=MYOB_HTTP_TIMEOUT_SECONDS, ) except requests.RequestException as exc: raise MyobError(f"Could not reach MYOB: {exc}") from exc # 200/201 both indicate success depending on API version; MYOB returns the # new record's URI in the Location header. if resp.status_code not in (200, 201): detail = _error_detail(resp) raise MyobError(f"MYOB rejected the customer (HTTP {resp.status_code}): {detail}") location = resp.headers.get("Location", "") uid = location.rstrip("/").rsplit("/", 1)[-1] if location else "" logger.info("MYOB: created customer uid=%s for %s", uid or "?", email) return {"uid": uid, "uri": location} def _error_detail(resp: requests.Response) -> str: """Extract a human-readable error message from a MYOB error response.""" try: data = resp.json() except ValueError: return (resp.text or "").strip()[:300] or "no detail provided" errors = data.get("Errors") if isinstance(data, dict) else None if isinstance(errors, list) and errors: messages = [str(e.get("Message", "")).strip() for e in errors if isinstance(e, dict)] joined = "; ".join(m for m in messages if m) if joined: return joined return str(data)[:300]