0d3a98e6ce
- Add graceful shutdown handler to properly close QB sessions on server stop - Enhance /quickbooks/logout endpoint with detailed logging and error handling - Style logout button as 'danger' action with clear QB connection closing label - Improve logout feedback with timestamps and error messaging - Update user to aewing in .env for QB authentication
1024 lines
35 KiB
TypeScript
1024 lines
35 KiB
TypeScript
import { Hono } from 'hono';
|
|
import { serveStatic } from 'hono/bun';
|
|
import fs from 'fs';
|
|
import path from 'path';
|
|
// @ts-ignore Bun project may not have dotenv types configured
|
|
import { config } from 'dotenv';
|
|
|
|
config();
|
|
|
|
const app = new Hono();
|
|
|
|
// Minimal log helpers
|
|
const logLine = (logPath: string, line: string) => {
|
|
try {
|
|
fs.appendFileSync(logPath, `${new Date().toISOString()} ${line}\n`);
|
|
} catch (err) {
|
|
console.error('Failed to write log:', err);
|
|
}
|
|
};
|
|
|
|
// QuickBooks SOAP Types
|
|
interface QBAuthResponse {
|
|
ticket: string;
|
|
timeCreated: string;
|
|
timeExpired: string;
|
|
}
|
|
|
|
interface QBSession {
|
|
ticket: string;
|
|
timeCreated: number;
|
|
timeExpired: number;
|
|
companyFile: string;
|
|
userName: string;
|
|
}
|
|
|
|
// Credit Card Charge Interface
|
|
interface CreditCardCharge {
|
|
id: string;
|
|
creditCardAccount: string;
|
|
vendor: string;
|
|
amount: number;
|
|
expenseAccount: string;
|
|
date: string;
|
|
refNum?: string;
|
|
memo?: string;
|
|
status: 'pending' | 'sent' | 'completed';
|
|
createdAt: number;
|
|
}
|
|
|
|
// Session management
|
|
let currentSession: QBSession | null = null;
|
|
const SESSION_TIMEOUT = 30 * 60 * 1000; // 30 minutes
|
|
|
|
// Charge queue for QB Web Connector
|
|
let chargeQueue: CreditCardCharge[] = [];
|
|
let sentCharges: Set<string> = new Set();
|
|
|
|
// QuickBooks SOAP WSDL Definition
|
|
const QUICKBOOKS_WSDL = `<?xml version="1.0" encoding="UTF-8"?>
|
|
<definitions xmlns="http://schemas.xmlsoap.org/wsdl/"
|
|
xmlns:soap="http://schemas.xmlsoap.org/wsdl/soap/"
|
|
xmlns:tns="http://developer.intuit.com/"
|
|
xmlns:xsd="http://www.w3.org/2001/XMLSchema"
|
|
targetNamespace="http://developer.intuit.com/">
|
|
|
|
<types>
|
|
<xsd:schema targetNamespace="http://developer.intuit.com/">
|
|
<xsd:element name="authenticate">
|
|
<xsd:complexType>
|
|
<xsd:sequence>
|
|
<xsd:element name="strUserName" type="xsd:string"/>
|
|
<xsd:element name="strPassword" type="xsd:string"/>
|
|
</xsd:sequence>
|
|
</xsd:complexType>
|
|
</xsd:element>
|
|
|
|
<xsd:element name="authenticateResponse">
|
|
<xsd:complexType>
|
|
<xsd:sequence>
|
|
<xsd:element name="authenticateResult" type="xsd:string"/>
|
|
</xsd:sequence>
|
|
</xsd:complexType>
|
|
</xsd:element>
|
|
|
|
<xsd:element name="sendRequestXML">
|
|
<xsd:complexType>
|
|
<xsd:sequence>
|
|
<xsd:element name="ticket" type="xsd:string"/>
|
|
<xsd:element name="strHCPResponse" type="xsd:string"/>
|
|
<xsd:element name="strCompanyFileName" type="xsd:string"/>
|
|
<xsd:element name="qbXMLCountry" type="xsd:string"/>
|
|
<xsd:element name="qbXMLMajorVers" type="xsd:int"/>
|
|
<xsd:element name="qbXMLMinorVers" type="xsd:int"/>
|
|
</xsd:sequence>
|
|
</xsd:complexType>
|
|
</xsd:element>
|
|
|
|
<xsd:element name="sendRequestXMLResponse">
|
|
<xsd:complexType>
|
|
<xsd:sequence>
|
|
<xsd:element name="sendRequestXMLResult" type="xsd:string"/>
|
|
</xsd:sequence>
|
|
</xsd:complexType>
|
|
</xsd:element>
|
|
|
|
<xsd:element name="getLastError">
|
|
<xsd:complexType>
|
|
<xsd:sequence>
|
|
<xsd:element name="ticket" type="xsd:string"/>
|
|
</xsd:sequence>
|
|
</xsd:complexType>
|
|
</xsd:element>
|
|
|
|
<xsd:element name="getLastErrorResponse">
|
|
<xsd:complexType>
|
|
<xsd:sequence>
|
|
<xsd:element name="getLastErrorResult" type="xsd:string"/>
|
|
</xsd:sequence>
|
|
</xsd:complexType>
|
|
</xsd:element>
|
|
|
|
<xsd:element name="closeConnection">
|
|
<xsd:complexType>
|
|
<xsd:sequence>
|
|
<xsd:element name="ticket" type="xsd:string"/>
|
|
</xsd:sequence>
|
|
</xsd:complexType>
|
|
</xsd:element>
|
|
|
|
<xsd:element name="closeConnectionResponse">
|
|
<xsd:complexType>
|
|
<xsd:sequence>
|
|
<xsd:element name="closeConnectionResult" type="xsd:string"/>
|
|
</xsd:sequence>
|
|
</xsd:complexType>
|
|
</xsd:element>
|
|
|
|
<xsd:element name="serverVersion">
|
|
<xsd:complexType>
|
|
<xsd:sequence>
|
|
</xsd:sequence>
|
|
</xsd:complexType>
|
|
</xsd:element>
|
|
|
|
<xsd:element name="serverVersionResponse">
|
|
<xsd:complexType>
|
|
<xsd:sequence>
|
|
<xsd:element name="serverVersionResult" type="xsd:string"/>
|
|
</xsd:sequence>
|
|
</xsd:complexType>
|
|
</xsd:element>
|
|
|
|
<xsd:element name="clientVersion">
|
|
<xsd:complexType>
|
|
<xsd:sequence>
|
|
<xsd:element name="strVersion" type="xsd:string"/>
|
|
</xsd:sequence>
|
|
</xsd:complexType>
|
|
</xsd:element>
|
|
|
|
<xsd:element name="clientVersionResponse">
|
|
<xsd:complexType>
|
|
<xsd:sequence>
|
|
<xsd:element name="clientVersionResult" type="xsd:string"/>
|
|
</xsd:sequence>
|
|
</xsd:complexType>
|
|
</xsd:element>
|
|
|
|
<xsd:element name="getSyncedLists">
|
|
<xsd:complexType>
|
|
<xsd:sequence>
|
|
<xsd:element name="ticket" type="xsd:string"/>
|
|
</xsd:sequence>
|
|
</xsd:complexType>
|
|
</xsd:element>
|
|
|
|
<xsd:element name="getSyncedListsResponse">
|
|
<xsd:complexType>
|
|
<xsd:sequence>
|
|
<xsd:element name="getSyncedListsResult" type="xsd:string"/>
|
|
</xsd:sequence>
|
|
</xsd:complexType>
|
|
</xsd:element>
|
|
|
|
<xsd:element name="getRequestList">
|
|
<xsd:complexType>
|
|
<xsd:sequence>
|
|
<xsd:element name="ticket" type="xsd:string"/>
|
|
<xsd:element name="strHCPResponse" type="xsd:string"/>
|
|
<xsd:element name="strCompanyFileName" type="xsd:string"/>
|
|
<xsd:element name="qbXMLCountry" type="xsd:string"/>
|
|
<xsd:element name="qbXMLMajorVers" type="xsd:int"/>
|
|
<xsd:element name="qbXMLMinorVers" type="xsd:int"/>
|
|
</xsd:sequence>
|
|
</xsd:complexType>
|
|
</xsd:element>
|
|
|
|
<xsd:element name="getRequestListResponse">
|
|
<xsd:complexType>
|
|
<xsd:sequence>
|
|
<xsd:element name="getRequestListResult" type="xsd:string"/>
|
|
</xsd:sequence>
|
|
</xsd:complexType>
|
|
</xsd:element>
|
|
|
|
<xsd:element name="connectionError">
|
|
<xsd:complexType>
|
|
<xsd:sequence>
|
|
<xsd:element name="ticket" type="xsd:string"/>
|
|
<xsd:element name="hresult" type="xsd:string"/>
|
|
<xsd:element name="message" type="xsd:string"/>
|
|
</xsd:sequence>
|
|
</xsd:complexType>
|
|
</xsd:element>
|
|
|
|
<xsd:element name="connectionErrorResponse">
|
|
<xsd:complexType>
|
|
<xsd:sequence>
|
|
<xsd:element name="connectionErrorResult" type="xsd:string"/>
|
|
</xsd:sequence>
|
|
</xsd:complexType>
|
|
</xsd:element>
|
|
|
|
<xsd:element name="receiveResponseXML">
|
|
<xsd:complexType>
|
|
<xsd:sequence>
|
|
<xsd:element name="ticket" type="xsd:string"/>
|
|
<xsd:element name="response" type="xsd:string"/>
|
|
<xsd:element name="hresult" type="xsd:string"/>
|
|
<xsd:element name="message" type="xsd:string"/>
|
|
</xsd:sequence>
|
|
</xsd:complexType>
|
|
</xsd:element>
|
|
|
|
<xsd:element name="receiveResponseXMLResponse">
|
|
<xsd:complexType>
|
|
<xsd:sequence>
|
|
<xsd:element name="receiveResponseXMLResult" type="xsd:int"/>
|
|
</xsd:sequence>
|
|
</xsd:complexType>
|
|
</xsd:element>
|
|
</xsd:schema>
|
|
</types>
|
|
|
|
<message name="serverVersionRequest">
|
|
<part name="parameters" element="tns:serverVersion"/>
|
|
</message>
|
|
<message name="serverVersionResponse">
|
|
<part name="parameters" element="tns:serverVersionResponse"/>
|
|
</message>
|
|
|
|
<message name="clientVersionRequest">
|
|
<part name="parameters" element="tns:clientVersion"/>
|
|
</message>
|
|
<message name="clientVersionResponse">
|
|
<part name="parameters" element="tns:clientVersionResponse"/>
|
|
</message>
|
|
|
|
<message name="getSyncedListsRequest">
|
|
<part name="parameters" element="tns:getSyncedLists"/>
|
|
</message>
|
|
<message name="getSyncedListsResponse">
|
|
<part name="parameters" element="tns:getSyncedListsResponse"/>
|
|
</message>
|
|
|
|
<message name="getRequestListRequest">
|
|
<part name="parameters" element="tns:getRequestList"/>
|
|
</message>
|
|
<message name="getRequestListResponse">
|
|
<part name="parameters" element="tns:getRequestListResponse"/>
|
|
</message>
|
|
|
|
<message name="authenticateRequest">
|
|
<part name="parameters" element="tns:authenticate"/>
|
|
</message>
|
|
<message name="authenticateResponse">
|
|
<part name="parameters" element="tns:authenticateResponse"/>
|
|
</message>
|
|
|
|
<message name="sendRequestXMLRequest">
|
|
<part name="parameters" element="tns:sendRequestXML"/>
|
|
</message>
|
|
<message name="sendRequestXMLResponse">
|
|
<part name="parameters" element="tns:sendRequestXMLResponse"/>
|
|
</message>
|
|
|
|
<message name="getLastErrorRequest">
|
|
<part name="parameters" element="tns:getLastError"/>
|
|
</message>
|
|
<message name="getLastErrorResponse">
|
|
<part name="parameters" element="tns:getLastErrorResponse"/>
|
|
</message>
|
|
|
|
<message name="closeConnectionRequest">
|
|
<part name="parameters" element="tns:closeConnection"/>
|
|
</message>
|
|
<message name="closeConnectionResponse">
|
|
<part name="parameters" element="tns:closeConnectionResponse"/>
|
|
</message>
|
|
|
|
<message name="connectionErrorRequest">
|
|
<part name="parameters" element="tns:connectionError"/>
|
|
</message>
|
|
<message name="connectionErrorResponse">
|
|
<part name="parameters" element="tns:connectionErrorResponse"/>
|
|
</message>
|
|
|
|
<message name="receiveResponseXMLRequest">
|
|
<part name="parameters" element="tns:receiveResponseXML"/>
|
|
</message>
|
|
<message name="receiveResponseXMLResponse">
|
|
<part name="parameters" element="tns:receiveResponseXMLResponse"/>
|
|
</message>
|
|
|
|
<portType name="QBWSServiceSoap">
|
|
<operation name="serverVersion">
|
|
<input message="tns:serverVersionRequest"/>
|
|
<output message="tns:serverVersionResponse"/>
|
|
</operation>
|
|
<operation name="clientVersion">
|
|
<input message="tns:clientVersionRequest"/>
|
|
<output message="tns:clientVersionResponse"/>
|
|
</operation>
|
|
<operation name="authenticate">
|
|
<input message="tns:authenticateRequest"/>
|
|
<output message="tns:authenticateResponse"/>
|
|
</operation>
|
|
<operation name="getSyncedLists">
|
|
<input message="tns:getSyncedListsRequest"/>
|
|
<output message="tns:getSyncedListsResponse"/>
|
|
</operation>
|
|
<operation name="getRequestList">
|
|
<input message="tns:getRequestListRequest"/>
|
|
<output message="tns:getRequestListResponse"/>
|
|
</operation>
|
|
<operation name="sendRequestXML">
|
|
<input message="tns:sendRequestXMLRequest"/>
|
|
<output message="tns:sendRequestXMLResponse"/>
|
|
</operation>
|
|
<operation name="getLastError">
|
|
<input message="tns:getLastErrorRequest"/>
|
|
<output message="tns:getLastErrorResponse"/>
|
|
</operation>
|
|
<operation name="closeConnection">
|
|
<input message="tns:closeConnectionRequest"/>
|
|
<output message="tns:closeConnectionResponse"/>
|
|
</operation>
|
|
<operation name="connectionError">
|
|
<input message="tns:connectionErrorRequest"/>
|
|
<output message="tns:connectionErrorResponse"/>
|
|
</operation>
|
|
<operation name="receiveResponseXML">
|
|
<input message="tns:receiveResponseXMLRequest"/>
|
|
<output message="tns:receiveResponseXMLResponse"/>
|
|
</operation>
|
|
</portType>
|
|
|
|
<binding name="QBWSServiceSoapBinding" type="tns:QBWSServiceSoap">
|
|
<soap:binding transport="http://schemas.xmlsoap.org/soap/http" style="document"/>
|
|
|
|
<operation name="serverVersion">
|
|
<soap:operation soapAction="http://developer.intuit.com/serverVersion"/>
|
|
<input><soap:body use="literal"/></input>
|
|
<output><soap:body use="literal"/></output>
|
|
</operation>
|
|
|
|
<operation name="clientVersion">
|
|
<soap:operation soapAction="http://developer.intuit.com/clientVersion"/>
|
|
<input><soap:body use="literal"/></input>
|
|
<output><soap:body use="literal"/></output>
|
|
</operation>
|
|
|
|
<operation name="authenticate">
|
|
<soap:operation soapAction="http://developer.intuit.com/authenticate"/>
|
|
<input><soap:body use="literal"/></input>
|
|
<output><soap:body use="literal"/></output>
|
|
</operation>
|
|
|
|
<operation name="getSyncedLists">
|
|
<soap:operation soapAction="http://developer.intuit.com/getSyncedLists"/>
|
|
<input><soap:body use="literal"/></input>
|
|
<output><soap:body use="literal"/></output>
|
|
</operation>
|
|
|
|
<operation name="getRequestList">
|
|
<soap:operation soapAction="http://developer.intuit.com/getRequestList"/>
|
|
<input><soap:body use="literal"/></input>
|
|
<output><soap:body use="literal"/></output>
|
|
</operation>
|
|
|
|
<operation name="sendRequestXML">
|
|
<soap:operation soapAction="http://developer.intuit.com/sendRequestXML"/>
|
|
<input><soap:body use="literal"/></input>
|
|
<output><soap:body use="literal"/></output>
|
|
</operation>
|
|
|
|
<operation name="getLastError">
|
|
<soap:operation soapAction="http://developer.intuit.com/getLastError"/>
|
|
<input><soap:body use="literal"/></input>
|
|
<output><soap:body use="literal"/></output>
|
|
</operation>
|
|
|
|
<operation name="closeConnection">
|
|
<soap:operation soapAction="http://developer.intuit.com/closeConnection"/>
|
|
<input><soap:body use="literal"/></input>
|
|
<output><soap:body use="literal"/></output>
|
|
</operation>
|
|
|
|
<operation name="connectionError">
|
|
<soap:operation soapAction="http://developer.intuit.com/connectionError"/>
|
|
<input><soap:body use="literal"/></input>
|
|
<output><soap:body use="literal"/></output>
|
|
</operation>
|
|
|
|
<operation name="receiveResponseXML">
|
|
<soap:operation soapAction="http://developer.intuit.com/receiveResponseXML"/>
|
|
<input><soap:body use="literal"/></input>
|
|
<output><soap:body use="literal"/></output>
|
|
</operation>
|
|
</binding>
|
|
|
|
<service name="QBWSService">
|
|
<port name="QBWSServicePort" binding="tns:QBWSServiceSoapBinding">
|
|
<soap:address location="http://localhost:3005/ws"/>
|
|
</port>
|
|
</service>
|
|
</definitions>`;
|
|
|
|
// SOAP Response generators
|
|
function generateSoapResponse(operationName: string, resultContent: string): string {
|
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/"
|
|
xmlns:tns="http://developer.intuit.com/">
|
|
<soap:Body>
|
|
<tns:${operationName}Response>
|
|
<tns:${operationName}Result>${resultContent}</tns:${operationName}Result>
|
|
</tns:${operationName}Response>
|
|
</soap:Body>
|
|
</soap:Envelope>`;
|
|
}
|
|
|
|
// Generate unique session ticket
|
|
function generateTicket(): string {
|
|
return `QB-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
|
|
}
|
|
|
|
// Extract SOAP body content
|
|
function extractSoapBody(soapXml: string): string {
|
|
const bodyMatch = soapXml.match(/<soap:Body>([\s\S]*)<\/soap:Body>/);
|
|
return bodyMatch ? bodyMatch[1] : '';
|
|
}
|
|
|
|
// Parse XML element value
|
|
function parseXmlValue(xml: string, elementName: string): string | null {
|
|
const match = xml.match(new RegExp(`<${elementName}>([^<]+)<\/${elementName}>`));
|
|
return match ? match[1] : null;
|
|
}
|
|
|
|
// Generate QB XML for credit card charge
|
|
function generateCreditCardChargeXML(charge: CreditCardCharge): string {
|
|
// QB Desktop format for credit card charges
|
|
// Using a simpler structure that QB actually expects
|
|
let memoField = '';
|
|
if (charge.memo) {
|
|
memoField = `<Memo>${escapeXml(charge.memo)}</Memo>`;
|
|
}
|
|
let refField = '';
|
|
if (charge.refNum) {
|
|
refField = `<RefNumber>${charge.refNum}</RefNumber>`;
|
|
}
|
|
|
|
return `<?xml version="1.0"?><?qbxml version="17.0"?>
|
|
<QBXML>
|
|
<QBXMLMsgsRq onError="stopOnError">
|
|
<CreditCardChargeAddRq requestID="1">
|
|
<CreditCardChargeAdd>
|
|
<AccountRef>
|
|
<FullyQualifiedName>${escapeXml(charge.creditCardAccount)}</FullyQualifiedName>
|
|
</AccountRef>
|
|
<TxnDate>${charge.date}</TxnDate>
|
|
${refField}
|
|
<VendorRef>
|
|
<FullyQualifiedName>${escapeXml(charge.vendor)}</FullyQualifiedName>
|
|
</VendorRef>
|
|
${memoField}
|
|
<ExpenseLineAdd>
|
|
<AccountRef>
|
|
<FullyQualifiedName>${escapeXml(charge.expenseAccount)}</FullyQualifiedName>
|
|
</AccountRef>
|
|
<Amount>${charge.amount.toFixed(2)}</Amount>
|
|
</ExpenseLineAdd>
|
|
</CreditCardChargeAdd>
|
|
</CreditCardChargeAddRq>
|
|
</QBXMLMsgsRq>
|
|
</QBXML>`;
|
|
}
|
|
|
|
// Helper to escape XML special characters in field values
|
|
function escapeXml(str: string): string {
|
|
if (!str) return '';
|
|
return str
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
}
|
|
|
|
// Static files
|
|
app.use('/frontend/*', serveStatic({ root: './' }));
|
|
app.use('/', serveStatic({ path: './frontend/index.html' }));
|
|
|
|
// Health check
|
|
app.get('/health', (c) => {
|
|
return c.json({ status: 'ok', mode: 'Mode3-QuickBooks' });
|
|
});
|
|
|
|
// WSDL endpoint
|
|
app.get('/ws', (c) => {
|
|
return new Response(QUICKBOOKS_WSDL, {
|
|
status: 200,
|
|
headers: {
|
|
'Content-Type': 'application/xml; charset=utf-8'
|
|
}
|
|
});
|
|
});
|
|
|
|
// SOAP endpoint
|
|
app.post('/ws', async (c) => {
|
|
try {
|
|
const body = await c.req.text();
|
|
const logPath = 'logs/soap.log';
|
|
|
|
logLine(logPath, `[SOAP Request] ${body.substring(0, 300)}`);
|
|
|
|
// Extract operation from SOAP body - look for the first element after <soap:Body>
|
|
let operation = 'unknown';
|
|
const bodyMatch = body.match(/<soap:Body[^>]*>([\s\S]*?)<\/soap:Body>/i);
|
|
if (bodyMatch) {
|
|
const bodyContent = bodyMatch[1].trim();
|
|
const methodMatch = bodyContent.match(/<(?:tns:)?([a-zA-Z]\w*)[>\s]/);
|
|
if (methodMatch) {
|
|
operation = methodMatch[1];
|
|
}
|
|
}
|
|
|
|
logLine(logPath, `[OPERATION] ${operation}`);
|
|
let response: string;
|
|
|
|
if (operation === 'serverVersion') {
|
|
logLine(logPath, `[SERVER VERSION] Requested`);
|
|
// QB expects: <serverVersionResult>
|
|
const responseBody = '<serverVersionResult>1.0.0</serverVersionResult>';
|
|
response = `<?xml version="1.0" encoding="utf-8"?>
|
|
<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
|
|
<soap:Body>
|
|
<serverVersionResponse xmlns="http://developer.intuit.com/">
|
|
${responseBody}
|
|
</serverVersionResponse>
|
|
</soap:Body>
|
|
</soap:Envelope>`;
|
|
} else if (operation === 'clientVersion') {
|
|
const strVersion = parseXmlValue(body, 'strVersion');
|
|
logLine(logPath, `[CLIENT VERSION] ${strVersion}`);
|
|
// QB expects empty or OK response for clientVersion
|
|
response = `<?xml version="1.0" encoding="utf-8"?>
|
|
<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
|
|
<soap:Body>
|
|
<clientVersionResponse xmlns="http://developer.intuit.com/">
|
|
<clientVersionResult></clientVersionResult>
|
|
</clientVersionResponse>
|
|
</soap:Body>
|
|
</soap:Envelope>`;
|
|
} else if (operation === 'authenticate') {
|
|
const username = parseXmlValue(body, 'strUserName');
|
|
const password = parseXmlValue(body, 'strPassword');
|
|
|
|
logLine(logPath, `[AUTHENTICATE] User: ${username}, Password: ${password || '(empty)'}`);
|
|
|
|
// For qbuser, accept any password (test/integration user)
|
|
// For admin, require matching password
|
|
let isValid = false;
|
|
|
|
if (username === 'qbuser') {
|
|
isValid = true; // Accept qbuser with any password
|
|
} else if (username === 'admin') {
|
|
const validPass = process.env.QUICKBOOKS_PASSWORD || 'password';
|
|
isValid = password === validPass;
|
|
}
|
|
|
|
if (isValid) {
|
|
const ticket = generateTicket();
|
|
const now = Date.now();
|
|
currentSession = {
|
|
ticket,
|
|
timeCreated: now,
|
|
timeExpired: now + SESSION_TIMEOUT,
|
|
companyFile: process.env.QUICKBOOKS_FILE_PATH || 'Sample Company',
|
|
userName: username || 'unknown',
|
|
};
|
|
|
|
logLine(logPath, `[AUTH SUCCESS] User: ${username}, Ticket: ${ticket}`);
|
|
const companyFile = currentSession.companyFile;
|
|
response = `<?xml version="1.0" encoding="utf-8"?>
|
|
<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/" xmlns:xsd="http://www.w3.org/2001/XMLSchema">
|
|
<soap:Body>
|
|
<authenticateResponse xmlns="http://developer.intuit.com/">
|
|
<authenticateResult>
|
|
<string>${ticket}</string>
|
|
<string>${companyFile}</string>
|
|
</authenticateResult>
|
|
</authenticateResponse>
|
|
</soap:Body>
|
|
</soap:Envelope>`;
|
|
} else {
|
|
logLine(logPath, `[AUTH FAILED] Invalid credentials for user: ${username}`);
|
|
response = `<?xml version="1.0" encoding="utf-8"?>
|
|
<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/" xmlns:xsd="http://www.w3.org/2001/XMLSchema">
|
|
<soap:Body>
|
|
<authenticateResponse xmlns="http://developer.intuit.com/">
|
|
<authenticateResult>
|
|
<string>0</string>
|
|
<string></string>
|
|
</authenticateResult>
|
|
</authenticateResponse>
|
|
</soap:Body>
|
|
</soap:Envelope>`;
|
|
}
|
|
} else if (operation === 'getSyncedLists') {
|
|
const ticket = parseXmlValue(body, 'ticket');
|
|
logLine(logPath, `[GET SYNCED LISTS] Ticket: ${ticket}`);
|
|
|
|
if (!currentSession || currentSession.ticket !== ticket) {
|
|
logLine(logPath, `[GET SYNCED LISTS FAILED] Invalid ticket`);
|
|
response = `<?xml version="1.0" encoding="utf-8"?>
|
|
<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
|
|
<soap:Body>
|
|
<getSyncedListsResponse xmlns="http://developer.intuit.com/">
|
|
<getSyncedListsResult></getSyncedListsResult>
|
|
</getSyncedListsResponse>
|
|
</soap:Body>
|
|
</soap:Envelope>`;
|
|
} else {
|
|
// Return list of QB object types that this connector can sync
|
|
const syncedLists = `<SyncedList><ListID>Customer</ListID><FullSync>false</FullSync><SyncToken>0</SyncToken></SyncedList>
|
|
<SyncedList><ListID>Invoice</ListID><FullSync>false</FullSync><SyncToken>0</SyncToken></SyncedList>`;
|
|
|
|
response = `<?xml version="1.0" encoding="utf-8"?>
|
|
<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
|
|
<soap:Body>
|
|
<getSyncedListsResponse xmlns="http://developer.intuit.com/">
|
|
<getSyncedListsResult><SyncedLists>${syncedLists}</SyncedLists></getSyncedListsResult>
|
|
</getSyncedListsResponse>
|
|
</soap:Body>
|
|
</soap:Envelope>`;
|
|
}
|
|
} else if (operation === 'getRequestList') {
|
|
const ticket = parseXmlValue(body, 'ticket');
|
|
logLine(logPath, `[GET REQUEST LIST] Ticket: ${ticket}`);
|
|
|
|
if (!currentSession || currentSession.ticket !== ticket) {
|
|
logLine(logPath, `[GET REQUEST LIST FAILED] Invalid ticket`);
|
|
response = `<?xml version="1.0" encoding="utf-8"?>
|
|
<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
|
|
<soap:Body>
|
|
<getRequestListResponse xmlns="http://developer.intuit.com/">
|
|
<getRequestListResult></getRequestListResult>
|
|
</getRequestListResponse>
|
|
</soap:Body>
|
|
</soap:Envelope>`;
|
|
} else {
|
|
// Return empty request list initially
|
|
response = `<?xml version="1.0" encoding="utf-8"?>
|
|
<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
|
|
<soap:Body>
|
|
<getRequestListResponse xmlns="http://developer.intuit.com/">
|
|
<getRequestListResult><RequestList></RequestList></getRequestListResult>
|
|
</getRequestListResponse>
|
|
</soap:Body>
|
|
</soap:Envelope>`;
|
|
}
|
|
} else if (operation === 'sendRequestXML') {
|
|
const ticket = parseXmlValue(body, 'ticket');
|
|
logLine(logPath, `[SEND REQUEST] Ticket: ${ticket}`);
|
|
|
|
if (!currentSession || currentSession.ticket !== ticket) {
|
|
logLine(logPath, `[REQUEST FAILED] Invalid ticket: ${ticket}`);
|
|
response = `<?xml version="1.0" encoding="utf-8"?>
|
|
<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
|
|
<soap:Body>
|
|
<sendRequestXMLResponse xmlns="http://developer.intuit.com/">
|
|
<sendRequestXMLResult><error>Invalid ticket</error></sendRequestXMLResult>
|
|
</sendRequestXMLResponse>
|
|
</soap:Body>
|
|
</soap:Envelope>`;
|
|
} else if (currentSession.timeExpired < Date.now()) {
|
|
logLine(logPath, `[REQUEST FAILED] Ticket expired: ${ticket}`);
|
|
currentSession = null;
|
|
response = `<?xml version="1.0" encoding="utf-8"?>
|
|
<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
|
|
<soap:Body>
|
|
<sendRequestXMLResponse xmlns="http://developer.intuit.com/">
|
|
<sendRequestXMLResult><error>Session expired</error></sendRequestXMLResult>
|
|
</sendRequestXMLResponse>
|
|
</soap:Body>
|
|
</soap:Envelope>`;
|
|
} else {
|
|
const qbXml = parseXmlValue(body, 'strHCPResponse') || '';
|
|
logLine(logPath, `[REQUEST PROCESSED] Ticket: ${ticket}`);
|
|
|
|
// Get pending charges and return as QB XML requests
|
|
let qbXmlResponse = '';
|
|
if (chargeQueue.length > 0 && !sentCharges.has(ticket)) {
|
|
// Get first pending charge and send it
|
|
const pendingCharge = chargeQueue.find(ch => ch.status === 'pending');
|
|
if (pendingCharge) {
|
|
qbXmlResponse = generateCreditCardChargeXML(pendingCharge);
|
|
pendingCharge.status = 'sent';
|
|
sentCharges.add(ticket);
|
|
logLine(logPath, `[CHARGE QUEUED] ${pendingCharge.id}: ${pendingCharge.vendor} $${pendingCharge.amount}`);
|
|
} else {
|
|
// No pending charges, return empty response
|
|
qbXmlResponse = `<?xml version="1.0"?><?qbxml version="17.0"?>
|
|
<QBXML>
|
|
<QBXMLMsgsRs>
|
|
</QBXMLMsgsRs>
|
|
</QBXML>`;
|
|
}
|
|
} else {
|
|
// No more charges to send
|
|
qbXmlResponse = `<?xml version="1.0"?><?qbxml version="17.0"?>
|
|
<QBXML>
|
|
<QBXMLMsgsRs>
|
|
</QBXMLMsgsRs>
|
|
</QBXML>`;
|
|
}
|
|
|
|
response = `<?xml version="1.0" encoding="utf-8"?>
|
|
<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
|
|
<soap:Body>
|
|
<sendRequestXMLResponse xmlns="http://developer.intuit.com/">
|
|
<sendRequestXMLResult>${qbXmlResponse.replace(/[<>&]/g, m => ({ '<': '<', '>': '>', '&': '&' }[m]!))}</sendRequestXMLResult>
|
|
</sendRequestXMLResponse>
|
|
</soap:Body>
|
|
</soap:Envelope>`;
|
|
}
|
|
} else if (operation === 'getLastError') {
|
|
const ticket = parseXmlValue(body, 'ticket');
|
|
logLine(logPath, `[GET ERROR] Ticket: ${ticket}`);
|
|
response = `<?xml version="1.0" encoding="utf-8"?>
|
|
<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
|
|
<soap:Body>
|
|
<getLastErrorResponse xmlns="http://developer.intuit.com/">
|
|
<getLastErrorResult></getLastErrorResult>
|
|
</getLastErrorResponse>
|
|
</soap:Body>
|
|
</soap:Envelope>`;
|
|
} else if (operation === 'closeConnection') {
|
|
const ticket = parseXmlValue(body, 'ticket');
|
|
logLine(logPath, `[CLOSE CONNECTION] Ticket: ${ticket}`);
|
|
currentSession = null;
|
|
response = `<?xml version="1.0" encoding="utf-8"?>
|
|
<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
|
|
<soap:Body>
|
|
<closeConnectionResponse xmlns="http://developer.intuit.com/">
|
|
<closeConnectionResult>Connection closed</closeConnectionResult>
|
|
</closeConnectionResponse>
|
|
</soap:Body>
|
|
</soap:Envelope>`;
|
|
} else if (operation === 'connectionError') {
|
|
const ticket = parseXmlValue(body, 'ticket');
|
|
const hresult = parseXmlValue(body, 'hresult');
|
|
const message = parseXmlValue(body, 'message');
|
|
logLine(logPath, `[CONNECTION ERROR] Ticket: ${ticket}, Error: ${message} (${hresult})`);
|
|
// Return empty string to signal "don't retry with another company file"
|
|
response = `<?xml version="1.0" encoding="utf-8"?>
|
|
<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
|
|
<soap:Body>
|
|
<connectionErrorResponse xmlns="http://developer.intuit.com/">
|
|
<connectionErrorResult></connectionErrorResult>
|
|
</connectionErrorResponse>
|
|
</soap:Body>
|
|
</soap:Envelope>`;
|
|
} else if (operation === 'receiveResponseXML') {
|
|
const ticket = parseXmlValue(body, 'ticket');
|
|
const responseXml = parseXmlValue(body, 'response');
|
|
const hresult = parseXmlValue(body, 'hresult');
|
|
const message = parseXmlValue(body, 'message');
|
|
|
|
if (hresult) {
|
|
logLine(logPath, `[RECEIVE RESPONSE] Ticket: ${ticket}, Error: ${message} (${hresult})`);
|
|
} else {
|
|
logLine(logPath, `[RECEIVE RESPONSE] Ticket: ${ticket}, Success`);
|
|
}
|
|
|
|
// Return percentage complete (0 = 0% done, 100 = 100% done)
|
|
// Since we don't have data yet, return 100 to signal completion
|
|
response = `<?xml version="1.0" encoding="utf-8"?>
|
|
<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
|
|
<soap:Body>
|
|
<receiveResponseXMLResponse xmlns="http://developer.intuit.com/">
|
|
<receiveResponseXMLResult>100</receiveResponseXMLResult>
|
|
</receiveResponseXMLResponse>
|
|
</soap:Body>
|
|
</soap:Envelope>`;
|
|
} else {
|
|
logLine(logPath, `[SOAP ERROR] Unknown operation: ${operation}`);
|
|
response = `<?xml version="1.0" encoding="utf-8"?>
|
|
<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
|
|
<soap:Body>
|
|
<soap:Fault>
|
|
<faultcode>soap:Server</faultcode>
|
|
<faultstring>Operation not supported: ${operation}</faultstring>
|
|
</soap:Fault>
|
|
</soap:Body>
|
|
</soap:Envelope>`;
|
|
}
|
|
|
|
return new Response(response, {
|
|
status: 200,
|
|
headers: {
|
|
'Content-Type': 'text/xml; charset=utf-8'
|
|
}
|
|
});
|
|
} catch (error) {
|
|
console.error('SOAP Error:', error);
|
|
logLine('logs/error.log', `SOAP Error: ${error}`);
|
|
const faultResponse = '<?xml version="1.0" encoding="utf-8"?><soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/"><soap:Body><soap:Fault><faultcode>soap:Server</faultcode><faultstring>Internal Server Error</faultstring></soap:Fault></soap:Body></soap:Envelope>';
|
|
return new Response(faultResponse, {
|
|
status: 500,
|
|
headers: {
|
|
'Content-Type': 'text/xml; charset=utf-8'
|
|
}
|
|
});
|
|
}
|
|
});
|
|
|
|
// REST endpoints for management
|
|
app.get('/quickbooks/status', (c) => {
|
|
if (currentSession && currentSession.timeExpired > Date.now()) {
|
|
return c.json({
|
|
authenticated: true,
|
|
companyFile: currentSession.companyFile,
|
|
userName: currentSession.userName,
|
|
sessionExpires: new Date(currentSession.timeExpired).toISOString(),
|
|
});
|
|
}
|
|
return c.json({ authenticated: false });
|
|
});
|
|
|
|
app.post('/quickbooks/auth', async (c) => {
|
|
try {
|
|
const body = await c.req.json();
|
|
const { username, password } = body;
|
|
|
|
const validUser = process.env.QUICKBOOKS_USER_NAME || 'admin';
|
|
const validPass = process.env.QUICKBOOKS_PASSWORD || 'password';
|
|
|
|
if (username === validUser && password === validPass) {
|
|
const ticket = generateTicket();
|
|
const now = Date.now();
|
|
currentSession = {
|
|
ticket,
|
|
timeCreated: now,
|
|
timeExpired: now + SESSION_TIMEOUT,
|
|
companyFile: process.env.QUICKBOOKS_FILE_PATH || 'Sample Company',
|
|
userName: username,
|
|
};
|
|
|
|
return c.json({
|
|
success: true,
|
|
ticket,
|
|
message: 'Authenticated successfully',
|
|
});
|
|
}
|
|
|
|
return c.json({ success: false, error: 'Invalid credentials' }, 401);
|
|
} catch (error) {
|
|
console.error('Auth Error:', error);
|
|
return c.json({ success: false, error: 'Auth failed' }, 500);
|
|
}
|
|
});
|
|
|
|
app.post('/quickbooks/logout', (c) => {
|
|
try {
|
|
if (currentSession) {
|
|
const ticket = currentSession.ticket;
|
|
const userName = currentSession.userName;
|
|
|
|
// Log the closeConnection as if it came from QB
|
|
logLine('logs/qbws.log', `[CLOSE CONNECTION] Ticket: ${ticket}, User: ${userName}`);
|
|
|
|
// Clear the session to prevent further requests
|
|
currentSession = null;
|
|
|
|
logLine('logs/qbws.log', `[SESSION CLEARED] Connection properly closed by user`);
|
|
|
|
return c.json({
|
|
success: true,
|
|
message: 'QuickBooks connection closed successfully',
|
|
closedAt: new Date().toISOString()
|
|
});
|
|
} else {
|
|
return c.json({
|
|
success: true,
|
|
message: 'No active QuickBooks session to close'
|
|
});
|
|
}
|
|
} catch (error) {
|
|
console.error('Logout Error:', error);
|
|
logLine('logs/error.log', `Logout Error: ${error}`);
|
|
return c.json({ success: false, error: 'Logout failed' }, 500);
|
|
}
|
|
});
|
|
|
|
// 404 handler
|
|
app.notFound((c) => {
|
|
return c.json({ error: 'Not found' }, 404);
|
|
});
|
|
|
|
// Error handler
|
|
app.onError((err, c) => {
|
|
console.error('Server Error:', err);
|
|
logLine('logs/error.log', `Server Error: ${err.message}`);
|
|
return c.json({ error: 'Internal server error' }, 500);
|
|
});
|
|
|
|
// Credit Card Charge Management API
|
|
app.post('/api/charges', async (c) => {
|
|
try {
|
|
const body = await c.req.json();
|
|
const charge: CreditCardCharge = {
|
|
id: `CHG-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
|
creditCardAccount: body.creditCardAccount || '21010 - Kum&Go Fleet Card',
|
|
vendor: body.vendor || 'Unknown Vendor',
|
|
amount: parseFloat(body.amount) || 0,
|
|
expenseAccount: body.expenseAccount || '60000 - Semi-Variable Expenses',
|
|
date: body.date || new Date().toISOString().split('T')[0],
|
|
refNum: body.refNum || undefined,
|
|
memo: body.memo || undefined,
|
|
status: 'pending',
|
|
createdAt: Date.now()
|
|
};
|
|
|
|
chargeQueue.push(charge);
|
|
logLine('logs/charges.log', `[CHARGE CREATED] ${charge.id}: ${charge.vendor} $${charge.amount} from ${charge.creditCardAccount}`);
|
|
|
|
return c.json({
|
|
success: true,
|
|
charge,
|
|
message: 'Charge created and queued for QB sync'
|
|
});
|
|
} catch (err) {
|
|
logLine('logs/error.log', `[CHARGE ERROR] Failed to create charge: ${err}`);
|
|
return c.json({ success: false, error: String(err) }, 400);
|
|
}
|
|
});
|
|
|
|
app.get('/api/charges', (c) => {
|
|
return c.json({
|
|
pending: chargeQueue.filter(ch => ch.status === 'pending'),
|
|
sent: chargeQueue.filter(ch => ch.status === 'sent'),
|
|
total: chargeQueue.length
|
|
});
|
|
});
|
|
|
|
app.get('/api/charges/:id', (c) => {
|
|
const id = c.req.param('id');
|
|
const charge = chargeQueue.find(ch => ch.id === id);
|
|
return charge
|
|
? c.json(charge)
|
|
: c.json({ error: 'Charge not found' }, 404);
|
|
});
|
|
|
|
app.delete('/api/charges/:id', (c) => {
|
|
const id = c.req.param('id');
|
|
const index = chargeQueue.findIndex(ch => ch.id === id);
|
|
if (index !== -1) {
|
|
const removed = chargeQueue.splice(index, 1)[0];
|
|
logLine('logs/charges.log', `[CHARGE DELETED] ${id}`);
|
|
return c.json({ success: true, removed });
|
|
}
|
|
return c.json({ error: 'Charge not found' }, 404);
|
|
});
|
|
|
|
// Graceful shutdown handler
|
|
function gracefulShutdown() {
|
|
console.log('\n[SHUTDOWN] Received shutdown signal...');
|
|
|
|
if (currentSession) {
|
|
const ticket = currentSession.ticket;
|
|
const userName = currentSession.userName;
|
|
logLine('logs/qbws.log', `[SERVER SHUTDOWN] Closing active session - Ticket: ${ticket}, User: ${userName}`);
|
|
currentSession = null;
|
|
console.log('[SHUTDOWN] Active QuickBooks session closed');
|
|
}
|
|
|
|
logLine('logs/qbws.log', `[SERVER STOPPED] Server shutdown at ${new Date().toISOString()}`);
|
|
console.log('[SHUTDOWN] Server shutdown complete');
|
|
process.exit(0);
|
|
}
|
|
|
|
// Handle shutdown signals
|
|
process.on('SIGTERM', gracefulShutdown);
|
|
process.on('SIGINT', gracefulShutdown);
|
|
|
|
// Startup
|
|
const PORT = process.env.PORT || 3005;
|
|
console.log(`
|
|
================================================================================
|
|
[Mode3 - QuickBooks SOAP Server]
|
|
================================================================================
|
|
Listening on port: ${PORT}
|
|
SOAP Endpoint: http://localhost:${PORT}/ws
|
|
Company File: ${process.env.QUICKBOOKS_FILE_PATH || 'Sample Company'}
|
|
User: ${process.env.QUICKBOOKS_USER_NAME || 'admin'}
|
|
================================================================================
|
|
`);
|
|
|
|
export default {
|
|
port: PORT,
|
|
fetch: app.fetch,
|
|
};
|