Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions models/AccountingConnection.js
Original file line number Diff line number Diff line change
@@ -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);
12 changes: 3 additions & 9 deletions models/Expense.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
});

Expand Down
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,9 @@
"node-fetch": "^3.3.2",
"moment-timezone": "^0.5.43",
"i18next": "^23.7.6",
"i18next-fs-backend": "^2.3.1"
"i18next-fs-backend": "^2.3.1",
"intuit-oauth": "^4.0.0",
"xero-node": "^4.20.0"
},
"devDependencies": {
"nodemon": "^3.0.1"
Expand Down
86 changes: 86 additions & 0 deletions routes/accounting.js
Original file line number Diff line number Diff line change
@@ -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;
1 change: 1 addition & 0 deletions server.js
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,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'));

// Root route to serve the UI
Expand Down
210 changes: 210 additions & 0 deletions services/accountingService.js
Original file line number Diff line number Diff line change
@@ -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();