v4.0.5
Component architecture - Reorganise src/lib/components into pages/, sections/, ui/ subdirectories and update all imports across routes and tests. Owner admin dashboard (+2k lines) - Scheduled welcome-pack emails: durable queue with cancel/reschedule and a background sender loop (SCHEDULED_CHECK_INTERVAL_SECONDS, default 60s). - Custom welcome-pack subject line, preview recipients, and client BCC. - Add-client, edit client profile, and reset-onboarding flows. - "View as client" onboarding preview (owner impersonation, dry-run submit). - Tabbed owner welcome route (/owner/welcome/[[tab]]). MYOB integration - New mail_api/myob.py: create new clients as MYOB AccountRight customer contacts. Disabled until all MYOB_* env vars are set (no-op otherwise). Onboarding - New "Does your dog resource guard?" Yes/No question in the Behaviour step. - Persist vetAddress, flea/tick, and pet-insurance fields server-side. Misc - vite config, new hooks.ts, responsive CSS tweaks, deploy/docker config, mail-api README and start-dev.ps1. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,212 @@
|
||||
"""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]
|
||||
Reference in New Issue
Block a user