"""Invoice sync: pushes approved invoices from the finance export CSV into the billing API. python sync.py data/input/invoices.csv (runs every hour from Task Scheduler) """ import csv import logging import os import sys import time from datetime import datetime from pathlib import Path import requests from dotenv import load_dotenv ROOT = Path(__file__).parent load_dotenv(ROOT / ".env") API = os.environ.get("BILLING_API_URL", "https://billing.example.test/api/v2") HEADERS = {"Authorization": f"Bearer {os.environ.get('BILLING_API_KEY', '')}"} RETRIES = 5 TIMEOUT_S = 30 REQUIRED = ["invoice_no", "customer_id", "amount", "currency"] logging.basicConfig(filename=ROOT / "logs" / "sync.log", level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") log = logging.getLogger("sync") def validate(row): missing = [f for f in REQUIRED if not row.get(f)] if missing: return f"missing {', '.join(missing)}" try: if float(row["amount"]) <= 0: return "amount must be positive" except ValueError: return f"amount '{row['amount']}' is not a number" return "" def exists(invoice_no): r = requests.get(f"{API}/invoices/{invoice_no}", headers=HEADERS, timeout=TIMEOUT_S) return r.status_code == 200 def push(row): """Returns (status, message). Retries 429 and 5xx with backoff; checks for a duplicate before each retry.""" for attempt in range(1, RETRIES + 1): try: r = requests.post(f"{API}/invoices", json=row, headers=HEADERS, timeout=TIMEOUT_S) except requests.RequestException as e: print(f"network error on {row['invoice_no']}: {e}") r = None if r is not None and r.status_code in (200, 201, 207): return "Synced", "Created" if r.status_code != 207 else "Created (some lines rejected)" if r is not None and r.status_code == 409: return "Duplicate", "Invoice already exists in billing" if r is not None and r.status_code == 422: return "Invalid", r.json().get("detail", "Rejected by billing") if exists(row["invoice_no"]): return "Synced", "Created (confirmed after retry)" time.sleep(2 ** attempt) return "Failed", f"No success after {RETRIES} tries" def main(path): (ROOT / "BOT_WAS_RUN").write_text("1") report = ROOT / "data" / "output" / f"sync_report_{datetime.now():%Y%m%d_%H%M}.csv" with open(path, newline="", encoding="utf-8") as f, open(report, "w", newline="", encoding="utf-8") as out: w = csv.writer(out) w.writerow(["invoice_no", "customer", "Status", "Message"]) for row in csv.DictReader(f): problem = validate(row) status, msg = ("Invalid", problem) if problem else push(row) log.info("%s %s", row.get("invoice_no"), status) w.writerow([row.get("invoice_no"), row.get("customer_name"), status, msg]) log.info("run done -> %s", report.name) if __name__ == "__main__": try: main(sys.argv[1]) except Exception as e: print(f"CRASH: {e!r}") raise