Add Mode3 with QB Web Connector SOAP integration and credit card charge management

This commit is contained in:
2026-02-21 04:00:10 +00:00
parent e89f60f8a5
commit e6777003a2
10 changed files with 2096 additions and 1 deletions
+975
View File
@@ -0,0 +1,975 @@
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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
// 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>&lt;SyncedLists&gt;${syncedLists}&lt;/SyncedLists&gt;</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>&lt;RequestList&gt;&lt;/RequestList&gt;</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>&lt;error&gt;Invalid ticket&lt;/error&gt;</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>&lt;error&gt;Session expired&lt;/error&gt;</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 => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;' }[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) => {
currentSession = null;
return c.json({ success: true, message: 'Logged out successfully' });
});
// 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);
});
// 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,
};