WFDASH-WEBAPP/.e2e-tmp/validate_invoicing.py
SJCoetz 18334721cb feat(invoicing): add QuickBooks invoicing flow and print functionality
Add complete end-to-end QuickBooks invoicing workflow for the invoicing page:
- Add `jspdf` dependency for client-side PDF generation
- Implement updated item status handling with fallback for new invoice statuses
- Add server-side proxy API for secure QuickBooks customer lookup
- Build utilities to combine QuickBooks invoices and picking slips into a single PDF
- Add invoicing view models for row grouping, payload construction, and status checks
- Add full test coverage for all new components, APIs, and utilities
- Include feature design documentation and sample n8n workflow config
2026-07-09 09:44:55 +02:00

351 lines
16 KiB
Python

"""Browser validation for the invoicing QuickBooks customer modal flow."""
from __future__ import annotations
import json
import os
import sys
import time
from pathlib import Path
from playwright.sync_api import sync_playwright
BASE = "http://localhost:4000"
SCREENSHOT_DIR = Path("/tmp/invoicing_validation")
SCREENSHOT_DIR.mkdir(parents=True, exist_ok=True)
# Recorded network traffic
requests_log: list[dict] = []
def main() -> int:
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
context = browser.new_context(viewport={"width": 1440, "height": 900})
page = context.new_page()
def on_request(req):
url = req.url
if (
"/api/invoicing/customers" in url
or "wfdash" in url.lower()
or "quickbooks" in url.lower()
or "qb" in url.lower()
or "/api/" in url
and "pocketbase" not in url.lower()
):
try:
body = req.post_data
except Exception:
body = None
requests_log.append({
"method": req.method,
"url": url,
"headers": dict(req.headers),
"body": body,
})
print(f"[REQ] {req.method} {url}")
if body:
print(f" body: {body[:500]}")
def on_response(res):
url = res.url
if (
"/api/invoicing/customers" in url
or "wfdash" in url.lower()
or "quickbooks" in url.lower()
or "qb" in url.lower()
):
print(f"[RES] {res.status} {url}")
for r in requests_log:
if r["url"] == url and "response_status" not in r:
try:
txt = res.text()
except Exception:
txt = ""
r["response_status"] = res.status
r["response_body"] = txt[:1500]
break
page.on("request", on_request)
page.on("response", on_response)
# 0. Register a user so we can pass the auth gate
print("\n=== STEP 0: Register a dev user ===")
page.goto(f"{BASE}/login", wait_until="domcontentloaded")
page.wait_for_timeout(1000)
# Click the Register tab
register_tab = page.get_by_role("tab", name="Register")
if register_tab.count() > 0:
register_tab.click()
page.wait_for_timeout(500)
# Fill register form
page.fill("#reg-name", "Test Operator")
page.fill("#reg-email", "test-operator@example.com")
page.fill("#reg-password", "testpass1234")
page.fill("#reg-confirm", "testpass1234")
page.get_by_role("button", name="Create Account").click()
page.wait_for_timeout(3000)
page.screenshot(path=str(SCREENSHOT_DIR / "00_register.png"), full_page=True)
print(f" current URL: {page.url}")
# 1. Open the invoicing page
print("\n=== STEP 1: Load invoicing page ===")
requests_log.clear()
page.goto(f"{BASE}/invoicing", wait_until="domcontentloaded")
page.wait_for_timeout(3000)
# If we got redirected to login, sign in
if "/login" in page.url:
print(" redirected to login — signing in")
page.fill("#login-email", "test-operator@example.com")
page.fill("#login-password", "testpass1234")
page.get_by_role("button", name="Sign In").click()
page.wait_for_timeout(3000)
page.goto(f"{BASE}/invoicing", wait_until="domcontentloaded")
page.wait_for_timeout(3000)
page.screenshot(path=str(SCREENSHOT_DIR / "01_loaded.png"), full_page=True)
print(f" current URL: {page.url}")
# Check for QB-ready groups
invoice_buttons = page.get_by_role("button", name="Invoice in QuickBooks").all()
print(f" 'Invoice in QuickBooks' single buttons found: {len(invoice_buttons)}")
bulk_button = page.get_by_role("button", name="Invoice in QuickBooks (bulk)")
bulk_visible = bulk_button.count() > 0 and bulk_button.is_visible()
bulk_enabled = bulk_visible and bulk_button.is_enabled()
print(f" bulk button visible: {bulk_visible}, enabled: {bulk_enabled}")
# If toggle is "Ready only" and nothing there, try "All"
if len(invoice_buttons) == 0 and bulk_visible and not bulk_enabled:
all_toggle = page.get_by_role("radio", name="All")
if all_toggle.count() > 0:
print(" no Ready groups; switching to 'All'")
all_toggle.click()
page.wait_for_timeout(1500)
page.screenshot(path=str(SCREENSHOT_DIR / "01b_all_toggle.png"), full_page=True)
invoice_buttons = page.get_by_role("button", name="Invoice in QuickBooks").all()
bulk_button = page.get_by_role("button", name="Invoice in QuickBooks (bulk)")
bulk_visible = bulk_button.count() > 0 and bulk_button.is_visible()
bulk_enabled = bulk_visible and bulk_button.is_enabled()
print(f" After 'All': single buttons={len(invoice_buttons)}, bulk visible={bulk_visible} enabled={bulk_enabled}")
if len(invoice_buttons) == 0:
print("\n!!! No QB-ready groups found in any toggle. Exiting.")
browser.close()
return 0
# 2. Open single-order modal
print("\n=== STEP 2: Open single-order modal ===")
requests_log.clear()
invoice_buttons[0].click()
page.wait_for_timeout(4000)
page.screenshot(path=str(SCREENSHOT_DIR / "02_single_modal.png"), full_page=True)
# Inspect the customers POST request
single_customers_req = None
for r in requests_log:
if "/api/invoicing/customers" in r["url"] and r["method"] == "POST":
single_customers_req = r
break
if single_customers_req:
print(f"\n[CHECK 1a] POST /api/invoicing/customers fired")
print(f" status: {single_customers_req.get('response_status')}")
try:
body = json.loads(single_customers_req.get("body") or "{}")
cq = body.get("customer_query", "")
bc = body.get("billing_company", "")
print(f" customer_query: {cq!r} (non-empty: {bool(cq.strip())})")
print(f" billing_company: {bc!r} (non-empty: {bool(str(bc).strip())})")
except Exception as e:
print(f" body parse error: {e}")
resp_body = single_customers_req.get("response_body", "")
print(f" response body (first 400): {resp_body[:400]}")
else:
print("\n[FAIL] No POST /api/invoicing/customers recorded for single modal")
# Inspect the select element
modal_selects = page.locator('[role="dialog"] select').all()
print(f"\n modal select count: {len(modal_selects)}")
single_preselect = None
if modal_selects:
try:
opts = modal_selects[0].locator("option").all_text_contents()
val = modal_selects[0].input_value()
single_preselect = val
print(f" select.value={val!r}")
print(f" select.options={opts[:15]}{'...' if len(opts) > 15 else ''}")
if len(opts) > 1:
print(f" [CHECK 3] list rendered ({len(opts)} entries), pre-selected: {val!r}")
else:
print(f" [CHECK 3] WARN: only {len(opts)} options in list")
except Exception as e:
print(f" select error: {e}")
# 3. Operator override
print("\n=== STEP 3: Operator override pre-selection ===")
if modal_selects and len(modal_selects[0].locator("option").all()) > 2:
opts = modal_selects[0].locator("option").all()
current = modal_selects[0].input_value()
new_value = None
for o in opts:
v = o.get_attribute("value") or ""
if v and v != current and v != "":
new_value = v
break
if new_value:
modal_selects[0].select_option(new_value)
page.wait_for_timeout(500)
after = modal_selects[0].input_value()
print(f" [CHECK 4] pre-select was {current!r}, after override is {after!r}, changed: {after != current}")
page.screenshot(path=str(SCREENSHOT_DIR / "03_override.png"), full_page=True)
else:
print(" [SKIP] No alternate value to pick for override")
else:
print(" [SKIP] No select with options to test override")
# 4. Submit single — capture QB webhook POST
print("\n=== STEP 4: Submit single send to QB webhook ===")
# Reset selection to pre-selected closest match
if modal_selects and single_preselect is not None:
modal_selects[0].select_option(single_preselect)
page.wait_for_timeout(300)
requests_log.clear()
send_btn = page.locator('[role="dialog"]').get_by_role("button", name="Send to QuickBooks")
if send_btn.count() > 0 and send_btn.is_enabled():
send_btn.click()
page.wait_for_timeout(5000)
page.screenshot(path=str(SCREENSHOT_DIR / "04_single_submit.png"), full_page=True)
else:
print(" [WARN] Send button not enabled")
# Find the QB webhook POST
qb_webhook_reqs = [
r for r in requests_log
if r["method"] == "POST" and "/api/invoicing/customers" not in r["url"]
]
print(f"\n[CHECK 5/6] QB webhook POSTs detected (single): {len(qb_webhook_reqs)}")
for r in qb_webhook_reqs:
print(f" {r['method']} {r['url']}")
body_preview = (r.get("body") or "")[:500]
print(f" body: {body_preview}")
print(f" response_status: {r.get('response_status')}")
resp = r.get("response_body", "")
print(f" response_body: {resp[:400]}")
# 5. Close single, open bulk
print("\n=== STEP 5: Open bulk modal ===")
# Wait for modal to potentially close automatically
page.wait_for_timeout(1500)
# If a modal is still open, close it
still_open_modal = page.locator('[role="dialog"]')
if still_open_modal.count() > 0:
cancel_btn = still_open_modal.get_by_role("button", name="Cancel")
if cancel_btn.count() > 0:
cancel_btn.click()
page.wait_for_timeout(500)
# Find bulk button again (in case DOM changed)
bulk_button = page.get_by_role("button", name="Invoice in QuickBooks (bulk)")
if bulk_button.count() == 0 or not bulk_button.is_visible():
print(" [WARN] bulk button not visible/present")
else:
requests_log.clear()
bulk_button.click()
page.wait_for_timeout(4000)
page.screenshot(path=str(SCREENSHOT_DIR / "05_bulk_modal.png"), full_page=True)
bulk_modal = page.locator('[role="dialog"]')
bulk_selects = bulk_modal.locator("select").all()
print(f" bulk select count: {len(bulk_selects)}")
bulk_customers_req = None
for r in requests_log:
if "/api/invoicing/customers" in r["url"] and r["method"] == "POST":
bulk_customers_req = r
break
if bulk_customers_req:
print(f"\n[CHECK 1b/4] Bulk POST /api/invoicing/customers fired")
print(f" status: {bulk_customers_req.get('response_status')}")
try:
body = json.loads(bulk_customers_req.get("body") or "{}")
cq = body.get("customer_query", "")
bc = body.get("billing_company", "")
print(f" customer_query: {cq!r} (non-empty: {bool(cq.strip())})")
print(f" billing_company: {bc!r} (non-empty: {bool(str(bc).strip())})")
except Exception as e:
print(f" body parse error: {e}")
resp_body = bulk_customers_req.get("response_body", "")
print(f" response body (first 400): {resp_body[:400]}")
else:
print("\n[FAIL] No POST /api/invoicing/customers recorded for bulk modal")
# Pre-select inspection
print("\n bulk modal pre-selects:")
preselected_count = 0
for i, sel in enumerate(bulk_selects):
try:
v = sel.input_value()
opts = sel.locator("option").all_text_contents()
if v:
preselected_count += 1
print(f" bulk select[{i}].value={v!r} (options={len(opts)})")
except Exception as e:
print(f" bulk select[{i}] error: {e}")
print(f" pre-selected: {preselected_count}/{len(bulk_selects)}")
# Operator override on bulk
print("\n=== STEP 6: Operator override on bulk modal ===")
if bulk_selects and len(bulk_selects[0].locator("option").all()) > 2:
opts = bulk_selects[0].locator("option").all()
current = bulk_selects[0].input_value()
new_value = None
for o in opts:
v = o.get_attribute("value") or ""
if v and v != current and v != "":
new_value = v
break
if new_value:
bulk_selects[0].select_option(new_value)
page.wait_for_timeout(500)
after = bulk_selects[0].input_value()
print(f" [CHECK 5] bulk pre-select was {current!r}, after override is {after!r}, changed: {after != current}")
page.screenshot(path=str(SCREENSHOT_DIR / "06_bulk_override.png"), full_page=True)
# Click "Send all to QuickBooks"
print("\n=== STEP 7: Submit bulk send ===")
requests_log.clear()
send_all_btn = bulk_modal.get_by_role("button", name="Send all to QuickBooks")
if send_all_btn.count() > 0 and send_all_btn.is_enabled():
send_all_btn.click()
page.wait_for_timeout(6000)
page.screenshot(path=str(SCREENSHOT_DIR / "07_bulk_submit.png"), full_page=True)
else:
print(" [WARN] Send all button not enabled")
qb_webhook_reqs = [
r for r in requests_log
if r["method"] == "POST" and "/api/invoicing/customers" not in r["url"]
]
print(f"\n[CHECK 5/6] QB webhook POSTs detected (bulk): {len(qb_webhook_reqs)}")
for r in qb_webhook_reqs:
print(f" {r['method']} {r['url']}")
body_preview = (r.get("body") or "")[:500]
print(f" body: {body_preview}")
print(f" response_status: {r.get('response_status')}")
resp = r.get("response_body", "")
print(f" response_body: {resp[:400]}")
# Final dump of the full request log
print("\n=== FULL REQUESTS LOG (relevant) ===")
for r in requests_log:
print(json.dumps({k: (v[:200] if isinstance(v, str) else v) for k, v in r.items()}, indent=2)[:2000])
print("---")
browser.close()
return 0
if __name__ == "__main__":
sys.exit(main())