commit 53404302988ab0a932a9c9de1e4a814cbbfbf781 Author: Aaron Guise Date: Thu May 8 16:12:33 2025 +1200 Initial project diff --git a/__pycache__/config.cpython-311.pyc b/__pycache__/config.cpython-311.pyc new file mode 100644 index 0000000..d96951d Binary files /dev/null and b/__pycache__/config.cpython-311.pyc differ diff --git a/accounting.db b/accounting.db new file mode 100644 index 0000000..3be996b Binary files /dev/null and b/accounting.db differ diff --git a/accounting/__init__.py b/accounting/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/accounting/__pycache__/__init__.cpython-311.pyc b/accounting/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..1fe5bd8 Binary files /dev/null and b/accounting/__pycache__/__init__.cpython-311.pyc differ diff --git a/accounting/__pycache__/api.cpython-311.pyc b/accounting/__pycache__/api.cpython-311.pyc new file mode 100644 index 0000000..3363c6e Binary files /dev/null and b/accounting/__pycache__/api.cpython-311.pyc differ diff --git a/accounting/__pycache__/models.cpython-311.pyc b/accounting/__pycache__/models.cpython-311.pyc new file mode 100644 index 0000000..b9d9b71 Binary files /dev/null and b/accounting/__pycache__/models.cpython-311.pyc differ diff --git a/accounting/api.py b/accounting/api.py new file mode 100644 index 0000000..0aa5b61 --- /dev/null +++ b/accounting/api.py @@ -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) \ No newline at end of file diff --git a/accounting/models.py b/accounting/models.py new file mode 100644 index 0000000..e1a8f21 --- /dev/null +++ b/accounting/models.py @@ -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"" + + +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') \ No newline at end of file diff --git a/accounting/templates/index.html b/accounting/templates/index.html new file mode 100644 index 0000000..ab6aeaf --- /dev/null +++ b/accounting/templates/index.html @@ -0,0 +1,588 @@ + + + + Accounting System + + + + + +
+

Accounting System

+ +
+
Accounts
+
Trial Balance
+
Bank Reconciliation
+
Reports
+
+ + +
+

Accounts

+ + + + + + + + + + + + + + + + + +
CodeNameTypeBalance
{{ account.code }}{{ account.name }}{{ account.type }}{{ formatCurrency(account.balance) }}
+ +

Add New Account

+
+ + +
+
+ + +
+
+ + +
+ +
+ + +
+

Trial Balance

+ + + + + + + + + + + + + + + +
AccountDebitCredit
{{ item.account }}{{ formatCurrency(item.debit) }}{{ formatCurrency(item.credit) }}
+
+ + +
+

Bank Reconciliation

+ +
+

Bank Accounts

+ + + + + + + + + + + + + + + + + + + +
BankAccount NumberNameCurrencyActions
{{ account.bank_name }}{{ account.account_number }}{{ account.name }}{{ account.currency }} + +
+ +

Add New Bank Account

+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ +
+

Import Transactions

+
+ + +
+ +
+ + +
+ + + +
+

Imported {{ importResult.imported.length }} transactions

+
+
+ +
+

Unreconciled Transactions

+
+ + +
+ + + + + + + + + + + + + + + + + + + + +
DateDescriptionAmountMatch With Journal EntryAction
{{ txn.date }}{{ txn.description }}{{ formatCurrency(txn.amount) }} + + + +
+

No unreconciled transactions found

+
+
+ + +
+

Reconciliation Reports

+ +
+

Create New Report

+
+ + +
+
+ + +
+
+ + +
+ + +

Recent Reports

+ + + + + + + + + + + + + + + + + + + +
Bank AccountDate RangeStatusCreatedActions
{{ getBankAccountName(report.bank_account_id) }}{{ report.start_date }} to {{ report.end_date }}{{ report.status }}{{ report.created_at }} + +
+

No reconciliation reports found

+
+ +
+

Reconciliation Report - {{ activeReport.start_date }} to {{ activeReport.end_date }}

+

Bank Account: {{ getBankAccountName(activeReport.bank_account_id) }}

+

Status: {{ activeReport.status }}

+ +
+ + +
+ + + + + + + + + + + + + + + + + + +
Bank TransactionMatch ScoreJournal EntryAction
+ {{ match.transaction_date }}
+ {{ match.transaction_description }}
+ {{ formatCurrency(match.transaction_amount) }} +
+
+ {{ match.match_score }}% +
+ {{ match.journal_entry_date }}
+ {{ match.journal_entry_description }} +
+ + +
+ +
+ + +
+
+
+
+ + + + \ No newline at end of file diff --git a/config.py b/config.py new file mode 100644 index 0000000..caaf8b6 --- /dev/null +++ b/config.py @@ -0,0 +1,2 @@ +# Configuration settings +DATABASE_URI = "sqlite:///accounting.db" \ No newline at end of file diff --git a/server.py b/server.py new file mode 100644 index 0000000..987a21e --- /dev/null +++ b/server.py @@ -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() \ No newline at end of file