stampli-bulk-editor/stampli_core.py
Adam Moussa 53aa0b7525 Initial commit — Stampli Bulk Editor v1.0.0
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
2026-04-22 15:07:17 -04:00

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.")