213 lines
6.8 KiB
Python
213 lines
6.8 KiB
Python
"""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]
|