You've already forked double-entry-accounting
Initial project
This commit is contained in:
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>
|
||||
Reference in New Issue
Block a user