#!/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);