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 = new Set(); // QuickBooks SOAP WSDL Definition const QUICKBOOKS_WSDL = ` `; // SOAP Response generators function generateSoapResponse(operationName: string, resultContent: string): string { return ` ${resultContent} `; } // 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(/([\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 = `${escapeXml(charge.memo)}`; } let refField = ''; if (charge.refNum) { refField = `${charge.refNum}`; } return ` ${escapeXml(charge.creditCardAccount)} ${charge.date} ${refField} ${escapeXml(charge.vendor)} ${memoField} ${escapeXml(charge.expenseAccount)} ${charge.amount.toFixed(2)} `; } // 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, '''); } // 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 let operation = 'unknown'; const bodyMatch = body.match(/]*>([\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: const responseBody = '1.0.0'; response = ` ${responseBody} `; } else if (operation === 'clientVersion') { const strVersion = parseXmlValue(body, 'strVersion'); logLine(logPath, `[CLIENT VERSION] ${strVersion}`); // QB expects empty or OK response for clientVersion response = ` `; } 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 = ` ${ticket} ${companyFile} `; } else { logLine(logPath, `[AUTH FAILED] Invalid credentials for user: ${username}`); response = ` 0 `; } } 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 = ` `; } else { // Return list of QB object types that this connector can sync const syncedLists = `Customerfalse0 Invoicefalse0`; response = ` <SyncedLists>${syncedLists}</SyncedLists> `; } } 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 = ` `; } else { // Return empty request list initially response = ` <RequestList></RequestList> `; } } 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 = ` <error>Invalid ticket</error> `; } else if (currentSession.timeExpired < Date.now()) { logLine(logPath, `[REQUEST FAILED] Ticket expired: ${ticket}`); currentSession = null; response = ` <error>Session expired</error> `; } 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 = ` `; } } else { // No more charges to send qbXmlResponse = ` `; } response = ` ${qbXmlResponse.replace(/[<>&]/g, m => ({ '<': '<', '>': '>', '&': '&' }[m]!))} `; } } else if (operation === 'getLastError') { const ticket = parseXmlValue(body, 'ticket'); logLine(logPath, `[GET ERROR] Ticket: ${ticket}`); response = ` `; } else if (operation === 'closeConnection') { const ticket = parseXmlValue(body, 'ticket'); logLine(logPath, `[CLOSE CONNECTION] Ticket: ${ticket}`); currentSession = null; response = ` Connection closed `; } 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 = ` `; } 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 = ` 100 `; } else { logLine(logPath, `[SOAP ERROR] Unknown operation: ${operation}`); response = ` soap:Server Operation not supported: ${operation} `; } 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 = 'soap:ServerInternal Server Error'; 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, };