mirror of
https://github.com/Sea-Haven-Industries/stampli-bulk-editor.git
synced 2026-09-30 04:33:11 +00:00
Mac GUI app (tkinter + Playwright) for bulk-editing invoice pay dates on Stampli's Select to Pay dashboard. Features: - Scan MUI DataGrid rows via virtual scroll - Auto-calculate pay dates (due - 5 days, business day adjusted) - Bulk edit pay date cells via calendar input - Filter to error-free invoices, select all, submit for payment - Scan Pending Approval tab to verify edits - PyInstaller .app bundle with custom icon
516 lines
17 KiB
Python
516 lines
17 KiB
Python
"""Shared logic for Stampli bulk editor — used by both CLI scripts and the GUI."""
|
|
|
|
import json
|
|
import time
|
|
from datetime import datetime, timedelta
|
|
from pathlib import Path
|
|
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.")
|