"""Shared logic for Stampli bulk editor — used by both CLI scripts and the GUI.""" import json import os import time from datetime import datetime, timedelta from pathlib import Path # Use system-wide Playwright browser cache so the bundled app doesn't # look for browsers inside its own .app bundle. _pw_cache = Path.home() / "Library" / "Caches" / "ms-playwright" if _pw_cache.is_dir(): os.environ.setdefault("PLAYWRIGHT_BROWSERS_PATH", str(_pw_cache)) from playwright.sync_api import sync_playwright PROJECT_DIR = Path(__file__).parent _APP_SUPPORT = Path.home() / "Library" / "Application Support" / "Stampli Bulk Editor" _APP_SUPPORT.mkdir(parents=True, exist_ok=True) CONFIG_PATH = _APP_SUPPORT / "config.json" BROWSER_DATA_DIR = _APP_SUPPORT / "browser_data" READY_TO_PAY_URL = "https://app.stampli.com/v265n2/dashboard.html#t=ready_to_pay" PENDING_APPROVAL_URL = "https://app.stampli.com/v265n2/dashboard.html#t=payments_to_approve" DATE_FMT = "%m/%d/%Y" DAYS_BEFORE_DUE = 5 ROW_SELECTOR = ".MuiDataGrid-virtualScrollerRenderZone div.MuiDataGrid-row" SCROLLER_SELECTOR = ".MuiDataGrid-virtualScroller" EDIT_COLUMNS = { "due": "div[data-field='dueDate']", "pay": "div[data-field='invoiceRequestedDate']", "vendor": "div[data-field='vendorName']", "invoice": "div[data-field='invoiceNumber']", } SCAN_COLUMNS = ["invoicesNumbers", "vendorName", "dueDate", "sendPaymentOn", "paymentMethod", "amountDue", "amount"] DEFAULT_CONFIG = { "stampli_url": READY_TO_PAY_URL, "date_format": DATE_FMT, "days_before_due": DAYS_BEFORE_DUE, } # --------------------------------------------------------------------------- # Config # --------------------------------------------------------------------------- def load_config(): if CONFIG_PATH.exists(): with open(CONFIG_PATH) as f: saved = json.load(f) return {**DEFAULT_CONFIG, **saved} return dict(DEFAULT_CONFIG) # --------------------------------------------------------------------------- # Date helpers # --------------------------------------------------------------------------- def next_business_day(d: datetime) -> datetime: weekday = d.weekday() if weekday == 5: d += timedelta(days=2) elif weekday == 6: d += timedelta(days=1) return d def calc_pay_date(due_date: datetime, days_before: int = DAYS_BEFORE_DUE) -> datetime: today = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0) if (due_date - today).days <= 5: return next_business_day(today + timedelta(days=2)) return next_business_day(due_date - timedelta(days=days_before)) # --------------------------------------------------------------------------- # Browser helpers # --------------------------------------------------------------------------- def launch_browser(pw): return pw.chromium.launch_persistent_context( user_data_dir=str(BROWSER_DATA_DIR), headless=False, viewport={"width": 1400, "height": 900}, ) def active_page(browser): pages = browser.pages return pages[-1] if pages else browser.new_page() def navigate(browser, url): page = browser.pages[0] if browser.pages else browser.new_page() page.goto(url, wait_until="domcontentloaded") return page def wait_for_login(browser, timeout_minutes=5, on_status=None): if on_status: on_status("Waiting for login...") deadline = time.time() + timeout_minutes * 60 while time.time() < deadline: for pg in browser.pages: url = pg.url.lower() if "login" not in url and "auth" not in url and "signin" not in url: if "stampli.com" in url: if on_status: on_status("Login detected.") return True time.sleep(2) if on_status: on_status("Login timed out.") return False def needs_login(page): url = page.url.lower() return "login" in url or "auth" in url or "signin" in url # --------------------------------------------------------------------------- # Virtual-scroll grid scanning # --------------------------------------------------------------------------- def collect_edit_rows(page, on_progress=None): """Scan the Select to Pay grid. Returns list of dicts with row_id, invoice, vendor, due_text, pay_text.""" scroller = page.query_selector(SCROLLER_SELECTOR) if not scroller: return [] seen_ids = set() all_rows = [] scroll_top = 0 stale_count = 0 while True: scroller.evaluate("(el, top) => el.scrollTop = top", scroll_top) time.sleep(0.5) rows = page.query_selector_all(ROW_SELECTOR) new_this_scroll = 0 for row in rows: row_id = row.get_attribute("data-id") if not row_id or row_id in seen_ids: continue seen_ids.add(row_id) new_this_scroll += 1 due_cell = row.query_selector(EDIT_COLUMNS["due"]) pay_cell = row.query_selector(EDIT_COLUMNS["pay"]) vendor_cell = row.query_selector(EDIT_COLUMNS["vendor"]) inv_cell = row.query_selector(EDIT_COLUMNS["invoice"]) all_rows.append({ "row_id": row_id, "invoice": inv_cell.inner_text().strip() if inv_cell else "", "vendor": (vendor_cell.inner_text().strip()[:40] if vendor_cell else ""), "due_text": (due_cell.inner_text().strip().split("\n")[0] if due_cell else ""), "pay_text": (pay_cell.inner_text().strip().split("\n")[0] if pay_cell else ""), }) if new_this_scroll == 0: stale_count += 1 if stale_count >= 3: break else: stale_count = 0 if on_progress: on_progress(len(all_rows)) scroll_top += 200 return all_rows def collect_scan_rows(page, on_progress=None): """Scan the Pending Approval grid. Returns list of dicts keyed by SCAN_COLUMNS.""" scroller = page.query_selector(SCROLLER_SELECTOR) if not scroller: return [] seen_ids = set() all_rows = [] scroll_top = 0 stale_count = 0 while True: scroller.evaluate("(el, top) => el.scrollTop = top", scroll_top) time.sleep(0.5) rows = page.query_selector_all(ROW_SELECTOR) new_this_scroll = 0 for row in rows: row_id = row.get_attribute("data-id") if not row_id or row_id in seen_ids: continue seen_ids.add(row_id) new_this_scroll += 1 fields = {} for col in SCAN_COLUMNS: cell = row.query_selector(f"div[data-field='{col}']") fields[col] = cell.inner_text().strip().split("\n")[0] if cell else "" all_rows.append(fields) if new_this_scroll == 0: stale_count += 1 if stale_count >= 3: break else: stale_count = 0 if on_progress: on_progress(len(all_rows)) scroll_top += 200 return all_rows # --------------------------------------------------------------------------- # Change computation # --------------------------------------------------------------------------- def compute_changes(all_rows, date_fmt=DATE_FMT, days_before=DAYS_BEFORE_DUE): """Pure function: given collected rows, return list of rows that need updating.""" changes = [] for row_data in all_rows: due_text = row_data["due_text"] pay_text = row_data["pay_text"] if not due_text: continue try: due_date = datetime.strptime(due_text, date_fmt) except ValueError: continue new_pay_date = calc_pay_date(due_date, days_before) new_pay_str = new_pay_date.strftime(date_fmt) if pay_text == new_pay_str: continue changes.append({**row_data, "new_pay": new_pay_str}) return changes def compute_scan_results(all_rows, date_fmt=DATE_FMT, days_before=DAYS_BEFORE_DUE): """Pure function: compare actual vs expected pay dates. Returns (results, incorrect).""" results = [] incorrect = [] for r in all_rows: due_text = r["dueDate"] pay_text = r["sendPaymentOn"] try: due_dt = datetime.strptime(due_text, date_fmt) expected = calc_pay_date(due_dt, days_before) expected_str = expected.strftime(date_fmt) except ValueError: expected_str = "???" ok = pay_text == expected_str entry = {**r, "expected": expected_str, "status": "OK" if ok else "WRONG"} results.append(entry) if not ok: incorrect.append(entry) return results, incorrect # --------------------------------------------------------------------------- # Editing # --------------------------------------------------------------------------- def find_row(page, scroller, row_id, start_scroll=0): row = page.query_selector( f".MuiDataGrid-virtualScrollerRenderZone div.MuiDataGrid-row[data-id='{row_id}']" ) if row: return row, start_scroll scroll_top = 0 max_scroll = scroller.evaluate("el => el.scrollHeight") while scroll_top <= max_scroll: scroller.evaluate("(el, top) => el.scrollTop = top", scroll_top) time.sleep(0.3) row = page.query_selector( f".MuiDataGrid-virtualScrollerRenderZone div.MuiDataGrid-row[data-id='{row_id}']" ) if row: return row, scroll_top scroll_top += 200 return None, scroll_top def try_edit_cell(page, row, new_date_str): pay_cell = row.query_selector(EDIT_COLUMNS["pay"]) if not pay_cell: return False calendar_icon = pay_cell.query_selector("i.fa-calendar, [data-test-id='test-icon-calendar']") if calendar_icon: calendar_icon.click() else: pay_cell.dblclick() time.sleep(0.5) date_input = page.query_selector( ".MuiDataGrid-cell--editing input, " ".react-datepicker input, " "input[type='date'], " "input.date-input, " ".datepicker input, " "input[placeholder*='date' i], " "input[placeholder*='MM' i]" ) if date_input: date_input.fill("") date_input.fill(new_date_str) time.sleep(0.3) date_input.press("Enter") else: page.keyboard.type(new_date_str) page.keyboard.press("Enter") time.sleep(0.5) # Dismiss any date picker popup that may still be open page.keyboard.press("Escape") time.sleep(0.3) # Click away from the cell to deselect it page.mouse.click(0, 0) time.sleep(0.3) return True def edit_single_row(page, row_id, new_date_str, last_scroll=0, max_attempts=3): scroller = page.query_selector(SCROLLER_SELECTOR) for attempt in range(max_attempts): row, scroll_pos = find_row(page, scroller, row_id, start_scroll=last_scroll) if not row: if attempt < max_attempts - 1: time.sleep(0.5) continue return False, scroll_pos if try_edit_cell(page, row, new_date_str): return True, scroll_pos page.keyboard.press("Escape") time.sleep(0.3) return False, scroll_pos def apply_edits(page, changes, on_progress=None): """Edit rows and retry failures. Returns (success_count, failed_list).""" success = 0 failed = [] last_scroll = 0 for i, c in enumerate(changes): try: ok, last_scroll = edit_single_row(page, c["row_id"], c["new_pay"], last_scroll) if ok: success += 1 if on_progress: on_progress(i + 1, len(changes), c["invoice"], True) else: failed.append(c) if on_progress: on_progress(i + 1, len(changes), c["invoice"], False) except Exception as e: failed.append(c) if on_progress: on_progress(i + 1, len(changes), c["invoice"], False, str(e)) if failed: retry_failed = [] for c in failed: try: ok, _ = edit_single_row(page, c["row_id"], c["new_pay"], last_scroll=0) if ok: success += 1 if on_progress: on_progress(-1, len(failed), c["invoice"], True, "retry") else: retry_failed.append(c) if on_progress: on_progress(-1, len(failed), c["invoice"], False, "retry failed") except Exception as e: retry_failed.append(c) if on_progress: on_progress(-1, len(failed), c["invoice"], False, str(e)) failed = retry_failed return success, failed # --------------------------------------------------------------------------- # Filter / Select / Pay # --------------------------------------------------------------------------- def filter_select_and_pay(page, on_status=None, on_manual_fallback=None): """Apply 'No errors' filter, select all, click Pay Invoices, click Google auth.""" def status(msg): if on_status: on_status(msg) status("Opening filter drawer...") filter_toggle = page.query_selector("button[data-test-id='selectToPay-filter-toggle']") if not filter_toggle: status("ERROR: Could not find filter toggle button.") return filter_toggle.click() time.sleep(1) applied = False try: status_input = page.query_selector( "div[data-test-id='selectToPay-filter-filterStatuses'] input[placeholder='Status']" ) if not status_input: view_all = page.query_selector( "div[data-test-id='selectToPay-filter-menu'] >> text=View all" ) if view_all: view_all.click() time.sleep(0.5) status_input = page.query_selector( "div[data-test-id='selectToPay-filter-filterStatuses'] input[placeholder='Status']" ) if status_input: status_input.evaluate("el => el.scrollIntoView({block: 'center', behavior: 'instant'})") time.sleep(0.3) status_input.click(force=True) time.sleep(0.8) no_errors = page.query_selector(".MuiAutocomplete-listbox >> text=No errors") if not no_errors: no_errors = page.query_selector(".MuiAutocomplete-listbox >> text=No Errors") if not no_errors: options = page.query_selector_all(".MuiAutocomplete-option") for opt in options: if "no error" in opt.inner_text().strip().lower(): no_errors = opt break if no_errors: no_errors.click() time.sleep(0.5) applied = True status("Applied 'No errors' filter.") except Exception: pass if not applied: status("Could not auto-apply filter.") if on_manual_fallback: on_manual_fallback("Please apply Status -> 'No errors' manually in the browser, then click OK.") else: input(" Apply the filter manually, then press Enter: ") close_btn = page.query_selector( "div[data-test-id='selectToPay-filter-menu-header'] button:last-child" ) if close_btn: close_btn.click() time.sleep(0.5) time.sleep(2) rows = page.query_selector_all(ROW_SELECTOR) status(f"{len(rows)} rows visible after filtering.") status("Selecting all rows...") select_all = page.query_selector("input[aria-label='Select all rows']") if not select_all: select_all = page.query_selector(".MuiDataGrid-columnHeaderCheckbox .MuiCheckbox-root") if select_all: select_all.click() time.sleep(1) status("Selected all rows.") else: status("ERROR: Could not find Select All checkbox.") return status("Clicking Pay Invoices...") pay_button = page.query_selector("button[data-test-id='selectToPay-action-payInvoices']") if not pay_button: status("ERROR: Could not find Pay Invoices button.") return is_disabled = pay_button.get_attribute("disabled") if is_disabled is not None: status("Pay Invoices button disabled — no rows selected.") if on_manual_fallback: on_manual_fallback("Please select rows manually, then click OK.") else: input(" Select rows manually, then press Enter: ") pay_button = page.query_selector("button[data-test-id='selectToPay-action-payInvoices']") pay_button.click() time.sleep(2) status("Clicked Pay Invoices.") status("Looking for re-authentication modal...") google_btn = page.query_selector("button >> text=Google") if not google_btn: google_btn = page.query_selector("text=Google") if google_btn: google_btn.click() status("Clicked Google login.") time.sleep(3) else: status("No re-auth modal found.")