diff --git a/models/AccountingConnection.js b/models/AccountingConnection.js new file mode 100644 index 0000000..b12aa46 --- /dev/null +++ b/models/AccountingConnection.js @@ -0,0 +1,14 @@ +const mongoose = require('mongoose'); + +const accountingConnectionSchema = new mongoose.Schema({ + user: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true }, + platform: { type: String, enum: ['quickbooks', 'xero'], required: true }, + accessToken: { type: String, required: true }, + refreshToken: { type: String, required: true }, + realmId: { type: String }, // For QuickBooks company ID + tenantId: { type: String }, // For Xero tenant ID + expiresAt: { type: Date, required: true }, + connectedAt: { type: Date, default: Date.now } +}); + +module.exports = mongoose.model('AccountingConnection', accountingConnectionSchema); \ No newline at end of file diff --git a/models/Expense.js b/models/Expense.js index dde44ab..887dad0 100644 --- a/models/Expense.js +++ b/models/Expense.js @@ -73,16 +73,10 @@ const expenseSchema = new mongoose.Schema({ type: Boolean, default: false }, - status: { - type: String, - enum: ['draft', 'pending_approval', 'approved', 'rejected'], - default: 'approved' // Default to approved for backward compatibility - }, - approvalWorkflow: { - type: mongoose.Schema.Types.ObjectId, - ref: 'ApprovalWorkflow' + syncedToAccounting: { + type: Boolean, + default: false } -}, { timestamps: true }); diff --git a/package.json b/package.json index c5e2b29..3984be2 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,10 @@ "simple-statistics": "^7.8.3", "node-fetch": "^3.3.2", "moment-timezone": "^0.5.43", - "i18next": "^23.7.6" + "i18next": "^23.7.6", + "i18next-fs-backend": "^2.3.1", + "intuit-oauth": "^4.0.0", + "xero-node": "^4.20.0" }, "devDependencies": { "nodemon": "^3.0.1" diff --git a/routes/accounting.js b/routes/accounting.js new file mode 100644 index 0000000..571e82e --- /dev/null +++ b/routes/accounting.js @@ -0,0 +1,86 @@ +const express = require('express'); +const router = express.Router(); +const accountingService = require('../services/accountingService'); +const auth = require('../middleware/auth'); + +// Get auth URL for platform +router.get('/auth/:platform', auth, async (req, res) => { + try { + const { platform } = req.params; + const userId = req.user.id; + + let authUrl; + if (platform === 'quickbooks') { + authUrl = await accountingService.getQuickBooksAuthUrl(userId); + } else if (platform === 'xero') { + authUrl = await accountingService.getXeroAuthUrl(userId); + } else { + return res.status(400).json({ error: 'Unsupported platform' }); + } + + res.json({ authUrl }); + } catch (error) { + console.error('Auth URL error:', error); + res.status(500).json({ error: 'Failed to generate auth URL' }); + } +}); + +// Handle OAuth callback +router.get('/callback/:platform', async (req, res) => { + try { + const { platform } = req.params; + const { code, state } = req.query; + + let connection; + if (platform === 'quickbooks') { + connection = await accountingService.handleQuickBooksCallback(code, state); + } else if (platform === 'xero') { + connection = await accountingService.handleXeroCallback(code, state); + } else { + return res.status(400).json({ error: 'Unsupported platform' }); + } + + // Redirect to frontend with success + res.redirect(`${process.env.FRONTEND_URL}/settings/accounting?connected=${platform}`); + } catch (error) { + console.error('Callback error:', error); + res.redirect(`${process.env.FRONTEND_URL}/settings/accounting?error=${encodeURIComponent(error.message)}`); + } +}); + +// Get user's connected platforms +router.get('/connections', auth, async (req, res) => { + try { + const connections = await accountingService.getUserConnections(req.user.id); + res.json(connections); + } catch (error) { + console.error('Get connections error:', error); + res.status(500).json({ error: 'Failed to get connections' }); + } +}); + +// Sync expenses to platform +router.post('/sync/:platform', auth, async (req, res) => { + try { + const { platform } = req.params; + const result = await accountingService.syncExpensesToAccounting(req.user.id, platform); + res.json(result); + } catch (error) { + console.error('Sync error:', error); + res.status(500).json({ error: error.message }); + } +}); + +// Disconnect platform +router.delete('/disconnect/:platform', auth, async (req, res) => { + try { + const { platform } = req.params; + await accountingService.disconnectPlatform(req.user.id, platform); + res.json({ message: 'Disconnected successfully' }); + } catch (error) { + console.error('Disconnect error:', error); + res.status(500).json({ error: 'Failed to disconnect' }); + } +}); + +module.exports = router; \ No newline at end of file diff --git a/server.js b/server.js index 67e8a12..e59f143 100644 --- a/server.js +++ b/server.js @@ -227,6 +227,7 @@ app.use('/api/ai', require('./routes/ai')); app.use('/api/multicurrency', require('./routes/multicurrency')); app.use('/api/collaboration', require('./routes/collaboration')); app.use('/api/audit-compliance', require('./routes/auditCompliance')); +app.use('/api/accounting', require('./routes/accounting')); app.use('/api/analytics', require('./routes/analytics')); app.use('/api/fraud-detection', require('./routes/fraudDetection')); diff --git a/services/accountingService.js b/services/accountingService.js new file mode 100644 index 0000000..e6dfd75 --- /dev/null +++ b/services/accountingService.js @@ -0,0 +1,210 @@ +const axios = require('axios'); +const OAuthClient = require('intuit-oauth'); +const { XeroClient } = require('xero-node'); +const AccountingConnection = require('../models/AccountingConnection'); +const Expense = require('../models/Expense'); + +class AccountingService { + constructor() { + // QuickBooks OAuth client + this.qbClient = new OAuthClient({ + clientId: process.env.QUICKBOOKS_CLIENT_ID, + clientSecret: process.env.QUICKBOOKS_CLIENT_SECRET, + redirectUri: process.env.QUICKBOOKS_REDIRECT_URI, + environment: process.env.NODE_ENV === 'production' ? 'production' : 'sandbox' + }); + + // Xero client + this.xeroClient = new XeroClient({ + clientId: process.env.XERO_CLIENT_ID, + clientSecret: process.env.XERO_CLIENT_SECRET, + redirectUris: [process.env.XERO_REDIRECT_URI], + scopes: ['accounting.transactions', 'accounting.contacts'] + }); + } + + // QuickBooks methods + async getQuickBooksAuthUrl(userId) { + const authUri = this.qbClient.authorizeUri({ + scope: ['com.intuit.quickbooks.accounting'], + state: `user_${userId}` + }); + return authUri; + } + + async handleQuickBooksCallback(code, state) { + const userId = state.replace('user_', ''); + const authResponse = await this.qbClient.createToken(code); + const tokenData = authResponse.getJson(); + + const connection = new AccountingConnection({ + user: userId, + platform: 'quickbooks', + accessToken: tokenData.access_token, + refreshToken: tokenData.refresh_token, + realmId: tokenData.realmId, + expiresAt: new Date(Date.now() + tokenData.expires_in * 1000) + }); + + await connection.save(); + return connection; + } + + async refreshQuickBooksToken(connection) { + this.qbClient.setToken({ + access_token: connection.accessToken, + refresh_token: connection.refreshToken, + token_type: 'Bearer', + expires_in: Math.floor((connection.expiresAt - Date.now()) / 1000) + }); + + const authResponse = await this.qbClient.refresh(); + const tokenData = authResponse.getJson(); + + connection.accessToken = tokenData.access_token; + connection.refreshToken = tokenData.refresh_token; + connection.expiresAt = new Date(Date.now() + tokenData.expires_in * 1000); + await connection.save(); + + return connection; + } + + // Xero methods + async getXeroAuthUrl(userId) { + const consentUrl = await this.xeroClient.buildConsentUrl(); + return `${consentUrl}&state=user_${userId}`; + } + + async handleXeroCallback(code, state) { + const userId = state.replace('user_', ''); + const tokenSet = await this.xeroClient.apiCallback(code); + + const connections = await this.xeroClient.updateTenants(); + const tenantId = connections[0].tenantId; + + const connection = new AccountingConnection({ + user: userId, + platform: 'xero', + accessToken: tokenSet.access_token, + refreshToken: tokenSet.refresh_token, + tenantId: tenantId, + expiresAt: new Date(Date.now() + tokenSet.expires_in * 1000) + }); + + await connection.save(); + return connection; + } + + async refreshXeroToken(connection) { + await this.xeroClient.setTokenSet({ + access_token: connection.accessToken, + refresh_token: connection.refreshToken, + expires_at: connection.expiresAt.getTime() / 1000 + }); + + const tokenSet = await this.xeroClient.refreshToken(); + connection.accessToken = tokenSet.access_token; + connection.refreshToken = tokenSet.refresh_token; + connection.expiresAt = new Date(tokenSet.expires_at * 1000); + await connection.save(); + + return connection; + } + + // Sync expenses to accounting platform + async syncExpensesToAccounting(userId, platform) { + const connection = await AccountingConnection.findOne({ user: userId, platform }); + if (!connection) throw new Error('No accounting connection found'); + + // Refresh token if needed + if (connection.expiresAt < new Date()) { + if (platform === 'quickbooks') { + await this.refreshQuickBooksToken(connection); + } else { + await this.refreshXeroToken(connection); + } + } + + const expenses = await Expense.find({ user: userId, syncedToAccounting: { $ne: true } }); + + for (const expense of expenses) { + if (platform === 'quickbooks') { + await this.syncToQuickBooks(expense, connection); + } else { + await this.syncToXero(expense, connection); + } + expense.syncedToAccounting = true; + await expense.save(); + } + + return { synced: expenses.length }; + } + + async syncToQuickBooks(expense, connection) { + // Implement QuickBooks API call to create expense + const qbUrl = `https://quickbooks.api.intuit.com/v3/company/${connection.realmId}/purchase`; + + const expenseData = { + "AccountRef": { + "value": "1", // Default expense account + "name": "Expense" + }, + "PaymentType": "Cash", + "TxnDate": expense.date.toISOString().split('T')[0], + "Line": [{ + "Amount": expense.amount, + "DetailType": "AccountBasedExpenseLineDetail", + "AccountBasedExpenseLineDetail": { + "AccountRef": { + "value": "1" + } + } + }] + }; + + await axios.post(qbUrl, expenseData, { + headers: { + 'Authorization': `Bearer ${connection.accessToken}`, + 'Content-Type': 'application/json' + } + }); + } + + async syncToXero(expense, connection) { + // Implement Xero API call + await this.xeroClient.setTokenSet({ + access_token: connection.accessToken, + refresh_token: connection.refreshToken + }); + + const expenseData = { + Type: 'ACCPAY', + Contact: { + Name: expense.merchant || 'Expense' + }, + Date: expense.date.toISOString().split('T')[0], + DueDate: expense.date.toISOString().split('T')[0], + LineItems: [{ + Description: expense.description, + Quantity: 1, + UnitAmount: expense.amount, + AccountCode: '400' // Expense account + }], + Status: 'AUTHORISED' + }; + + await this.xeroClient.accountingApi.createInvoices(connection.tenantId, { invoices: [expenseData] }); + } + + // Get connected platforms for user + async getUserConnections(userId) { + return await AccountingConnection.find({ user: userId }); + } + + // Disconnect platform + async disconnectPlatform(userId, platform) { + await AccountingConnection.findOneAndDelete({ user: userId, platform }); + } +} + +module.exports = new AccountingService(); \ No newline at end of file