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
351 lines
16 KiB
Python
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())
|