You've already forked double-entry-accounting
Compare commits
4 Commits
5340430298
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 29877c2a28 | |||
| e36c554a70 | |||
| 5046ac67f1 | |||
| a2942f6d7d |
107
.gitignore
vendored
Normal file
107
.gitignore
vendored
Normal file
@@ -0,0 +1,107 @@
|
||||
.venv
|
||||
*.db
|
||||
.idea/**/workspace.xml
|
||||
.idea/**/tasks.xml
|
||||
.idea/**/usage.statistics.xml
|
||||
.idea/**/dictionaries
|
||||
.idea/**/shelf
|
||||
.idea/**/aws.xml
|
||||
.idea/**/contentModel.xml
|
||||
.idea/**/dataSources/
|
||||
.idea/**/dataSources.ids
|
||||
.idea/**/dataSources.local.xml
|
||||
.idea/**/sqlDataSources.xml
|
||||
.idea/**/dynamic.xml
|
||||
.idea/**/uiDesigner.xml
|
||||
.idea/**/dbnavigator.xml
|
||||
.idea/**/gradle.xml
|
||||
.idea/**/libraries
|
||||
cmake-build-*/
|
||||
.idea/**/mongoSettings.xml
|
||||
*.iws
|
||||
out/
|
||||
.idea_modules/
|
||||
atlassian-ide-plugin.xml
|
||||
.idea/replstate.xml
|
||||
.idea/sonarlint/
|
||||
com_crashlytics_export_strings.xml
|
||||
crashlytics.properties
|
||||
crashlytics-build.properties
|
||||
fabric.properties
|
||||
.idea/httpRequests
|
||||
.idea/caches/build_file_checksums.ser
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
share/python-wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
*.manifest
|
||||
*.spec
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
*.py,cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
cover/
|
||||
*.mo
|
||||
*.pot
|
||||
*.log
|
||||
local_settings.py
|
||||
db.sqlite3
|
||||
db.sqlite3-journal
|
||||
instance/
|
||||
.webassets-cache
|
||||
.scrapy
|
||||
docs/_build/
|
||||
.pybuilder/
|
||||
target/
|
||||
.ipynb_checkpoints
|
||||
profile_default/
|
||||
ipython_config.py
|
||||
.pdm.toml
|
||||
__pypackages__/
|
||||
celerybeat-schedule
|
||||
celerybeat.pid
|
||||
*.sage.py
|
||||
.env
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
.spyderproject
|
||||
.spyproject
|
||||
.ropeproject
|
||||
/site
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
.pyre/
|
||||
.pytype/
|
||||
cython_debug/
|
||||
8
.idea/.gitignore
generated
vendored
Normal file
8
.idea/.gitignore
generated
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
# Default ignored files
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
# Editor-based HTTP Client requests
|
||||
/httpRequests/
|
||||
# Datasource local storage ignored files
|
||||
/dataSources/
|
||||
/dataSources.local.xml
|
||||
20
.idea/dataSources.xml
generated
Normal file
20
.idea/dataSources.xml
generated
Normal file
@@ -0,0 +1,20 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
|
||||
<data-source source="LOCAL" name="accounting" uuid="1ada57dc-35b7-4cdf-8ed9-5577b0dcb588">
|
||||
<driver-ref>sqlite.xerial</driver-ref>
|
||||
<synchronize>true</synchronize>
|
||||
<jdbc-driver>org.sqlite.JDBC</jdbc-driver>
|
||||
<jdbc-url>jdbc:sqlite:$PROJECT_DIR$/accounting.db</jdbc-url>
|
||||
<working-dir>$ProjectFileDir$</working-dir>
|
||||
<libraries>
|
||||
<library>
|
||||
<url>file://$APPLICATION_CONFIG_DIR$/jdbc-drivers/Xerial SQLiteJDBC/3.45.1/org/xerial/sqlite-jdbc/3.45.1.0/sqlite-jdbc-3.45.1.0.jar</url>
|
||||
</library>
|
||||
<library>
|
||||
<url>file://$APPLICATION_CONFIG_DIR$/jdbc-drivers/Xerial SQLiteJDBC/3.45.1/org/slf4j/slf4j-api/1.7.36/slf4j-api-1.7.36.jar</url>
|
||||
</library>
|
||||
</libraries>
|
||||
</data-source>
|
||||
</component>
|
||||
</project>
|
||||
10
.idea/double-entry-accounting.iml
generated
Normal file
10
.idea/double-entry-accounting.iml
generated
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<module type="PYTHON_MODULE" version="4">
|
||||
<component name="NewModuleRootManager">
|
||||
<content url="file://$MODULE_DIR$">
|
||||
<excludeFolder url="file://$MODULE_DIR$/.venv" />
|
||||
</content>
|
||||
<orderEntry type="jdk" jdkName="Python 3.11 (double-entry-accounting)" jdkType="Python SDK" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
</component>
|
||||
</module>
|
||||
15
.idea/git_toolbox_prj.xml
generated
Normal file
15
.idea/git_toolbox_prj.xml
generated
Normal file
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="GitToolBoxProjectSettings">
|
||||
<option name="commitMessageIssueKeyValidationOverride">
|
||||
<BoolValueOverride>
|
||||
<option name="enabled" value="true" />
|
||||
</BoolValueOverride>
|
||||
</option>
|
||||
<option name="commitMessageValidationEnabledOverride">
|
||||
<BoolValueOverride>
|
||||
<option name="enabled" value="true" />
|
||||
</BoolValueOverride>
|
||||
</option>
|
||||
</component>
|
||||
</project>
|
||||
39
.idea/inspectionProfiles/Project_Default.xml
generated
Normal file
39
.idea/inspectionProfiles/Project_Default.xml
generated
Normal file
@@ -0,0 +1,39 @@
|
||||
<component name="InspectionProjectProfileManager">
|
||||
<profile version="1.0">
|
||||
<option name="myName" value="Project Default" />
|
||||
<inspection_tool class="PyBroadExceptionInspection" enabled="false" level="WEAK WARNING" enabled_by_default="false" />
|
||||
<inspection_tool class="PyGlobalUndefinedInspection" enabled="false" level="WEAK WARNING" enabled_by_default="false" />
|
||||
<inspection_tool class="PyPep8Inspection" enabled="true" level="WEAK WARNING" enabled_by_default="true">
|
||||
<option name="ignoredErrors">
|
||||
<list>
|
||||
<option value="E722" />
|
||||
</list>
|
||||
</option>
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PyPep8NamingInspection" enabled="true" level="WEAK WARNING" enabled_by_default="true">
|
||||
<option name="ignoredErrors">
|
||||
<list>
|
||||
<option value="N802" />
|
||||
<option value="N813" />
|
||||
<option value="N806" />
|
||||
</list>
|
||||
</option>
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PyTypeCheckerInspection" enabled="false" level="WARNING" enabled_by_default="false" />
|
||||
<inspection_tool class="PyUnboundLocalVariableInspection" enabled="false" level="WARNING" enabled_by_default="false" />
|
||||
<inspection_tool class="PyUnresolvedReferencesInspection" enabled="true" level="WARNING" enabled_by_default="true">
|
||||
<option name="ignoredIdentifiers">
|
||||
<list>
|
||||
<option value="property.*" />
|
||||
</list>
|
||||
</option>
|
||||
</inspection_tool>
|
||||
<inspection_tool class="SpellCheckingInspection" enabled="false" level="TYPO" enabled_by_default="false">
|
||||
<option name="processCode" value="true" />
|
||||
<option name="processLiterals" value="true" />
|
||||
<option name="processComments" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="SqlDialectInspection" enabled="false" level="WARNING" enabled_by_default="false" />
|
||||
<inspection_tool class="SqlNoDataSourceInspection" enabled="false" level="WARNING" enabled_by_default="false" />
|
||||
</profile>
|
||||
</component>
|
||||
6
.idea/inspectionProfiles/profiles_settings.xml
generated
Normal file
6
.idea/inspectionProfiles/profiles_settings.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
<component name="InspectionProjectProfileManager">
|
||||
<settings>
|
||||
<option name="USE_PROJECT_PROFILE" value="false" />
|
||||
<version value="1.0" />
|
||||
</settings>
|
||||
</component>
|
||||
6
.idea/misc.xml
generated
Normal file
6
.idea/misc.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="Black">
|
||||
<option name="sdkName" value="Python 3.11 (double-entry-accounting)" />
|
||||
</component>
|
||||
</project>
|
||||
8
.idea/modules.xml
generated
Normal file
8
.idea/modules.xml
generated
Normal file
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ProjectModuleManager">
|
||||
<modules>
|
||||
<module fileurl="file://$PROJECT_DIR$/.idea/double-entry-accounting.iml" filepath="$PROJECT_DIR$/.idea/double-entry-accounting.iml" />
|
||||
</modules>
|
||||
</component>
|
||||
</project>
|
||||
6
.idea/vcs.xml
generated
Normal file
6
.idea/vcs.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
Binary file not shown.
BIN
accounting.db
BIN
accounting.db
Binary file not shown.
@@ -1,161 +1,416 @@
|
||||
import cherrypy
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from datetime import datetime
|
||||
from accounting.models import Account, BankAccount, BankTransaction, JournalEntry, JournalEntryLine, ReconciliationReport, \
|
||||
from accounting.models import Account, BankAccount, BankTransaction, JournalEntry, JournalEntryLine, \
|
||||
ReconciliationReport, \
|
||||
ReconciliationMatch
|
||||
import simplejson as json
|
||||
|
||||
|
||||
@cherrypy.tools.json_out()
|
||||
class AccountingAPI:
|
||||
exposed = True
|
||||
|
||||
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):
|
||||
# Mount resource handlers as attributes - these become URL segments
|
||||
self.accounts = AccountHandler(self.session)
|
||||
self.bank_accounts = BankAccountHandler(self.session)
|
||||
self.journal_entries = JournalEntryHandler(self.session)
|
||||
|
||||
def GET(self):
|
||||
"""Handle GET requests to the API root"""
|
||||
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)
|
||||
def OPTIONS(self):
|
||||
"""Handle OPTIONS requests (CORS preflight) to the API root"""
|
||||
cherrypy.response.headers['Allow'] = 'GET, OPTIONS'
|
||||
return ""
|
||||
|
||||
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)
|
||||
@cherrypy.tools.json_out()
|
||||
class AccountHandler:
|
||||
exposed = True
|
||||
|
||||
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}
|
||||
def __init__(self, session):
|
||||
self.session = session
|
||||
|
||||
# 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 ''
|
||||
def GET(self, account_id=None):
|
||||
"""
|
||||
GET /api/accounts - List all accounts
|
||||
GET /api/accounts/123 - Get specific account
|
||||
"""
|
||||
if account_id is not None:
|
||||
# Get specific account
|
||||
account = self.session.query(Account).get(account_id)
|
||||
if not account:
|
||||
raise cherrypy.HTTPError(404, "Account not found")
|
||||
|
||||
# Accounts Endpoints
|
||||
@cherrypy.expose
|
||||
@cherrypy.tools.json_out()
|
||||
def accounts(self):
|
||||
if cherrypy.request.method != 'GET':
|
||||
raise cherrypy.HTTPError(405)
|
||||
return {
|
||||
'id': account.id,
|
||||
'name': account.name,
|
||||
'code': account.code,
|
||||
'type': account.account_type,
|
||||
'balance': float(account.balance) if account.balance else 0
|
||||
}
|
||||
else:
|
||||
# List all accounts
|
||||
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]
|
||||
|
||||
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']
|
||||
def POST(self):
|
||||
"""Handle POST to create a new account"""
|
||||
try:
|
||||
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(line)
|
||||
self.session.add(account)
|
||||
self.session.commit()
|
||||
return {'status': 'success', 'id': account.id}
|
||||
except Exception as e:
|
||||
self.session.rollback()
|
||||
raise cherrypy.HTTPError(400, str(e))
|
||||
|
||||
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']
|
||||
def PUT(self, account_id):
|
||||
"""Handle PUT to update an existing account"""
|
||||
try:
|
||||
account = self.session.query(Account).get(account_id)
|
||||
if not account:
|
||||
raise cherrypy.HTTPError(404, "Account not found")
|
||||
|
||||
self.session.commit()
|
||||
return {'status': 'success', 'id': entry.id}
|
||||
data = cherrypy.request.json
|
||||
if 'name' in data:
|
||||
account.name = data['name']
|
||||
if 'type' in data:
|
||||
account.account_type = data['type']
|
||||
if 'code' in data:
|
||||
account.code = data['code']
|
||||
if 'parent_id' in data:
|
||||
account.parent_id = data['parent_id']
|
||||
|
||||
@cherrypy.expose
|
||||
@cherrypy.tools.json_out()
|
||||
def journal_entries(self):
|
||||
if cherrypy.request.method != 'GET':
|
||||
raise cherrypy.HTTPError(405)
|
||||
self.session.commit()
|
||||
return {'status': 'success', 'id': account.id}
|
||||
except Exception as e:
|
||||
self.session.rollback()
|
||||
raise cherrypy.HTTPError(400, str(e))
|
||||
|
||||
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]
|
||||
def DELETE(self, account_id):
|
||||
"""Handle DELETE to remove an account"""
|
||||
try:
|
||||
account = self.session.query(Account).get(account_id)
|
||||
if not account:
|
||||
raise cherrypy.HTTPError(404, "Account not found")
|
||||
|
||||
# Add default OPTIONS handler for all endpoints
|
||||
@cherrypy.expose
|
||||
def default(self, *args, **kwargs):
|
||||
if cherrypy.request.method == 'OPTIONS':
|
||||
# Check for dependencies before deleting
|
||||
if self.session.query(JournalEntryLine).filter_by(account_id=account_id).count() > 0:
|
||||
return {'status': 'error', 'message': 'Cannot delete account with journal entries'}
|
||||
|
||||
self.session.delete(account)
|
||||
self.session.commit()
|
||||
return {'status': 'success'}
|
||||
except Exception as e:
|
||||
self.session.rollback()
|
||||
raise cherrypy.HTTPError(400, str(e))
|
||||
|
||||
def OPTIONS(self, account_id=None):
|
||||
"""Handle OPTIONS requests (CORS preflight)"""
|
||||
if account_id is not None:
|
||||
cherrypy.response.headers['Allow'] = 'GET, PUT, DELETE, OPTIONS'
|
||||
else:
|
||||
cherrypy.response.headers['Allow'] = 'GET, POST, OPTIONS'
|
||||
cherrypy.response.headers['Access-Control-Allow-Headers'] = 'Content-Type'
|
||||
return ''
|
||||
raise cherrypy.HTTPError(404)
|
||||
return ""
|
||||
|
||||
|
||||
@cherrypy.tools.json_out()
|
||||
class BankAccountHandler:
|
||||
exposed = True
|
||||
|
||||
def __init__(self, session):
|
||||
self.session = session
|
||||
# Sub-resource for transactions
|
||||
self.transactions = BankTransactionHandler(session)
|
||||
|
||||
def GET(self, bank_account_id=None):
|
||||
"""
|
||||
GET /api/bank_accounts - List all bank accounts
|
||||
GET /api/bank_accounts/123 - Get specific bank account
|
||||
"""
|
||||
if bank_account_id is not None:
|
||||
# Get specific bank account
|
||||
account = self.session.query(BankAccount).get(bank_account_id)
|
||||
if not account:
|
||||
raise cherrypy.HTTPError(404, "Bank account not found")
|
||||
data = {
|
||||
'id': account.id,
|
||||
'name': account.name,
|
||||
'bank_name': account.bank_name,
|
||||
'account_number': account.account_number,
|
||||
'currency': account.currency
|
||||
}
|
||||
return data
|
||||
else:
|
||||
# List all bank accounts
|
||||
accounts = self.session.query(BankAccount).all()
|
||||
data = [{
|
||||
'id': a.id,
|
||||
'name': a.name,
|
||||
'bank_name': a.bank_name,
|
||||
'account_number': a.account_number,
|
||||
'currency': a.currency
|
||||
} for a in accounts]
|
||||
return data
|
||||
|
||||
def POST(self):
|
||||
"""Handle POST to create a new bank account"""
|
||||
try:
|
||||
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}
|
||||
except Exception as e:
|
||||
self.session.rollback()
|
||||
raise cherrypy.HTTPError(400, str(e))
|
||||
|
||||
def PUT(self, bank_account_id):
|
||||
"""Handle PUT to update a bank account"""
|
||||
try:
|
||||
account = self.session.query(BankAccount).get(bank_account_id)
|
||||
if not account:
|
||||
raise cherrypy.HTTPError(404, "Bank account not found")
|
||||
data = cherrypy.request.json
|
||||
if 'name' in data:
|
||||
account.name = data['name']
|
||||
if 'bank_name' in data:
|
||||
account.bank_name = data['bank_name']
|
||||
if 'account_number' in data:
|
||||
account.account_number = data['account_number']
|
||||
if 'currency' in data:
|
||||
account.currency = data['currency']
|
||||
self.session.commit()
|
||||
return {'status': 'success', 'id': account.id}
|
||||
except Exception as e:
|
||||
self.session.rollback()
|
||||
raise cherrypy.HTTPError(400, str(e))
|
||||
|
||||
def DELETE(self, bank_account_id):
|
||||
"""Handle DELETE to remove a bank account"""
|
||||
try:
|
||||
account = self.session.query(BankAccount).get(bank_account_id)
|
||||
if not account:
|
||||
raise cherrypy.HTTPError(404, "Bank account not found")
|
||||
# Check for dependencies before deleting
|
||||
if self.session.query(BankTransaction).filter_by(bank_account_id=bank_account_id).count() > 0:
|
||||
return {'status': 'error', 'message': 'Cannot delete bank account with transactions'}
|
||||
self.session.delete(account)
|
||||
self.session.commit()
|
||||
return {'status': 'success'}
|
||||
except Exception as e:
|
||||
self.session.rollback()
|
||||
raise cherrypy.HTTPError(400, str(e))
|
||||
|
||||
def OPTIONS(self, bank_account_id=None):
|
||||
"""Handle OPTIONS requests (CORS preflight)"""
|
||||
if bank_account_id is not None:
|
||||
cherrypy.response.headers['Allow'] = 'GET, PUT, DELETE, OPTIONS'
|
||||
else:
|
||||
cherrypy.response.headers['Allow'] = 'GET, POST, OPTIONS'
|
||||
return ""
|
||||
|
||||
|
||||
class BankTransactionHandler:
|
||||
exposed = True
|
||||
|
||||
def __init__(self, session):
|
||||
self.session = session
|
||||
|
||||
def GET(self, bank_account_id, transaction_id=None):
|
||||
"""
|
||||
GET /api/bank_accounts/123/transactions - List all transactions for bank account
|
||||
GET /api/bank_accounts/123/transactions/456 - Get specific transaction
|
||||
"""
|
||||
if not self.session.query(BankAccount).get(bank_account_id):
|
||||
raise cherrypy.HTTPError(404, "Bank account not found")
|
||||
|
||||
if transaction_id is not None:
|
||||
# Get specific transaction
|
||||
transaction = self.session.query(BankTransaction).get(transaction_id)
|
||||
if not transaction or transaction.bank_account_id != int(bank_account_id):
|
||||
raise cherrypy.HTTPError(404, "Transaction not found")
|
||||
|
||||
return {
|
||||
'id': transaction.id,
|
||||
'date': transaction.date.isoformat(),
|
||||
'description': transaction.description,
|
||||
'amount': float(transaction.amount),
|
||||
'bank_account_id': transaction.bank_account_id
|
||||
}
|
||||
else:
|
||||
# List all transactions for the bank account
|
||||
transactions = self.session.query(BankTransaction).filter_by(bank_account_id=bank_account_id).all()
|
||||
return [{
|
||||
'id': t.id,
|
||||
'date': t.date.isoformat(),
|
||||
'description': t.description,
|
||||
'amount': float(t.amount),
|
||||
'bank_account_id': t.bank_account_id
|
||||
} for t in transactions]
|
||||
|
||||
def POST(self, bank_account_id):
|
||||
"""Handle POST to create a new transaction"""
|
||||
try:
|
||||
if not self.session.query(BankAccount).get(bank_account_id):
|
||||
raise cherrypy.HTTPError(404, "Bank account not found")
|
||||
|
||||
data = cherrypy.request.json
|
||||
transaction = BankTransaction(
|
||||
bank_account_id=bank_account_id,
|
||||
date=datetime.strptime(data['date'], '%Y-%m-%d').date(),
|
||||
description=data['description'],
|
||||
amount=data['amount']
|
||||
)
|
||||
self.session.add(transaction)
|
||||
self.session.commit()
|
||||
return {'status': 'success', 'id': transaction.id}
|
||||
except Exception as e:
|
||||
self.session.rollback()
|
||||
raise cherrypy.HTTPError(400, str(e))
|
||||
|
||||
def OPTIONS(self, bank_account_id, transaction_id=None):
|
||||
"""Handle OPTIONS requests (CORS preflight)"""
|
||||
if transaction_id is not None:
|
||||
cherrypy.response.headers['Allow'] = 'GET, OPTIONS'
|
||||
else:
|
||||
cherrypy.response.headers['Allow'] = 'GET, POST, OPTIONS'
|
||||
return ""
|
||||
|
||||
|
||||
@cherrypy.tools.json_out()
|
||||
class JournalEntryHandler:
|
||||
exposed = True
|
||||
|
||||
def __init__(self, session):
|
||||
self.session = session
|
||||
|
||||
def GET(self, entry_id=None):
|
||||
"""
|
||||
GET /api/journal_entries - List all journal entries
|
||||
GET /api/journal_entries/123 - Get specific journal entry
|
||||
"""
|
||||
if entry_id is not None:
|
||||
# Get specific journal entry
|
||||
entry = self.session.query(JournalEntry).get(entry_id)
|
||||
if not entry:
|
||||
raise cherrypy.HTTPError(404, "Journal entry not found")
|
||||
|
||||
return {
|
||||
'id': entry.id,
|
||||
'date': entry.date.isoformat(),
|
||||
'description': entry.description,
|
||||
'reference': entry.reference,
|
||||
'lines': [{
|
||||
'account_id': l.account_id,
|
||||
'amount': float(l.amount),
|
||||
'is_debit': l.is_debit
|
||||
} for l in entry.lines]
|
||||
}
|
||||
else:
|
||||
# List all journal entries
|
||||
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]
|
||||
|
||||
def POST(self):
|
||||
"""Handle POST to create a new journal entry"""
|
||||
try:
|
||||
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}
|
||||
except Exception as e:
|
||||
self.session.rollback()
|
||||
raise cherrypy.HTTPError(400, str(e))
|
||||
|
||||
def DELETE(self, entry_id):
|
||||
"""Handle DELETE to remove a journal entry"""
|
||||
try:
|
||||
entry = self.session.query(JournalEntry).get(entry_id)
|
||||
if not entry:
|
||||
raise cherrypy.HTTPError(404, "Journal entry not found")
|
||||
|
||||
# Reverse the effect on account balances
|
||||
for line in entry.lines:
|
||||
account = self.session.query(Account).get(line.account_id)
|
||||
if line.is_debit:
|
||||
account.balance -= line.amount
|
||||
else:
|
||||
account.balance += line.amount
|
||||
|
||||
# Delete the journal entry and its lines (cascade should handle this)
|
||||
self.session.delete(entry)
|
||||
self.session.commit()
|
||||
return {'status': 'success'}
|
||||
except Exception as e:
|
||||
self.session.rollback()
|
||||
raise cherrypy.HTTPError(400, str(e))
|
||||
|
||||
def OPTIONS(self, entry_id=None):
|
||||
"""Handle OPTIONS requests (CORS preflight)"""
|
||||
if entry_id is not None:
|
||||
cherrypy.response.headers['Allow'] = 'GET, DELETE, OPTIONS'
|
||||
else:
|
||||
cherrypy.response.headers['Allow'] = 'GET, POST, OPTIONS'
|
||||
return ""
|
||||
|
||||
@@ -404,7 +404,7 @@
|
||||
});
|
||||
},
|
||||
addAccount() {
|
||||
axios.post('/api/add_account', this.newAccount)
|
||||
axios.post('/api/accounts', this.newAccount)
|
||||
.then(response => {
|
||||
if (response.data.status === 'success') {
|
||||
this.fetchAccounts();
|
||||
@@ -419,7 +419,7 @@
|
||||
});
|
||||
},
|
||||
addBankAccount() {
|
||||
axios.post('/api/add_bank_account', this.newBankAccount)
|
||||
axios.post('/api/bank_accounts', this.newBankAccount)
|
||||
.then(response => {
|
||||
if (response.data.status === 'success') {
|
||||
this.fetchBankAccounts();
|
||||
@@ -455,8 +455,7 @@
|
||||
transactions.push(txn);
|
||||
}
|
||||
|
||||
axios.post('/api/import_bank_transactions', {
|
||||
bank_account_id: this.selectedBankAccount,
|
||||
axios.post(`/api/bank_accounts/${this.selectedBankAccount}/transactions`, {
|
||||
transactions: transactions
|
||||
}).then(response => {
|
||||
this.importResult = response.data;
|
||||
@@ -471,7 +470,7 @@
|
||||
},
|
||||
fetchUnreconciledTransactions() {
|
||||
if (this.selectedBankAccountForReconciliation) {
|
||||
axios.get(`/api/get_unreconciled_transactions?bank_account_id=${this.selectedBankAccountForReconciliation}`)
|
||||
axios.get(`/api/bank_accounts/${this.selectedBankAccountForReconciliation}/unreconciled`)
|
||||
.then(response => {
|
||||
this.unreconciledTransactions = response.data;
|
||||
});
|
||||
@@ -506,7 +505,7 @@
|
||||
const journalEntryId = this.selectedJournalEntries[txnId];
|
||||
if (!journalEntryId) return;
|
||||
|
||||
axios.post('/api/reconcile_transaction', {
|
||||
axios.post('/api/reconciliation', {
|
||||
transaction_id: txnId,
|
||||
journal_entry_id: journalEntryId
|
||||
}).then(response => {
|
||||
@@ -519,13 +518,13 @@
|
||||
});
|
||||
},
|
||||
fetchReconciliationReports() {
|
||||
axios.get('/api/reconciliation_reports')
|
||||
axios.get('/api/reconciliation/reports')
|
||||
.then(response => {
|
||||
this.reconciliationReports = response.data;
|
||||
});
|
||||
},
|
||||
createReconciliationReport() {
|
||||
axios.post('/api/create_reconciliation_report', this.newReport)
|
||||
axios.post('/api/reconciliation/reports', this.newReport)
|
||||
.then(response => {
|
||||
if (response.data.status === 'success') {
|
||||
this.loadReport(response.data.report_id);
|
||||
@@ -534,7 +533,7 @@
|
||||
});
|
||||
},
|
||||
loadReport(reportId) {
|
||||
axios.get(`/api/get_reconciliation_report?report_id=${reportId}`)
|
||||
axios.get(`/api/reconciliation/reports/${reportId}`)
|
||||
.then(response => {
|
||||
if (response.data.status === 'success') {
|
||||
this.activeReport = response.data.report;
|
||||
@@ -543,7 +542,7 @@
|
||||
});
|
||||
},
|
||||
acceptMatch(match) {
|
||||
axios.post('/api/reconcile_transaction', {
|
||||
axios.post('/api/reconciliation', {
|
||||
transaction_id: match.transaction_id,
|
||||
journal_entry_id: match.journal_entry_id
|
||||
}).then(response => {
|
||||
@@ -553,21 +552,20 @@
|
||||
});
|
||||
},
|
||||
rejectMatch(match) {
|
||||
axios.delete(`/api/reconciliation_match/${match.id}`)
|
||||
axios.delete(`/api/reconciliation/matches/${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();
|
||||
}
|
||||
});
|
||||
axios.post(`/api/reconciliation/reports/${this.activeReport.id}/finalize`)
|
||||
.then(response => {
|
||||
if (response.data.status === 'success') {
|
||||
this.activeReport = null;
|
||||
this.fetchReconciliationReports();
|
||||
this.fetchUnreconciledTransactions();
|
||||
}
|
||||
});
|
||||
},
|
||||
cancelReconciliation() {
|
||||
this.activeReport = null;
|
||||
|
||||
50
server.py
50
server.py
@@ -3,6 +3,7 @@ from sqlalchemy import create_engine
|
||||
from accounting.models import Base
|
||||
from accounting.api import AccountingAPI
|
||||
import os
|
||||
import json
|
||||
from config import DATABASE_URI
|
||||
|
||||
|
||||
@@ -12,6 +13,16 @@ def CORS():
|
||||
cherrypy.response.headers["Access-Control-Allow-Headers"] = "Content-Type"
|
||||
|
||||
|
||||
# JSON Tools for response serialization
|
||||
class JSONEncoder(object):
|
||||
def __init__(self):
|
||||
self.json_encoder = json.JSONEncoder()
|
||||
|
||||
def __call__(self, value):
|
||||
# Convert the Python object to a JSON string then to bytes
|
||||
return json.dumps(value).encode('utf-8')
|
||||
|
||||
|
||||
class Root:
|
||||
@cherrypy.expose
|
||||
def index(self):
|
||||
@@ -40,8 +51,11 @@ def main():
|
||||
if not os.path.exists(static_path):
|
||||
os.makedirs(static_path)
|
||||
|
||||
# CherryPy configuration
|
||||
conf = {
|
||||
# Register tools
|
||||
cherrypy.tools.CORS = cherrypy.Tool('before_handler', CORS)
|
||||
|
||||
# Root application config
|
||||
root_conf = {
|
||||
'/': {
|
||||
'tools.sessions.on': True,
|
||||
'tools.staticdir.root': os.path.abspath(os.path.dirname(__file__)),
|
||||
@@ -50,23 +64,31 @@ def main():
|
||||
'/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)
|
||||
# API application config
|
||||
api_conf = {
|
||||
'/': {
|
||||
'request.dispatch': cherrypy.dispatch.MethodDispatcher(),
|
||||
'tools.CORS.on': True,
|
||||
'tools.json_out.on': True, # Use the built-in JSON serializer
|
||||
'tools.encode.on': True,
|
||||
'tools.encode.encoding': 'utf-8',
|
||||
# Process JSON request bodies
|
||||
'request.body.processors': {
|
||||
'application/json': cherrypy.lib.jsontools.json_processor
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Create application
|
||||
# Create and mount Root application
|
||||
root = Root()
|
||||
root.api = AccountingAPI(db_engine)
|
||||
cherrypy.tree.mount(root, '/', root_conf)
|
||||
|
||||
cherrypy.tree.mount(root, '/', conf)
|
||||
# Create and mount API as a separate application
|
||||
api = AccountingAPI(db_engine)
|
||||
cherrypy.tree.mount(api, '/api', api_conf)
|
||||
|
||||
# Start server
|
||||
cherrypy.config.update({
|
||||
@@ -82,4 +104,4 @@ def main():
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
main()
|
||||
|
||||
Reference in New Issue
Block a user