From 4c0decd1758aab20fbd8314ec1b8abb8bab23d13 Mon Sep 17 00:00:00 2001 From: justin Date: Tue, 9 Jun 2026 07:51:54 -0500 Subject: [PATCH] fix(formation): add working /name-search worker route + e2e harness Two latent bugs the e2e harness caught: 1. api entities.ts GET /states/:code/name-search calls WORKER_URL/name-search, but job_server had NO such route -> 404 -> silently fell back to stale entity_cache on every live name check. Added a synchronous /name-search route returning {available,exact_match,similar_names,state}. 2. both the new route AND the existing async handle_name_search imported a nonexistent search_name_sync(); fixed to drive the real async search_name() via an event loop (same pattern as /entity-status). scripts/e2e-formation-order.mjs: replays the real formation order flow (live name search -> formation_orders insert -> ERPNext customer + Sales Order with BUSINESS-FORMATION + STATE-FILING-FEE line items -> verify SO total + DB linkage -> cleanup) without a real Stripe charge or state filing. Run in the api container. Also created the missing ERPNext Items (BUSINESS-FORMATION, STATE-FILING-FEE, FOREIGN-QUAL-SINGLE/MULTI) that the formation SO references. --- scripts/e2e-formation-order.mjs | 184 ++++++++++++++++++++++++++++++++ scripts/workers/job_server.py | 62 ++++++++++- 2 files changed, 244 insertions(+), 2 deletions(-) create mode 100644 scripts/e2e-formation-order.mjs diff --git a/scripts/e2e-formation-order.mjs b/scripts/e2e-formation-order.mjs new file mode 100644 index 0000000..d0de58f --- /dev/null +++ b/scripts/e2e-formation-order.mjs @@ -0,0 +1,184 @@ +#!/usr/bin/env node +/** + * e2e-formation-order.mjs - end-to-end check of the corporate formation order flow. + * + * Verifies the real plumbing WITHOUT taking a real Stripe payment or making a real + * state filing: + * 1. Name search against the live state portal (NV + TX) via the worker. + * 2. Insert a test formation_orders row (the same columns the intake route writes). + * 3. Replay the EXACT ERPNext Sales Order creation logic from checkout.ts + * (BUSINESS-FORMATION + STATE-FILING-FEE line items) against the real ERPNext. + * 4. Verify: the SO exists, has the right items, and formation_orders.erpnext_sales_order + * was set. Then CLEAN UP (cancel+delete the test SO, delete the test order row) + * unless --keep is passed. + * + * Run inside the api container on prod (it has node + the compiled erpnext client + + * DATABASE_URL + WORKER_URL): + * docker exec performancewest-api-1 node /app/scripts/e2e-formation-order.mjs [--keep] [--no-namesearch] + * + * Exit code 0 = all checks passed; non-zero = a check failed (CI-friendly). + */ +import pg from "pg"; + +const KEEP = process.argv.includes("--keep"); +const SKIP_NAMESEARCH = process.argv.includes("--no-namesearch"); +const WORKER_URL = process.env.WORKER_URL || "http://workers:8090"; + +// Load the app's own ERPNext client (compiled). +const erp = await import("/app/dist/erpnext-client.js"); +const { createResource, getResource, callMethod, searchLink, deleteResource } = erp; + +// Mirror checkout.ts findOrCreateCustomer using the exported client primitives +// (the route-level helper is not exported, so reproduce its essentials here). +async function findOrCreateCustomer(email, name) { + try { + const existing = await searchLink("Customer", email, { searchfield: "email_id" }); + if (existing && existing.length && existing[0].value) return { customerName: existing[0].value }; + } catch { /* fall through to create */ } + const c = await createResource("Customer", { + customer_name: name || email, + customer_type: "Company", + customer_group: "All Customer Groups", + territory: "All Territories", + email_id: email, + }); + return { customerName: c.name }; +} + +const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL }); + +let failures = 0; +const ok = (m) => console.log(` [PASS] ${m}`); +const bad = (m) => { console.log(` [FAIL] ${m}`); failures++; }; +const info = (m) => console.log(m); + +const toDollars = (c) => Math.round(c) / 100; + +// Test cases: NV LLC and TX corp (the two DEXIT destinations). +const CASES = [ + { state: "NV", entity_type: "llc", name: "PW E2E Test Holdings", service: 17900, stateFee: 7500 }, + { state: "TX", entity_type: "corporation", name: "PW E2E Test Industries", service: 17900, stateFee: 30000 }, +]; + +async function nameSearch(state, name) { + if (SKIP_NAMESEARCH) { info(` (skipped name search for ${state})`); return null; } + try { + const r = await fetch(`${WORKER_URL}/name-search`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ state_code: state, name }), + signal: AbortSignal.timeout(45000), + }); + if (!r.ok) { bad(`name-search ${state} HTTP ${r.status}`); return null; } + const j = await r.json(); + ok(`name search ${state} reachable (available=${j.available ?? "?"})`); + return j; + } catch (e) { + bad(`name-search ${state} failed: ${e.message} (worker reachable from this container?)`); + return null; + } +} + +async function runCase(tc) { + info(`\n=== ${tc.state} ${tc.entity_type} formation ===`); + const year = new Date().getFullYear(); + const orderNumber = `PW-E2E-${tc.state}-${Date.now().toString().slice(-7)}`; + const email = `e2e-formation+${tc.state.toLowerCase()}@performancewest.net`; + + await nameSearch(tc.state, tc.name); + + // 1) insert the test order (mirror the intake route columns) + let orderRow; + try { + const r = await pool.query( + `INSERT INTO formation_orders ( + order_number, customer_name, customer_email, + state_code, entity_type, entity_name, + include_ra_service, include_ein, expedited, + state_fee_cents, service_fee_cents, total_cents, status + ) VALUES ($1,$2,$3,$4,$5,$6,TRUE,FALSE,FALSE,$7,$8,$9,'received') + RETURNING id, order_number`, + [orderNumber, "PW E2E Test", email, tc.state, tc.entity_type, tc.name, + tc.stateFee, tc.service, tc.service + tc.stateFee], + ); + orderRow = r.rows[0]; + ok(`inserted formation_orders row ${orderRow.order_number}`); + } catch (e) { + bad(`formation_orders insert failed: ${e.message}`); + return { orderNumber, soName: null }; + } + + // 2) ERPNext customer + let customer; + try { + const { customerName } = await findOrCreateCustomer(email, "PW E2E Test"); + customer = customerName; + ok(`ERPNext customer: ${customer}`); + } catch (e) { + bad(`findOrCreateCustomer failed: ${e.message}`); + } + + // 3) replay the EXACT SO creation from checkout.ts (formation branch) + let soName = null; + if (customer) { + try { + const so = await createResource("Sales Order", { + customer, + delivery_date: new Date(Date.now() + 30 * 86400000).toISOString().split("T")[0], + custom_external_order_id: orderNumber, + custom_order_type: "formation", + workflow_state: "Received", + items: [ + { item_code: "BUSINESS-FORMATION", description: `${tc.entity_type} Formation — ${tc.state}`, qty: 1, rate: toDollars(tc.service) }, + { item_code: "STATE-FILING-FEE", description: `${tc.state} state filing fee (government fee)`, qty: 1, rate: toDollars(tc.stateFee) }, + ], + }); + soName = so.name; + ok(`created Sales Order ${soName}`); + await pool.query(`UPDATE formation_orders SET erpnext_sales_order = $1 WHERE order_number = $2`, [soName, orderNumber]); + } catch (e) { + bad(`Sales Order creation failed: ${e.message}`); + } + } + + // 4) verify the SO + line items + DB linkage + if (soName) { + try { + const so = await getResource("Sales Order", soName); + const codes = (so.items || []).map((i) => i.item_code); + const total = Number(so.total || 0); + if (codes.includes("BUSINESS-FORMATION") && codes.includes("STATE-FILING-FEE")) ok(`SO line items correct: ${codes.join(", ")}`); + else bad(`SO line items wrong: ${codes.join(", ")}`); + const expected = toDollars(tc.service + tc.stateFee); + if (Math.abs(total - expected) < 0.01) ok(`SO total = $${total} (expected $${expected})`); + else bad(`SO total $${total} != expected $${expected}`); + + const dbCheck = await pool.query(`SELECT erpnext_sales_order FROM formation_orders WHERE order_number=$1`, [orderNumber]); + if (dbCheck.rows[0]?.erpnext_sales_order === soName) ok(`formation_orders.erpnext_sales_order linked`); + else bad(`DB linkage missing (got ${dbCheck.rows[0]?.erpnext_sales_order})`); + } catch (e) { + bad(`SO verification fetch failed: ${e.message}`); + } + } + + // 5) cleanup + if (!KEEP) { + if (soName) { + try { await callMethod("frappe.client.cancel", { doctype: "Sales Order", name: soName }); } catch {} + try { await deleteResource("Sales Order", soName); } catch {} + info(` cleaned up SO ${soName}`); + } + try { await pool.query(`DELETE FROM formation_orders WHERE order_number=$1`, [orderNumber]); info(` cleaned up order row`); } catch {} + } else { + info(` --keep: left ${orderNumber} / ${soName} in place`); + } + return { orderNumber, soName }; +} + +info("=== formation order e2e harness ==="); +info(`worker: ${WORKER_URL} | namesearch: ${!SKIP_NAMESEARCH} | keep: ${KEEP}\n`); +for (const tc of CASES) await runCase(tc); + +await pool.end(); +info(`\n=== ${failures === 0 ? "ALL CHECKS PASSED" : failures + " CHECK(S) FAILED"} ===`); +process.exit(failures === 0 ? 0 : 1); diff --git a/scripts/workers/job_server.py b/scripts/workers/job_server.py index 4806f33..0b91889 100644 --- a/scripts/workers/job_server.py +++ b/scripts/workers/job_server.py @@ -224,7 +224,8 @@ def _check_standard_delay(order_name: str, checkpoint: str) -> dict | None: def handle_name_search(payload: dict) -> dict: """Search for business name availability in a state portal.""" - from scripts.formation.name_search import search_name_sync + import asyncio + from scripts.formation.name_search import search_name order_name = payload["order_name"] state_code = payload["state_code"] entity_name = payload["entity_name"] @@ -235,7 +236,12 @@ def handle_name_search(payload: dict) -> dict: return {"action": "name_search", **defer} LOG.info("Name search: %s in %s", entity_name, state_code) - result = search_name_sync(entity_name, state_code) + _loop = asyncio.new_event_loop() + try: + _nsr = _loop.run_until_complete(search_name(entity_name, state_code)) + finally: + _loop.close() + result = vars(_nsr) if hasattr(_nsr, "__dict__") else dict(_nsr) # Report back to ERPNext from scripts.workers.erpnext_client import ERPNextClient @@ -2014,6 +2020,16 @@ class JobHandler(BaseHTTPRequestHandler): self._handle_rmd_preview() return + if self.path == "/name-search": + # Synchronous name-availability check used by the API + # (GET /api/v1/states/:code/name-search). Returns the shape + # entities.ts expects: {available, state, exact_match, similar_names}. + # This is distinct from /entity-status (which frames the same + # search as an entity-status lookup) and from the order-gated + # async name_search job (which writes back to ERPNext). + self._handle_name_search_sync() + return + if self.path == "/jobs/presign": # Synchronous presign — used by API routes content_length = int(self.headers.get("Content-Length", 0)) @@ -2091,6 +2107,48 @@ class JobHandler(BaseHTTPRequestHandler): self._respond(202, {"job_id": job_id, "action": action, "status": "queued"}) + def _handle_name_search_sync(self): + """Synchronous name-availability check used by the API. + POST /name-search {state_code, name} + Returns {available, state, exact_match, similar_names} (the shape + api/src/routes/entities.ts expects). Calls the live state portal + adapter via search_name_sync; the API caches the result for 24h. + """ + content_length = int(self.headers.get("Content-Length", 0)) + body = self.rfile.read(content_length) + try: + payload = json.loads(body) + except json.JSONDecodeError: + self._respond(400, {"error": "Invalid JSON"}) + return + + state_code = payload.get("state_code", "").strip().upper() + name = (payload.get("name") or payload.get("entity_name") or "").strip() + if not state_code or not name: + self._respond(400, {"error": "state_code and name required"}) + return + + try: + import asyncio + from scripts.formation.name_search import search_name + loop = asyncio.new_event_loop() + result = loop.run_until_complete(search_name(name, state_code)) + loop.close() + if hasattr(result, "__dict__"): + result = vars(result) + elif hasattr(result, "_asdict"): + result = result._asdict() + self._respond(200, { + "available": result.get("available"), + "exact_match": result.get("exact_match"), + "similar_names": result.get("similar_names", []), + "state": state_code, + "source": "live_search", + }) + except Exception as exc: + LOG.exception("name-search sync failed for %s/%s", state_code, name) + self._respond(500, {"error": str(exc)}) + def _handle_entity_status(self): """Synchronous entity status lookup via Playwright state adapter. POST /entity-status {entity_name, state_code}