You've already forked double-entry-accounting
Initial project
This commit is contained in:
BIN
__pycache__/config.cpython-311.pyc
Normal file
BIN
__pycache__/config.cpython-311.pyc
Normal file
Binary file not shown.
BIN
accounting.db
Normal file
BIN
accounting.db
Normal file
Binary file not shown.
0
accounting/__init__.py
Normal file
0
accounting/__init__.py
Normal file
BIN
accounting/__pycache__/__init__.cpython-311.pyc
Normal file
BIN
accounting/__pycache__/__init__.cpython-311.pyc
Normal file
Binary file not shown.
BIN
accounting/__pycache__/api.cpython-311.pyc
Normal file
BIN
accounting/__pycache__/api.cpython-311.pyc
Normal file
Binary file not shown.
BIN
accounting/__pycache__/models.cpython-311.pyc
Normal file
BIN
accounting/__pycache__/models.cpython-311.pyc
Normal file
Binary file not shown.
161
accounting/api.py
Normal file
161
accounting/api.py
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
import cherrypy
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
from datetime import datetime
|
||||||
|
from accounting.models import Account, BankAccount, BankTransaction, JournalEntry, JournalEntryLine, ReconciliationReport, \
|
||||||
|
ReconciliationMatch
|
||||||
|
import simplejson as json
|
||||||
|
|
||||||
|
|
||||||
|
class AccountingAPI:
|
||||||
|
def __init__(self, db_engine):
|
||||||
|
self.db_engine = db_engine
|
||||||
|
Session = sessionmaker(bind=db_engine)
|
||||||
|
self.session = Session()
|
||||||
|
|
||||||
|
@cherrypy.expose
|
||||||
|
@cherrypy.tools.json_out()
|
||||||
|
def index(self):
|
||||||
|
return {"status": "success", "message": "Accounting API is running"}
|
||||||
|
|
||||||
|
# Bank Accounts Endpoints
|
||||||
|
@cherrypy.expose
|
||||||
|
@cherrypy.tools.json_out()
|
||||||
|
def bank_accounts(self):
|
||||||
|
if cherrypy.request.method != 'GET':
|
||||||
|
raise cherrypy.HTTPError(405)
|
||||||
|
|
||||||
|
accounts = self.session.query(BankAccount).all()
|
||||||
|
return [{
|
||||||
|
'id': a.id,
|
||||||
|
'name': a.name,
|
||||||
|
'bank_name': a.bank_name,
|
||||||
|
'account_number': a.account_number,
|
||||||
|
'currency': a.currency
|
||||||
|
} for a in accounts]
|
||||||
|
|
||||||
|
@cherrypy.expose
|
||||||
|
@cherrypy.tools.json_in()
|
||||||
|
@cherrypy.tools.json_out()
|
||||||
|
def add_bank_account(self):
|
||||||
|
if cherrypy.request.method != 'POST':
|
||||||
|
raise cherrypy.HTTPError(405)
|
||||||
|
|
||||||
|
data = cherrypy.request.json
|
||||||
|
account = BankAccount(
|
||||||
|
name=data['name'],
|
||||||
|
bank_name=data['bank_name'],
|
||||||
|
account_number=data['account_number'],
|
||||||
|
currency=data.get('currency', 'USD')
|
||||||
|
)
|
||||||
|
self.session.add(account)
|
||||||
|
self.session.commit()
|
||||||
|
return {'status': 'success', 'id': account.id}
|
||||||
|
|
||||||
|
# Add OPTIONS method handler for CORS preflight
|
||||||
|
@cherrypy.expose
|
||||||
|
def add_bank_account_options(self):
|
||||||
|
cherrypy.response.headers['Allow'] = 'POST, OPTIONS'
|
||||||
|
cherrypy.response.headers['Access-Control-Allow-Headers'] = 'Content-Type'
|
||||||
|
return ''
|
||||||
|
|
||||||
|
# Accounts Endpoints
|
||||||
|
@cherrypy.expose
|
||||||
|
@cherrypy.tools.json_out()
|
||||||
|
def accounts(self):
|
||||||
|
if cherrypy.request.method != 'GET':
|
||||||
|
raise cherrypy.HTTPError(405)
|
||||||
|
|
||||||
|
accounts = self.session.query(Account).all()
|
||||||
|
return [{
|
||||||
|
'id': a.id,
|
||||||
|
'name': a.name,
|
||||||
|
'code': a.code,
|
||||||
|
'type': a.account_type,
|
||||||
|
'balance': float(a.balance) if a.balance else 0
|
||||||
|
} for a in accounts]
|
||||||
|
|
||||||
|
@cherrypy.expose
|
||||||
|
@cherrypy.tools.json_in()
|
||||||
|
@cherrypy.tools.json_out()
|
||||||
|
def add_account(self):
|
||||||
|
if cherrypy.request.method != 'POST':
|
||||||
|
raise cherrypy.HTTPError(405)
|
||||||
|
|
||||||
|
data = cherrypy.request.json
|
||||||
|
account = Account(
|
||||||
|
name=data['name'],
|
||||||
|
account_type=data['type'],
|
||||||
|
code=data['code'],
|
||||||
|
parent_id=data.get('parent_id')
|
||||||
|
)
|
||||||
|
self.session.add(account)
|
||||||
|
self.session.commit()
|
||||||
|
return {'status': 'success', 'id': account.id}
|
||||||
|
|
||||||
|
# Journal Entries Endpoints
|
||||||
|
@cherrypy.expose
|
||||||
|
@cherrypy.tools.json_in()
|
||||||
|
@cherrypy.tools.json_out()
|
||||||
|
def add_journal_entry(self):
|
||||||
|
if cherrypy.request.method != 'POST':
|
||||||
|
raise cherrypy.HTTPError(405)
|
||||||
|
|
||||||
|
data = cherrypy.request.json
|
||||||
|
total_debit = sum(line['amount'] for line in data['lines'] if line['is_debit'])
|
||||||
|
total_credit = sum(line['amount'] for line in data['lines'] if not line['is_debit'])
|
||||||
|
|
||||||
|
if abs(total_debit - total_credit) > 0.01:
|
||||||
|
return {'status': 'error', 'message': 'Debits and credits must balance'}
|
||||||
|
|
||||||
|
entry = JournalEntry(
|
||||||
|
date=datetime.strptime(data['date'], '%Y-%m-%d').date(),
|
||||||
|
reference=data.get('reference', ''),
|
||||||
|
description=data.get('description', '')
|
||||||
|
)
|
||||||
|
self.session.add(entry)
|
||||||
|
|
||||||
|
for line_data in data['lines']:
|
||||||
|
line = JournalEntryLine(
|
||||||
|
journal_entry=entry,
|
||||||
|
account_id=line_data['account_id'],
|
||||||
|
amount=line_data['amount'],
|
||||||
|
is_debit=line_data['is_debit']
|
||||||
|
)
|
||||||
|
self.session.add(line)
|
||||||
|
|
||||||
|
account = self.session.query(Account).get(line_data['account_id'])
|
||||||
|
if line_data['is_debit']:
|
||||||
|
account.balance += line_data['amount']
|
||||||
|
else:
|
||||||
|
account.balance -= line_data['amount']
|
||||||
|
|
||||||
|
self.session.commit()
|
||||||
|
return {'status': 'success', 'id': entry.id}
|
||||||
|
|
||||||
|
@cherrypy.expose
|
||||||
|
@cherrypy.tools.json_out()
|
||||||
|
def journal_entries(self):
|
||||||
|
if cherrypy.request.method != 'GET':
|
||||||
|
raise cherrypy.HTTPError(405)
|
||||||
|
|
||||||
|
entries = self.session.query(JournalEntry).all()
|
||||||
|
return [{
|
||||||
|
'id': e.id,
|
||||||
|
'date': e.date.isoformat(),
|
||||||
|
'description': e.description,
|
||||||
|
'reference': e.reference,
|
||||||
|
'lines': [{
|
||||||
|
'account_id': l.account_id,
|
||||||
|
'amount': float(l.amount),
|
||||||
|
'is_debit': l.is_debit
|
||||||
|
} for l in e.lines]
|
||||||
|
} for e in entries]
|
||||||
|
|
||||||
|
# Add default OPTIONS handler for all endpoints
|
||||||
|
@cherrypy.expose
|
||||||
|
def default(self, *args, **kwargs):
|
||||||
|
if cherrypy.request.method == 'OPTIONS':
|
||||||
|
cherrypy.response.headers['Allow'] = 'GET, POST, OPTIONS'
|
||||||
|
cherrypy.response.headers['Access-Control-Allow-Headers'] = 'Content-Type'
|
||||||
|
return ''
|
||||||
|
raise cherrypy.HTTPError(404)
|
||||||
106
accounting/models.py
Normal file
106
accounting/models.py
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.ext.declarative import declarative_base
|
||||||
|
from sqlalchemy.orm import sessionmaker, relationship
|
||||||
|
|
||||||
|
Base = declarative_base()
|
||||||
|
|
||||||
|
|
||||||
|
class Account(Base):
|
||||||
|
__tablename__ = 'accounts'
|
||||||
|
|
||||||
|
id = sa.Column(sa.Integer, primary_key=True)
|
||||||
|
name = sa.Column(sa.String(100), nullable=False)
|
||||||
|
account_type = sa.Column(sa.String(50), nullable=False) # Asset, Liability, Equity, Revenue, Expense
|
||||||
|
code = sa.Column(sa.String(20), unique=True, nullable=False)
|
||||||
|
parent_id = sa.Column(sa.Integer, sa.ForeignKey('accounts.id'))
|
||||||
|
balance = sa.Column(sa.Numeric(15, 2), default=0)
|
||||||
|
|
||||||
|
parent = relationship('Account', remote_side=[id])
|
||||||
|
entries = relationship('JournalEntryLine', back_populates='account')
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<Account(code={self.code}, name={self.name}, type={self.account_type})>"
|
||||||
|
|
||||||
|
|
||||||
|
class JournalEntry(Base):
|
||||||
|
__tablename__ = 'journal_entries'
|
||||||
|
|
||||||
|
id = sa.Column(sa.Integer, primary_key=True)
|
||||||
|
date = sa.Column(sa.Date, nullable=False)
|
||||||
|
reference = sa.Column(sa.String(100))
|
||||||
|
description = sa.Column(sa.String(200))
|
||||||
|
created_at = sa.Column(sa.DateTime, server_default=sa.func.now())
|
||||||
|
|
||||||
|
lines = relationship('JournalEntryLine', back_populates='journal_entry')
|
||||||
|
|
||||||
|
|
||||||
|
class JournalEntryLine(Base):
|
||||||
|
__tablename__ = 'journal_entry_lines'
|
||||||
|
|
||||||
|
id = sa.Column(sa.Integer, primary_key=True)
|
||||||
|
journal_entry_id = sa.Column(sa.Integer, sa.ForeignKey('journal_entries.id'), nullable=False)
|
||||||
|
account_id = sa.Column(sa.Integer, sa.ForeignKey('accounts.id'), nullable=False)
|
||||||
|
amount = sa.Column(sa.Numeric(15, 2), nullable=False)
|
||||||
|
is_debit = sa.Column(sa.Boolean, nullable=False) # True for debit, False for credit
|
||||||
|
|
||||||
|
journal_entry = relationship('JournalEntry', back_populates='lines')
|
||||||
|
account = relationship('Account', back_populates='entries')
|
||||||
|
|
||||||
|
|
||||||
|
class BankAccount(Base):
|
||||||
|
__tablename__ = 'bank_accounts'
|
||||||
|
|
||||||
|
id = sa.Column(sa.Integer, primary_key=True)
|
||||||
|
name = sa.Column(sa.String(100), nullable=False)
|
||||||
|
account_number = sa.Column(sa.String(50))
|
||||||
|
bank_name = sa.Column(sa.String(100))
|
||||||
|
currency = sa.Column(sa.String(3), default='USD')
|
||||||
|
ledger_account_id = sa.Column(sa.Integer, sa.ForeignKey('accounts.id'))
|
||||||
|
|
||||||
|
ledger_account = relationship('Account')
|
||||||
|
transactions = relationship('BankTransaction', back_populates='bank_account')
|
||||||
|
|
||||||
|
|
||||||
|
class BankTransaction(Base):
|
||||||
|
__tablename__ = 'bank_transactions'
|
||||||
|
|
||||||
|
id = sa.Column(sa.Integer, primary_key=True)
|
||||||
|
bank_account_id = sa.Column(sa.Integer, sa.ForeignKey('bank_accounts.id'), nullable=False)
|
||||||
|
date = sa.Column(sa.Date, nullable=False)
|
||||||
|
amount = sa.Column(sa.Numeric(15, 2), nullable=False)
|
||||||
|
description = sa.Column(sa.String(200))
|
||||||
|
reference = sa.Column(sa.String(100))
|
||||||
|
status = sa.Column(sa.String(20), default='unreconciled') # unreconciled, reconciled
|
||||||
|
journal_entry_id = sa.Column(sa.Integer, sa.ForeignKey('journal_entries.id'))
|
||||||
|
|
||||||
|
bank_account = relationship('BankAccount', back_populates='transactions')
|
||||||
|
journal_entry = relationship('JournalEntry')
|
||||||
|
|
||||||
|
|
||||||
|
class ReconciliationReport(Base):
|
||||||
|
__tablename__ = 'reconciliation_reports'
|
||||||
|
|
||||||
|
id = sa.Column(sa.Integer, primary_key=True)
|
||||||
|
bank_account_id = sa.Column(sa.Integer, sa.ForeignKey('bank_accounts.id'), nullable=False)
|
||||||
|
start_date = sa.Column(sa.Date, nullable=False)
|
||||||
|
end_date = sa.Column(sa.Date, nullable=False)
|
||||||
|
created_at = sa.Column(sa.DateTime, server_default=sa.func.now())
|
||||||
|
status = sa.Column(sa.String(20), default='draft') # draft, completed
|
||||||
|
|
||||||
|
bank_account = relationship('BankAccount')
|
||||||
|
matches = relationship('ReconciliationMatch', back_populates='report')
|
||||||
|
|
||||||
|
|
||||||
|
class ReconciliationMatch(Base):
|
||||||
|
__tablename__ = 'reconciliation_matches'
|
||||||
|
|
||||||
|
id = sa.Column(sa.Integer, primary_key=True)
|
||||||
|
report_id = sa.Column(sa.Integer, sa.ForeignKey('reconciliation_reports.id'), nullable=False)
|
||||||
|
transaction_id = sa.Column(sa.Integer, sa.ForeignKey('bank_transactions.id'), nullable=False)
|
||||||
|
journal_entry_id = sa.Column(sa.Integer, sa.ForeignKey('journal_entries.id'), nullable=False)
|
||||||
|
match_score = sa.Column(sa.Numeric(5, 2)) # 0-100 score for match quality
|
||||||
|
notes = sa.Column(sa.String(200))
|
||||||
|
|
||||||
|
report = relationship('ReconciliationReport', back_populates='matches')
|
||||||
|
transaction = relationship('BankTransaction')
|
||||||
|
journal_entry = relationship('JournalEntry')
|
||||||
588
accounting/templates/index.html
Normal file
588
accounting/templates/index.html
Normal file
@@ -0,0 +1,588 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Accounting System</title>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/vue@2.6.12"></script>
|
||||||
|
<script src="https://unpkg.com/axios/dist/axios.min.js"></script>
|
||||||
|
<style>
|
||||||
|
body { font-family: Arial, sans-serif; margin: 20px; }
|
||||||
|
.section { margin-bottom: 30px; padding: 15px; border: 1px solid #eee; border-radius: 5px; }
|
||||||
|
table { border-collapse: collapse; width: 100%; margin-bottom: 20px; }
|
||||||
|
th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }
|
||||||
|
th { background-color: #f2f2f2; }
|
||||||
|
.tabs { margin-bottom: 20px; }
|
||||||
|
.tab { padding: 10px 15px; cursor: pointer; background: #f1f1f1; display: inline-block; margin-right: 5px; }
|
||||||
|
.tab.active { background: #4CAF50; color: white; }
|
||||||
|
input, select { padding: 8px; margin: 5px 0; width: 200px; }
|
||||||
|
button { padding: 8px 15px; background: #4CAF50; color: white; border: none; cursor: pointer; margin: 5px; }
|
||||||
|
button:hover { background: #45a049; }
|
||||||
|
button:disabled { background: #cccccc; }
|
||||||
|
.file-input { margin: 15px 0; }
|
||||||
|
.score-bar { height: 10px; background-color: #4CAF50; margin: 5px 0; }
|
||||||
|
.match-quality-filters { margin: 15px 0; }
|
||||||
|
.match-quality-filters label { margin-right: 15px; }
|
||||||
|
.report-actions { margin-top: 20px; }
|
||||||
|
.primary { background-color: #2196F3; }
|
||||||
|
.danger { background-color: #f44336; }
|
||||||
|
.bank-section { margin-bottom: 30px; }
|
||||||
|
.form-group { margin-bottom: 15px; }
|
||||||
|
label { display: block; margin-bottom: 5px; font-weight: bold; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app">
|
||||||
|
<h1>Accounting System</h1>
|
||||||
|
|
||||||
|
<div class="tabs">
|
||||||
|
<div class="tab" :class="{active: activeTab === 'accounts'}" @click="activeTab = 'accounts'">Accounts</div>
|
||||||
|
<div class="tab" :class="{active: activeTab === 'trial-balance'}" @click="activeTab = 'trial-balance'">Trial Balance</div>
|
||||||
|
<div class="tab" :class="{active: activeTab === 'banking'}" @click="activeTab = 'banking'">Bank Reconciliation</div>
|
||||||
|
<div class="tab" :class="{active: activeTab === 'reports'}" @click="activeTab = 'reports'">Reports</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Accounts Tab -->
|
||||||
|
<div class="section" v-if="activeTab === 'accounts'">
|
||||||
|
<h2>Accounts</h2>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Code</th>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Balance</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="account in accounts">
|
||||||
|
<td>{{ account.code }}</td>
|
||||||
|
<td>{{ account.name }}</td>
|
||||||
|
<td>{{ account.type }}</td>
|
||||||
|
<td>{{ formatCurrency(account.balance) }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h3>Add New Account</h3>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Account Name</label>
|
||||||
|
<input v-model="newAccount.name" placeholder="Account Name">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Account Code</label>
|
||||||
|
<input v-model="newAccount.code" placeholder="Account Code">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Account Type</label>
|
||||||
|
<select v-model="newAccount.type">
|
||||||
|
<option value="Asset">Asset</option>
|
||||||
|
<option value="Liability">Liability</option>
|
||||||
|
<option value="Equity">Equity</option>
|
||||||
|
<option value="Revenue">Revenue</option>
|
||||||
|
<option value="Expense">Expense</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button @click="addAccount">Add Account</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Trial Balance Tab -->
|
||||||
|
<div class="section" v-if="activeTab === 'trial-balance'">
|
||||||
|
<h2>Trial Balance</h2>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Account</th>
|
||||||
|
<th>Debit</th>
|
||||||
|
<th>Credit</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="item in trialBalance">
|
||||||
|
<td>{{ item.account }}</td>
|
||||||
|
<td>{{ formatCurrency(item.debit) }}</td>
|
||||||
|
<td>{{ formatCurrency(item.credit) }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Banking Tab -->
|
||||||
|
<div class="section" v-if="activeTab === 'banking'">
|
||||||
|
<h2>Bank Reconciliation</h2>
|
||||||
|
|
||||||
|
<div class="bank-section">
|
||||||
|
<h3>Bank Accounts</h3>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Bank</th>
|
||||||
|
<th>Account Number</th>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Currency</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="account in bankAccounts">
|
||||||
|
<td>{{ account.bank_name }}</td>
|
||||||
|
<td>{{ account.account_number }}</td>
|
||||||
|
<td>{{ account.name }}</td>
|
||||||
|
<td>{{ account.currency }}</td>
|
||||||
|
<td>
|
||||||
|
<button @click="viewReconciliation(account.id)">Reconcile</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h3>Add New Bank Account</h3>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Account Name</label>
|
||||||
|
<input v-model="newBankAccount.name" placeholder="Account Name">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Bank Name</label>
|
||||||
|
<input v-model="newBankAccount.bank_name" placeholder="Bank Name">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Account Number</label>
|
||||||
|
<input v-model="newBankAccount.account_number" placeholder="Account Number">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Currency</label>
|
||||||
|
<input v-model="newBankAccount.currency" placeholder="Currency" value="USD">
|
||||||
|
</div>
|
||||||
|
<button @click="addBankAccount">Add Bank Account</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bank-section" v-if="selectedBankAccount">
|
||||||
|
<h3>Import Transactions</h3>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Select Bank Account</label>
|
||||||
|
<select v-model="selectedBankAccount">
|
||||||
|
<option value="">Select Bank Account</option>
|
||||||
|
<option v-for="account in bankAccounts" :value="account.id">
|
||||||
|
{{ account.bank_name }} - {{ account.account_number }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>CSV File</label>
|
||||||
|
<input type="file" @change="handleFileUpload" accept=".csv">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button @click="importTransactions" :disabled="!selectedBankAccount || !fileData">Import</button>
|
||||||
|
|
||||||
|
<div v-if="importResult">
|
||||||
|
<p>Imported {{ importResult.imported.length }} transactions</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bank-section" v-if="selectedBankAccountForReconciliation">
|
||||||
|
<h3>Unreconciled Transactions</h3>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Select Bank Account</label>
|
||||||
|
<select v-model="selectedBankAccountForReconciliation" @change="fetchUnreconciledTransactions">
|
||||||
|
<option value="">Select Bank Account</option>
|
||||||
|
<option v-for="account in bankAccounts" :value="account.id">
|
||||||
|
{{ account.bank_name }} - {{ account.account_number }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table v-if="unreconciledTransactions.length > 0">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Date</th>
|
||||||
|
<th>Description</th>
|
||||||
|
<th>Amount</th>
|
||||||
|
<th>Match With Journal Entry</th>
|
||||||
|
<th>Action</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="txn in unreconciledTransactions">
|
||||||
|
<td>{{ txn.date }}</td>
|
||||||
|
<td>{{ txn.description }}</td>
|
||||||
|
<td>{{ formatCurrency(txn.amount) }}</td>
|
||||||
|
<td>
|
||||||
|
<select v-model="selectedJournalEntries[txn.id]">
|
||||||
|
<option value="">Select matching entry</option>
|
||||||
|
<option v-for="entry in potentialMatches(txn)" :value="entry.id">
|
||||||
|
{{ entry.date }} - {{ entry.description }} ({{ formatCurrency(entry.amount) }})
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button @click="reconcile(txn.id)" :disabled="!selectedJournalEntries[txn.id]">Reconcile</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<p v-else>No unreconciled transactions found</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Reports Tab -->
|
||||||
|
<div class="section" v-if="activeTab === 'reports'">
|
||||||
|
<h2>Reconciliation Reports</h2>
|
||||||
|
|
||||||
|
<div v-if="!activeReport">
|
||||||
|
<h3>Create New Report</h3>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Bank Account</label>
|
||||||
|
<select v-model="newReport.bank_account_id">
|
||||||
|
<option value="">Select Bank Account</option>
|
||||||
|
<option v-for="account in bankAccounts" :value="account.id">
|
||||||
|
{{ account.bank_name }} - {{ account.account_number }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Start Date</label>
|
||||||
|
<input type="date" v-model="newReport.start_date">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>End Date</label>
|
||||||
|
<input type="date" v-model="newReport.end_date">
|
||||||
|
</div>
|
||||||
|
<button @click="createReconciliationReport"
|
||||||
|
:disabled="!newReport.bank_account_id || !newReport.start_date || !newReport.end_date">
|
||||||
|
Generate Report
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<h3>Recent Reports</h3>
|
||||||
|
<table v-if="reconciliationReports.length > 0">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Bank Account</th>
|
||||||
|
<th>Date Range</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Created</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="report in reconciliationReports">
|
||||||
|
<td>{{ getBankAccountName(report.bank_account_id) }}</td>
|
||||||
|
<td>{{ report.start_date }} to {{ report.end_date }}</td>
|
||||||
|
<td>{{ report.status }}</td>
|
||||||
|
<td>{{ report.created_at }}</td>
|
||||||
|
<td>
|
||||||
|
<button @click="loadReport(report.id)">View</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<p v-else>No reconciliation reports found</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="activeReport">
|
||||||
|
<h3>Reconciliation Report - {{ activeReport.start_date }} to {{ activeReport.end_date }}</h3>
|
||||||
|
<p>Bank Account: {{ getBankAccountName(activeReport.bank_account_id) }}</p>
|
||||||
|
<p>Status: {{ activeReport.status }}</p>
|
||||||
|
|
||||||
|
<div class="match-quality-filters">
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" v-model="filterMatches" value="high"> High Confidence (>80%)
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" v-model="filterMatches" value="medium"> Medium Confidence (50-80%)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Bank Transaction</th>
|
||||||
|
<th>Match Score</th>
|
||||||
|
<th>Journal Entry</th>
|
||||||
|
<th>Action</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="match in filteredMatches">
|
||||||
|
<td>
|
||||||
|
{{ match.transaction_date }}<br>
|
||||||
|
{{ match.transaction_description }}<br>
|
||||||
|
<strong>{{ formatCurrency(match.transaction_amount) }}</strong>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="score-bar" :style="{width: match.match_score + '%'}"></div>
|
||||||
|
{{ match.match_score }}%
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{{ match.journal_entry_date }}<br>
|
||||||
|
{{ match.journal_entry_description }}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button @click="acceptMatch(match)">Accept</button>
|
||||||
|
<button @click="rejectMatch(match)" class="danger">Reject</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div class="report-actions">
|
||||||
|
<button @click="finalizeReconciliation" class="primary" v-if="activeReport.status === 'draft'">Finalize Reconciliation</button>
|
||||||
|
<button @click="cancelReconciliation">Back to Reports</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
new Vue({
|
||||||
|
el: '#app',
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
activeTab: 'accounts',
|
||||||
|
accounts: [],
|
||||||
|
trialBalance: [],
|
||||||
|
newAccount: {
|
||||||
|
name: '',
|
||||||
|
code: '',
|
||||||
|
type: 'Asset'
|
||||||
|
},
|
||||||
|
bankAccounts: [],
|
||||||
|
newBankAccount: {
|
||||||
|
name: '',
|
||||||
|
bank_name: '',
|
||||||
|
account_number: '',
|
||||||
|
currency: 'USD'
|
||||||
|
},
|
||||||
|
selectedBankAccount: null,
|
||||||
|
selectedBankAccountForReconciliation: null,
|
||||||
|
fileData: null,
|
||||||
|
importResult: null,
|
||||||
|
unreconciledTransactions: [],
|
||||||
|
selectedJournalEntries: {},
|
||||||
|
journalEntries: [],
|
||||||
|
reconciliationReports: [],
|
||||||
|
newReport: {
|
||||||
|
bank_account_id: null,
|
||||||
|
start_date: '',
|
||||||
|
end_date: ''
|
||||||
|
},
|
||||||
|
activeReport: null,
|
||||||
|
filterMatches: ['high', 'medium']
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
filteredMatches() {
|
||||||
|
if (!this.activeReport) return [];
|
||||||
|
|
||||||
|
return this.activeReport.matches.filter(match => {
|
||||||
|
if (this.filterMatches.includes('high') && match.match_score >= 80) return true;
|
||||||
|
if (this.filterMatches.includes('medium') && match.match_score >= 50 && match.match_score < 80) return true;
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.fetchAccounts();
|
||||||
|
this.fetchTrialBalance();
|
||||||
|
this.fetchBankAccounts();
|
||||||
|
this.fetchJournalEntries();
|
||||||
|
this.fetchReconciliationReports();
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
formatCurrency(value) {
|
||||||
|
if (value === undefined || value === null) return '$0.00';
|
||||||
|
return '$' + parseFloat(value).toFixed(2).replace(/\d(?=(\d{3})+\.)/g, '$&,');
|
||||||
|
},
|
||||||
|
fetchAccounts() {
|
||||||
|
axios.get('/api/accounts')
|
||||||
|
.then(response => {
|
||||||
|
this.accounts = response.data;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
fetchTrialBalance() {
|
||||||
|
axios.get('/api/trial_balance')
|
||||||
|
.then(response => {
|
||||||
|
this.trialBalance = response.data;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
addAccount() {
|
||||||
|
axios.post('/api/add_account', this.newAccount)
|
||||||
|
.then(response => {
|
||||||
|
if (response.data.status === 'success') {
|
||||||
|
this.fetchAccounts();
|
||||||
|
this.newAccount = { name: '', code: '', type: 'Asset' };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
fetchBankAccounts() {
|
||||||
|
axios.get('/api/bank_accounts')
|
||||||
|
.then(response => {
|
||||||
|
this.bankAccounts = response.data;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
addBankAccount() {
|
||||||
|
axios.post('/api/add_bank_account', this.newBankAccount)
|
||||||
|
.then(response => {
|
||||||
|
if (response.data.status === 'success') {
|
||||||
|
this.fetchBankAccounts();
|
||||||
|
this.newBankAccount = { name: '', bank_name: '', account_number: '', currency: 'USD' };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
handleFileUpload(event) {
|
||||||
|
this.fileData = event.target.files[0];
|
||||||
|
},
|
||||||
|
importTransactions() {
|
||||||
|
if (!this.fileData || !this.selectedBankAccount) return;
|
||||||
|
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (e) => {
|
||||||
|
const content = e.target.result;
|
||||||
|
const lines = content.split('\n');
|
||||||
|
const headers = lines[0].split(',').map(h => h.trim());
|
||||||
|
const transactions = [];
|
||||||
|
|
||||||
|
for (let i = 1; i < lines.length; i++) {
|
||||||
|
if (!lines[i].trim()) continue;
|
||||||
|
|
||||||
|
const values = lines[i].split(',');
|
||||||
|
const txn = {};
|
||||||
|
for (let j = 0; j < headers.length; j++) {
|
||||||
|
txn[headers[j]] = values[j] ? values[j].trim() : '';
|
||||||
|
}
|
||||||
|
// Convert amount to number
|
||||||
|
if (txn.amount) {
|
||||||
|
txn.amount = parseFloat(txn.amount);
|
||||||
|
}
|
||||||
|
transactions.push(txn);
|
||||||
|
}
|
||||||
|
|
||||||
|
axios.post('/api/import_bank_transactions', {
|
||||||
|
bank_account_id: this.selectedBankAccount,
|
||||||
|
transactions: transactions
|
||||||
|
}).then(response => {
|
||||||
|
this.importResult = response.data;
|
||||||
|
this.fileData = null;
|
||||||
|
document.querySelector('input[type="file"]').value = '';
|
||||||
|
if (this.selectedBankAccount === this.selectedBankAccountForReconciliation) {
|
||||||
|
this.fetchUnreconciledTransactions();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
reader.readAsText(this.fileData);
|
||||||
|
},
|
||||||
|
fetchUnreconciledTransactions() {
|
||||||
|
if (this.selectedBankAccountForReconciliation) {
|
||||||
|
axios.get(`/api/get_unreconciled_transactions?bank_account_id=${this.selectedBankAccountForReconciliation}`)
|
||||||
|
.then(response => {
|
||||||
|
this.unreconciledTransactions = response.data;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
fetchJournalEntries() {
|
||||||
|
axios.get('/api/journal_entries')
|
||||||
|
.then(response => {
|
||||||
|
this.journalEntries = response.data.map(entry => {
|
||||||
|
const debitLines = entry.lines.filter(line => line.is_debit);
|
||||||
|
const creditLines = entry.lines.filter(line => !line.is_debit);
|
||||||
|
return {
|
||||||
|
id: entry.id,
|
||||||
|
date: entry.date,
|
||||||
|
description: entry.description,
|
||||||
|
amount: debitLines.reduce((sum, line) => sum + line.amount, 0),
|
||||||
|
lines: entry.lines
|
||||||
|
};
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
potentialMatches(txn) {
|
||||||
|
// Simple matching by amount and date
|
||||||
|
const txnDate = new Date(txn.date);
|
||||||
|
return this.journalEntries.filter(entry => {
|
||||||
|
const entryDate = new Date(entry.date);
|
||||||
|
return Math.abs(entry.amount - txn.amount) < 0.01 &&
|
||||||
|
txnDate.toDateString() === entryDate.toDateString();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
reconcile(txnId) {
|
||||||
|
const journalEntryId = this.selectedJournalEntries[txnId];
|
||||||
|
if (!journalEntryId) return;
|
||||||
|
|
||||||
|
axios.post('/api/reconcile_transaction', {
|
||||||
|
transaction_id: txnId,
|
||||||
|
journal_entry_id: journalEntryId
|
||||||
|
}).then(response => {
|
||||||
|
if (response.data.status === 'success') {
|
||||||
|
this.fetchUnreconciledTransactions();
|
||||||
|
this.selectedJournalEntries[txnId] = null;
|
||||||
|
} else {
|
||||||
|
alert(response.data.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
fetchReconciliationReports() {
|
||||||
|
axios.get('/api/reconciliation_reports')
|
||||||
|
.then(response => {
|
||||||
|
this.reconciliationReports = response.data;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
createReconciliationReport() {
|
||||||
|
axios.post('/api/create_reconciliation_report', this.newReport)
|
||||||
|
.then(response => {
|
||||||
|
if (response.data.status === 'success') {
|
||||||
|
this.loadReport(response.data.report_id);
|
||||||
|
this.fetchReconciliationReports();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
loadReport(reportId) {
|
||||||
|
axios.get(`/api/get_reconciliation_report?report_id=${reportId}`)
|
||||||
|
.then(response => {
|
||||||
|
if (response.data.status === 'success') {
|
||||||
|
this.activeReport = response.data.report;
|
||||||
|
this.activeTab = 'reports';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
acceptMatch(match) {
|
||||||
|
axios.post('/api/reconcile_transaction', {
|
||||||
|
transaction_id: match.transaction_id,
|
||||||
|
journal_entry_id: match.journal_entry_id
|
||||||
|
}).then(response => {
|
||||||
|
if (response.data.status === 'success') {
|
||||||
|
this.loadReport(this.activeReport.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
rejectMatch(match) {
|
||||||
|
axios.delete(`/api/reconciliation_match/${match.id}`)
|
||||||
|
.then(() => {
|
||||||
|
this.loadReport(this.activeReport.id);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
finalizeReconciliation() {
|
||||||
|
axios.post('/api/finalize_reconciliation', {
|
||||||
|
report_id: this.activeReport.id
|
||||||
|
}).then(response => {
|
||||||
|
if (response.data.status === 'success') {
|
||||||
|
this.activeReport = null;
|
||||||
|
this.fetchReconciliationReports();
|
||||||
|
this.fetchUnreconciledTransactions();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
cancelReconciliation() {
|
||||||
|
this.activeReport = null;
|
||||||
|
},
|
||||||
|
getBankAccountName(accountId) {
|
||||||
|
const account = this.bankAccounts.find(a => a.id === accountId);
|
||||||
|
return account ? `${account.bank_name} - ${account.account_number}` : '';
|
||||||
|
},
|
||||||
|
viewReconciliation(accountId) {
|
||||||
|
this.selectedBankAccountForReconciliation = accountId;
|
||||||
|
this.fetchUnreconciledTransactions();
|
||||||
|
this.activeTab = 'banking';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
2
config.py
Normal file
2
config.py
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
# Configuration settings
|
||||||
|
DATABASE_URI = "sqlite:///accounting.db"
|
||||||
85
server.py
Normal file
85
server.py
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import cherrypy
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from accounting.models import Base
|
||||||
|
from accounting.api import AccountingAPI
|
||||||
|
import os
|
||||||
|
from config import DATABASE_URI
|
||||||
|
|
||||||
|
|
||||||
|
def CORS():
|
||||||
|
cherrypy.response.headers["Access-Control-Allow-Origin"] = "*"
|
||||||
|
cherrypy.response.headers["Access-Control-Allow-Methods"] = "GET, POST, PUT, DELETE, OPTIONS"
|
||||||
|
cherrypy.response.headers["Access-Control-Allow-Headers"] = "Content-Type"
|
||||||
|
|
||||||
|
|
||||||
|
class Root:
|
||||||
|
@cherrypy.expose
|
||||||
|
def index(self):
|
||||||
|
return open(os.path.join(os.path.dirname(__file__), 'accounting/templates/index.html')).read()
|
||||||
|
|
||||||
|
@cherrypy.expose
|
||||||
|
def favicon_ico(self):
|
||||||
|
return cherrypy.lib.static.serve_file(
|
||||||
|
os.path.join(os.path.dirname(__file__), 'static/favicon.ico'),
|
||||||
|
content_type='image/x-icon'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def setup_database():
|
||||||
|
engine = create_engine(DATABASE_URI)
|
||||||
|
Base.metadata.create_all(engine)
|
||||||
|
return engine
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
# Database setup
|
||||||
|
db_engine = setup_database()
|
||||||
|
|
||||||
|
# Create static directory if it doesn't exist
|
||||||
|
static_path = os.path.join(os.path.dirname(__file__), 'static')
|
||||||
|
if not os.path.exists(static_path):
|
||||||
|
os.makedirs(static_path)
|
||||||
|
|
||||||
|
# CherryPy configuration
|
||||||
|
conf = {
|
||||||
|
'/': {
|
||||||
|
'tools.sessions.on': True,
|
||||||
|
'tools.staticdir.root': os.path.abspath(os.path.dirname(__file__)),
|
||||||
|
'tools.CORS.on': True
|
||||||
|
},
|
||||||
|
'/static': {
|
||||||
|
'tools.staticdir.on': True,
|
||||||
|
'tools.staticdir.dir': 'static',
|
||||||
|
},
|
||||||
|
'/api': {
|
||||||
|
'request.dispatch': cherrypy.dispatch.MethodDispatcher(),
|
||||||
|
'tools.CORS.on': True,
|
||||||
|
'tools.response_headers.on': True,
|
||||||
|
'tools.response_headers.headers': [('Content-Type', 'application/json')],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Register CORS tool
|
||||||
|
cherrypy.tools.CORS = cherrypy.Tool('before_handler', CORS)
|
||||||
|
|
||||||
|
# Create application
|
||||||
|
root = Root()
|
||||||
|
root.api = AccountingAPI(db_engine)
|
||||||
|
|
||||||
|
cherrypy.tree.mount(root, '/', conf)
|
||||||
|
|
||||||
|
# Start server
|
||||||
|
cherrypy.config.update({
|
||||||
|
'server.socket_host': '0.0.0.0',
|
||||||
|
'server.socket_port': 8080,
|
||||||
|
'log.screen': True,
|
||||||
|
'engine.autoreload.on': True
|
||||||
|
})
|
||||||
|
|
||||||
|
print("Starting accounting system...")
|
||||||
|
cherrypy.engine.start()
|
||||||
|
cherrypy.engine.block()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user