from __future__ import annotations import hashlib import json import os import uuid from collections.abc import Mapping from dataclasses import asdict from typing import Any from .netsuite_restlet import NetSuiteRestletClient, OAuth1TbaCredentials, OAuth1TbaSigner from .netsuite_revenue_admission import AccountLookup, admit_candidates from .netsuite_revenue_candidate import ( CandidateManifest, CandidateRow, CandidateStore, GcsCandidateStore, LocalCandidateStore, candidate_rows_from_dicts, freeze_restlet_candidate, freeze_workbook_fixture, ) from .netsuite_revenue_config import RevenueSyncConfig from .netsuite_revenue_nexuscrm import ( CRMAPIAccountLookup, NexusCRMRevenueClient, build_write_plan, describe_sha256, read_existing_records, validate_target_contract, verify_active_target_parity, write_records, ) from .nexuscrm_client import NexusCRMClient class RevenueSyncGateError(RuntimeError): pass class StaticAccountLookup(AccountLookup): """Local fixture matcher only; Cloud Run never constructs this adapter.""" def __init__(self, matches: Mapping[str, list[str]]) -> None: self.matches = {str(key): [str(value) for value in values] for key, values in matches.items()} def find_active_accounts(self, account_ids: list[str]) -> Mapping[str, list[str]]: return {account_id: list(self.matches.get(account_id, [])) for account_id in account_ids} def build_store(config: RevenueSyncConfig, *, run_id: str, replay_candidate_dir: str | None = None) -> CandidateStore: if replay_candidate_dir: return LocalCandidateStore.from_run_directory(replay_candidate_dir) gcs_uri = os.environ.get(str(config.candidate["gcs_uri_env"]), "").strip() if gcs_uri: return GcsCandidateStore(gcs_uri, run_id=run_id) local_root = config.path.parent.parent.parent / str(config.candidate["local_directory"]) return LocalCandidateStore(local_root, run_id=run_id) def freeze_source_candidate( config: RevenueSyncConfig, *, run_id: str, posting_period_id: str, store: CandidateStore, fixture_workbook: str | None = None, live_source: bool = False, replay_candidate_dir: str | None = None, ) -> tuple[list[CandidateRow], CandidateManifest]: if replay_candidate_dir: manifest = _manifest_from_dict(store.read_json("source-manifest.json")) rows = candidate_rows_from_dicts(store.read_jsonl("source-candidate.jsonl")) if manifest.run_id != run_id: raise RevenueSyncGateError("--run-id must match the frozen candidate manifest run_id") if manifest.posting_period_id != posting_period_id: raise RevenueSyncGateError("posting period does not match the frozen candidate manifest") return rows, manifest if fixture_workbook: return freeze_workbook_fixture( config, workbook=fixture_workbook, store=store, run_id=run_id, posting_period_id=posting_period_id, ) if not live_source: raise RevenueSyncGateError( "choose --fixture-workbook, --replay-candidate-dir, or explicitly authorize --live-source" ) return _freeze_live_source(config, run_id=run_id, posting_period_id=posting_period_id, store=store) def run_revenue_sync( config: RevenueSyncConfig, *, run_id: str | None, posting_period_id: str, commit: bool, validate_only: bool, expected_project: str | None, expected_environment: str | None, expected_target_base_url: str | None, expected_api_version: str | None, expected_describe_sha256: str | None, expected_manifest_sha256: str | None, candidate_run_id: str | None, fixture_workbook: str | None = None, live_source: bool = False, replay_candidate_dir: str | None = None, fixture_account_matches: Mapping[str, list[str]] | None = None, crm_client: NexusCRMRevenueClient | None = None, ) -> dict[str, Any]: if commit and validate_only: raise RevenueSyncGateError("--commit and --validate-only are mutually exclusive") current_run_id = run_id or str(uuid.uuid4()) store = build_store(config, run_id=current_run_id, replay_candidate_dir=replay_candidate_dir) rows, manifest = freeze_source_candidate( config, run_id=current_run_id, posting_period_id=posting_period_id, store=store, fixture_workbook=fixture_workbook, live_source=live_source, replay_candidate_dir=replay_candidate_dir, ) manifest_sha = _manifest_sha256(manifest) summary: dict[str, Any] = { "job_name": config.name, "run_id": current_run_id, "mode": "commit" if commit else "validate_only" if validate_only else "dry_run", "candidate_prefix": store.prefix, "source_total": manifest.source_total, "source_hash": manifest.source_hash, "manifest_sha256": manifest_sha, "page_count": manifest.page_count, "fixture": manifest.fixture, "writer": "nexuscrm_composite_records_api", } if not commit and not validate_only and fixture_account_matches is None: summary["status"] = "candidate_frozen" summary["admission"] = "not_run_no_crm_connection_or_local_matcher" return summary if fixture_account_matches is not None: admitted, admission = admit_candidates( rows, account_lookup=StaticAccountLookup(fixture_account_matches), store=store, artifact_prefix=f"admission-attempt={uuid.uuid4()}", ) summary["admission"] = asdict(admission) summary["status"] = "admission_validated" if commit or validate_only: raise RevenueSyncGateError("fixture Account matches can only validate local admission") return summary if manifest.fixture: raise RevenueSyncGateError( "a workbook fixture has no RESTlet raw/display Account evidence and can never reach CRM admission" ) _assert_identity_gates( config, expected_project=expected_project, expected_environment=expected_environment, expected_target_base_url=expected_target_base_url, expected_api_version=expected_api_version, ) if commit: _assert_commit_gates( manifest=manifest, manifest_sha256=manifest_sha, expected_manifest_sha256=expected_manifest_sha256, candidate_run_id=candidate_run_id, expected_describe_sha256=expected_describe_sha256, ) owned_client = crm_client is None client = crm_client or _build_crm_client(config) try: _assert_runtime_base_url(config, expected_target_base_url or "") describe = client.describe_object(config.target["object"]) actual_describe_sha = describe_sha256(describe) if expected_describe_sha256 and expected_describe_sha256 != actual_describe_sha: raise RevenueSyncGateError("CRM Describe SHA-256 drift") validate_target_contract(config, describe) admitted, admission = admit_candidates( rows, account_lookup=CRMAPIAccountLookup(client), store=store, artifact_prefix=f"admission-attempt={uuid.uuid4()}", ) summary["describe_sha256"] = actual_describe_sha summary["admission"] = asdict(admission) if validate_only: summary["status"] = "target_and_admission_validated" return summary existing = read_existing_records(config, client, (row.candidate.source_key for row in admitted)) plan = build_write_plan( config, admitted, existing_by_source_key=existing, run_id=current_run_id, posting_period_id=posting_period_id, ) write_result = write_records( config, client, store, plan, attempt_prefix=f"records-api-attempt={uuid.uuid4()}", ) readback = read_existing_records(config, client, (row.candidate.source_key for row in admitted)) summary["write_plan"] = { "created_or_changed": plan.created_or_changed, "last_seen_only": plan.last_seen_only, "api_batch_size": config.execution["crm_upsert_batch_size"], } summary["records_api"] = asdict(write_result) summary["reconciliation"] = verify_active_target_parity( config, admitted=admitted, existing_by_source_key=readback ) summary["status"] = "committed" return summary finally: if owned_client and isinstance(client, NexusCRMClient): client.close() def _assert_identity_gates( config: RevenueSyncConfig, *, expected_project: str | None, expected_environment: str | None, expected_target_base_url: str | None, expected_api_version: str | None, ) -> None: expected = { "project": (expected_project, str(config.target["project"])), "environment": (expected_environment, str(config.target["environment"])), "target base URL": (expected_target_base_url, expected_target_base_url), "API version": (expected_api_version, str(config.target["api_version"])), } for label, (provided, required) in expected.items(): if not provided or not required or provided.rstrip("/") != required.rstrip("/"): raise RevenueSyncGateError(f"identity gate failed for expected {label}") def _assert_runtime_base_url(config: RevenueSyncConfig, expected_target_base_url: str) -> None: actual = _require_env(str(config.target["base_url_env"])).rstrip("/") if actual != expected_target_base_url.rstrip("/"): raise RevenueSyncGateError("runtime CRM base URL differs from explicit expected identity") def _assert_commit_gates( *, manifest: CandidateManifest, manifest_sha256: str, expected_manifest_sha256: str | None, candidate_run_id: str | None, expected_describe_sha256: str | None, ) -> None: expected = { "manifest_sha256": (expected_manifest_sha256, manifest_sha256), "candidate_run_id": (candidate_run_id, manifest.run_id), "describe_sha256": (expected_describe_sha256, expected_describe_sha256), } for label, (provided, required) in expected.items(): if not provided or not required or provided != required: raise RevenueSyncGateError(f"commit gate failed for expected {label}") def _build_crm_client(config: RevenueSyncConfig) -> NexusCRMClient: target = config.target return NexusCRMClient( base_url=_require_env(str(target["base_url_env"])), client_id=_require_env(str(target["client_id_env"])), client_secret=_require_env(str(target["client_secret_env"])), api_version=str(target["api_version"]), max_attempts=int(config.execution["max_http_attempts"]), ) def _freeze_live_source( config: RevenueSyncConfig, *, run_id: str, posting_period_id: str, store: CandidateStore ) -> tuple[list[CandidateRow], CandidateManifest]: source = config.source credentials = OAuth1TbaCredentials( realm=str(source["account_realm"]), consumer_key=_require_env(str(source["consumer_key_env"])), consumer_secret=_require_env(str(source["consumer_secret_env"])), token_key=_require_env(str(source["token_key_env"])), token_secret=_require_env(str(source["token_secret_env"])), ) with NetSuiteRestletClient( url=_require_env(str(source["restlet_url_env"])), signer=OAuth1TbaSigner(credentials), max_attempts=int(config.execution["max_http_attempts"]), ) as restlet: return freeze_restlet_candidate( config, client=restlet, store=store, run_id=run_id, posting_period_id=posting_period_id, ) def _manifest_sha256(manifest: CandidateManifest) -> str: return hashlib.sha256( json.dumps(asdict(manifest), sort_keys=True, separators=(",", ":"), default=str).encode() ).hexdigest() def _manifest_from_dict(value: Mapping[str, Any]) -> CandidateManifest: return CandidateManifest( run_id=str(value["run_id"]), posting_period_id=str(value["posting_period_id"]), source_total=int(value["source_total"]), source_hash=str(value["source_hash"]), page_count=int(value["page_count"]), headers=tuple(value["headers"]), control_totals={str(key): str(item) for key, item in dict(value["control_totals"]).items()}, fixture=bool(value["fixture"]), created_at=str(value["created_at"]), object_prefix=str(value["object_prefix"]), ) def _require_env(name: str) -> str: value = os.environ.get(name, "") if not value: raise RevenueSyncGateError(f"required environment variable is not set: {name}") return value