Files
adminandClaude Opus 4.8 0e88e7bdac 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>
2026-06-17 21:51:29 +12:00

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]