diff --git a/.claudeignore b/.claudeignore new file mode 100644 index 0000000..3fa8aee --- /dev/null +++ b/.claudeignore @@ -0,0 +1,15 @@ + +dashboard/ +log/ +dist/ +venv/ +node_modules/ +docs/ +uploads/ +workdir/ +.env +pytest.ini +general_ledger/data +general_ledger/scripts +general_ledger/static + diff --git a/.gitignore b/.gitignore index 5df24ca..b6bc96c 100644 --- a/.gitignore +++ b/.gitignore @@ -4,9 +4,13 @@ mysite/settings/prod.py mysite/settings/dev.py general_ledger/data +general_ledger/guff .env.* -.env /run +stuff +guff +/general_ledger/scripts/ofxparse1.py +/uploads/ # Django # *.log @@ -128,13 +132,6 @@ venv.bak/ # mypy .mypy_cache/ -# Sublime Text # -*.tmlanguage.cache -*.tmPreferences.cache -*.stTheme.cache -*.sublime-workspace -*.sublime-project - # sftp configuration file sftp-config.json @@ -152,5 +149,3 @@ GitHub.sublime-settings !.vscode/launch.json !.vscode/extensions.json .history -/general_ledger/scripts/ofxparse1.py -/uploads/ diff --git a/dashboard/dynamic_preferences_registry.py b/dashboard/dynamic_preferences_registry.py index f10cfe0..aab9fb0 100644 --- a/dashboard/dynamic_preferences_registry.py +++ b/dashboard/dynamic_preferences_registry.py @@ -1,8 +1,18 @@ +import json +from collections import defaultdict + from dynamic_preferences.preferences import Section from dynamic_preferences.registries import ( global_preferences_registry, ) -from dynamic_preferences.types import BooleanPreference, StringPreference, IntegerPreference +from dynamic_preferences.serializers import BaseSerializer +from dynamic_preferences.types import ( + BooleanPreference, + StringPreference, + IntegerPreference, + LongStringPreference, + BasePreferenceType, +) from dynamic_preferences.users.registries import user_preferences_registry from .registries import book_preferences_registry @@ -11,6 +21,7 @@ discussion = Section("discussion") access = Section("access") debugging = Section("debugging") +layout = Section("layout") # We start with a global preference @@ -37,6 +48,7 @@ class CommentNotificationsEnabled(BooleanPreference): name = "comment_notifications_enabled" default = True + @user_preferences_registry.register class DebugLevel(IntegerPreference): """ @@ -48,6 +60,92 @@ class DebugLevel(IntegerPreference): default = 0 +string_types = str +from django.template import defaultfilters + + +class GridstackLayoutPreferenceSerializer(BaseSerializer): + @classmethod + def to_db(cls, value, **kwargs): + # print("calling to_db in serializer") + if not isinstance(value, string_types): + raise cls.exception( + "Cannot serialize, value {0} is not a string".format(value) + ) + # print(f"value is '{value}'") + if value == "": + return "" + + data = json.loads(value) + filtered = { + x["id"]: { + k: v + for (k, v) in x.items() + if k + in [ + "w", + "h", + "x", + "y", + "minH", + "minW", + ] + } + for x in data + if x.get("id") + } + for foo in filtered: + if not "w" in filtered[foo]: + filtered[foo]["w"] = filtered[foo]["minW"] + + out = json.dumps(filtered, indent=2) + + # print("out is ", out) + if kwargs.get("escape_html", False): + return defaultfilters.force_escape(out) + else: + return out + + @classmethod + def to_python(cls, value, **kwargs): + # print("calling to_python in serializer ") + # print(f"value is '{value}'") + if not value: + return defaultdict(dict) + try: + return json.loads(value) + except: + raise cls.exception("Cannot deserialize value {0} to json".format(value)) + + +class GridstackLayoutPreference(LongStringPreference): + section = layout + name = "dashboard_layout_json" + verbose_name = "GridStack Layout Configuration" + serializer = GridstackLayoutPreferenceSerializer + + # def serialize(self, value): + # """Convert dict to JSON string for storage""" + # if isinstance(value, str): + # return value + # return json.dumps(value) + # + # def deserialize(self, value): + # """Convert stored JSON string back to dict""" + # try: + # return json.loads(value) + # except json.JSONDecodeError: + # return {} + + +@user_preferences_registry.register +class DashboardLayout(GridstackLayoutPreference): + section = layout + name = "dashboard_layout_json" + default = "" + required = False + + @book_preferences_registry.register class IsPublic(BooleanPreference): section = access @@ -59,3 +157,41 @@ class IsPublic(BooleanPreference): class MaintenanceMode(BooleanPreference): name = "maintenance_mode" default = False + + +# @book_preferences_registry.register +# class DashboardLayout(LongStringPreference): +# name = "dashboard_layout_json" +# default = "" +# required = False + +# class GridstackLayoutPreference(PerInstancePreferenceType): +# """ +# Stores gridstack layout configuration as JSON string +# """ +# section = dashboard +# name = 'gridstack_layout' +# verbose_name = 'Gridstack Layout Configuration' +# +# default = '{}' # Empty JSON object as default +# +# def validate(self, value): +# """Ensure the value is valid JSON""" +# try: +# json.loads(value) +# return True +# except json.JSONDecodeError: +# return False +# +# def serialize(self, value): +# """Convert dict to JSON string for storage""" +# if isinstance(value, str): +# return value +# return json.dumps(value) +# +# def deserialize(self, value): +# """Convert stored JSON string back to dict""" +# try: +# return json.loads(value) +# except json.JSONDecodeError: +# return {} diff --git a/dashboard/migrations/0001_initial.py b/dashboard/migrations/0001_initial.py index 4b5b7f9..b648ae6 100644 --- a/dashboard/migrations/0001_initial.py +++ b/dashboard/migrations/0001_initial.py @@ -1,6 +1,5 @@ -# Generated by Django 5.1.1 on 2024-10-04 02:46 +# Generated by Django 5.1.1 on 2024-11-04 22:44 -import django.db.models.deletion from django.db import migrations, models @@ -8,9 +7,7 @@ class Migration(migrations.Migration): initial = True - dependencies = [ - ("general_ledger", "0001_squashed_0007_bankbalance_balance_type"), - ] + dependencies = [] operations = [ migrations.CreateModel( @@ -46,13 +43,6 @@ class Migration(migrations.Migration): "raw_value", models.TextField(blank=True, null=True, verbose_name="Raw Value"), ), - ( - "instance", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - to="general_ledger.book", - ), - ), ], ), ] diff --git a/dashboard/migrations/0002_initial.py b/dashboard/migrations/0002_initial.py new file mode 100644 index 0000000..b21338b --- /dev/null +++ b/dashboard/migrations/0002_initial.py @@ -0,0 +1,24 @@ +# Generated by Django 5.1.1 on 2024-11-04 22:44 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("dashboard", "0001_initial"), + ("general_ledger", "0001_initial"), + ] + + operations = [ + migrations.AddField( + model_name="bookpreferencemodel", + name="instance", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="general_ledger.book" + ), + ), + ] diff --git a/dashboard/models/book_preferences.py b/dashboard/models/book_preferences.py index 87fae59..427c232 100644 --- a/dashboard/models/book_preferences.py +++ b/dashboard/models/book_preferences.py @@ -1,7 +1,7 @@ from django.db import models from dynamic_preferences.models import PerInstancePreferenceModel -from general_ledger.models import Book +from general_ledger.django.models import Book class BookPreferenceModel(PerInstancePreferenceModel): diff --git a/dashboard/settings.py b/dashboard/settings.py index d296f53..be079c9 100644 --- a/dashboard/settings.py +++ b/dashboard/settings.py @@ -5,6 +5,9 @@ import graypy from django.contrib.messages import constants as messages from loguru import logger +import re +from django.utils.translation import gettext as _ +from general_ledger.utils.utility import bool_colorize # Build paths inside the project like this: BASE_DIR / 'subdir'. BASE_DIR = Path(__file__).resolve().parent.parent @@ -29,30 +32,74 @@ "general_ledger.helpers": "DEBUG", "general_ledger.helpers.book": "INFO", "general_ledger.helpers.invoice": "DEBUG", - "general_ledger.models.transaction": "INFO", + "general_ledger.django.models.transaction": "INFO", "general_ledger.managers.transaction_entry": "DEBUG", + "general_ledger.statements": "DEBUG", + "general_ledger.utils.utility": "DEBUG", } +def boolval(record): + """Custom markup handler for boolean values""" + return ( + record["message"] + .replace("True", "True") + .replace("False", "False") + ) + + +# logger = logger.patch(boolval) + +max_line_no = 3 +max_file_name = 0 + + +def custom_formatter(record): + global max_line_no, max_file_name + # print(f"record is {record}") + # message = record["message"] + # message = re.sub(r"(True|False)", bool_colorize, message) + max_line_no = max(max_line_no, len(str(record["line"]))) + max_file_name = max(max_file_name, len(record["file"].name)) + message_format = "" + message_format += ( + "{time:HH:mm:ss} " + "{level: <5} " + "{file.name: >{max_file_name}}:{line:<{max_line_no}} " + ) + message_format += "{message}" + # if "extra" in record and record["extra"]: + # message_format += " | {extra}" + record["extra"].update({"max_line_no": max_line_no, "max_file_name": max_file_name}) + record["max_line_no"] = max_line_no + record["max_file_name"] = max_file_name + return message_format + "\n" + + logger.add( - "log/loguru.log", + sys.stderr, colorize=True, level="TRACE", - format="{time:HH:mm:ss} " - "{level: <5} |{module}|{name}|" - "{file.name}:{line} {message}", + filter=filter_dict, + # format="{time:HH:mm:ss} " + # "{level: <5} " + # "{file.name}:{line} {message}" + # " | {extra}", + format=custom_formatter, ) + logger.add( - sys.stderr, + "log/loguru.log", colorize=True, level="TRACE", - filter=filter_dict, - format="{time:HH:mm:ss} " - "{level: <5} |" - "{file.name}:{line} {message}", + format="{time:HH:mm:ss} " + "{level: <5} |{module}|" + "{file.name}:{line} {message} | {extra}", + retention="3 days", ) + # logger.add(handler, serialize=True) # logger.add( # handler, @@ -84,7 +131,7 @@ "disable_existing_loggers": False, "root": { # "handlers": ["file", "console"], - "handlers": ["file", "richconsole"], + "handlers": ["file", "console"], "level": "DEBUG", }, "loggers": { @@ -216,7 +263,7 @@ "drf_spectacular", "allauth", "allauth.account", - "allauth.socialaccount", + # "allauth.socialaccount", "formset", # "notifications", "import_export", @@ -307,7 +354,7 @@ # Internationalization # https://docs.djangoproject.com/en/5.1/topics/i18n/ -LANGUAGE_CODE = "en-us" +LANGUAGE_CODE = "en-gb" TIME_ZONE = "UTC" diff --git a/dashboard/views/book_preference_viewset.py b/dashboard/views/book_preference_viewset.py new file mode 100644 index 0000000..b98dd2e --- /dev/null +++ b/dashboard/views/book_preference_viewset.py @@ -0,0 +1,38 @@ +from dynamic_preferences.api.viewsets import PerInstancePreferenceViewSet +from rest_framework import permissions + +from dynamic_preferences.api import viewsets +from django.db import models + +from dashboard.models import BookPreferenceModel +from general_ledger.django.models import Book + + +# class UserPreferencesViewSet(viewsets.PerInstancePreferenceViewSet): +# queryset = models.UserPreferenceModel.objects.all() +# serializer_class = serializers.UserPreferenceSerializer +# permission_classes = [permissions.IsAuthenticated] +# +# def get_related_instance(self): +# return self.request.user + + +class BookPreferenceViewSet(viewsets.PerInstancePreferenceViewSet): + queryset = BookPreferenceModel.objects.all() + + # + # # def get_queryset(self): + # # return ( + # # super(PerInstancePreferenceViewSet, self) + # # .get_queryset() + # # .filter(instance=self.get_related_instance()) + # # ) + # # + # # def get_related_instance(self): + # # return self.request.user + # + def get_related_instance(self): + """Override this to the instance bound to the preferences""" + lookup_url_kwarg = self.lookup_url_kwarg or self.lookup_field + print(f"lookup_url_kwarg: {lookup_url_kwarg}") + Book.objects.get(pk=self.kwargs[lookup_url_kwarg]) diff --git a/docs/requirements.txt b/docs/requirements.txt index 4923817..951bfb9 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,3 +1,4 @@ furo==2024.8.6 django>=5 Sphinx==8.0.2 +sphinxcontrib-mermaid==1.0.0 diff --git a/docs/source/conf.py b/docs/source/conf.py index ea25efb..b97e251 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -19,7 +19,6 @@ project = 'Lime Pepper General Ledger' copyright = '2024, Tom Hodder' author = 'Tom Hodder' -release = '0.0.1' # -- General configuration --------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration @@ -28,6 +27,7 @@ 'sphinx.ext.autodoc', 'sphinx.ext.napoleon', 'sphinx.ext.viewcode', + 'sphinxcontrib.mermaid', ] templates_path = ['_templates'] diff --git a/general_ledger/__init__.py b/general_ledger/__init__.py index b133ac3..1c153e7 100644 --- a/general_ledger/__init__.py +++ b/general_ledger/__init__.py @@ -1,3 +1,6 @@ default_app_config = "general_ledger.apps.GeneralLedgerConfig" VERSION = 0, 0, 1 -__version__ = '.'.join(map(str, VERSION)) +__version__ = ".".join(map(str, VERSION)) + +if __name__ == "__main__": + print(__version__) diff --git a/general_ledger/admin/account.py b/general_ledger/admin/account.py index 7a90be9..fd9643d 100644 --- a/general_ledger/admin/account.py +++ b/general_ledger/admin/account.py @@ -3,7 +3,7 @@ from django.contrib import admin from import_export.admin import ImportExportModelAdmin -from general_ledger.models import Account, AccountType, TaxRate, ChartOfAccounts +from general_ledger.django.models import Account, AccountType, TaxRate, ChartOfAccounts from general_ledger.resources import AccountResource from general_ledger.resources.account import AccountResourceSimple from general_ledger.utils import update_items diff --git a/general_ledger/admin/account_type.py b/general_ledger/admin/account_type.py index 1386fd7..02f64ba 100644 --- a/general_ledger/admin/account_type.py +++ b/general_ledger/admin/account_type.py @@ -1,7 +1,7 @@ from django.contrib import admin from import_export.admin import ImportExportModelAdmin -from general_ledger.models import AccountType +from general_ledger.django.models import AccountType from general_ledger.resources.account_type import AccountTypeResource diff --git a/general_ledger/admin/bank.py b/general_ledger/admin/bank.py index 7db7f03..d9a9dd5 100644 --- a/general_ledger/admin/bank.py +++ b/general_ledger/admin/bank.py @@ -4,7 +4,7 @@ from django.utils.html import format_html from import_export.admin import ImportExportModelAdmin -from general_ledger.models import Bank +from general_ledger.django.models import Bank from general_ledger.utils import update_items diff --git a/general_ledger/admin/bank_balance.py b/general_ledger/admin/bank_balance.py index 48e1144..a740609 100644 --- a/general_ledger/admin/bank_balance.py +++ b/general_ledger/admin/bank_balance.py @@ -1,7 +1,7 @@ from django.contrib import admin from import_export.admin import ImportExportModelAdmin -from general_ledger.models import BankBalance +from general_ledger.django.models import BankBalance @admin.register(BankBalance) diff --git a/general_ledger/admin/bank_statement_line.py b/general_ledger/admin/bank_statement_line.py index 54c16ea..a609788 100644 --- a/general_ledger/admin/bank_statement_line.py +++ b/general_ledger/admin/bank_statement_line.py @@ -1,7 +1,7 @@ from django.contrib import admin from import_export.admin import ImportExportModelAdmin -from general_ledger.models import BankStatementLine +from general_ledger.django.models import BankStatementLine @admin.register(BankStatementLine) diff --git a/general_ledger/admin/book.py b/general_ledger/admin/book.py index 9ba3dc1..41bb394 100644 --- a/general_ledger/admin/book.py +++ b/general_ledger/admin/book.py @@ -3,7 +3,7 @@ from django.shortcuts import redirect, render from django.urls import path from django.contrib.auth import get_user_model -from general_ledger.models import Book, Ledger +from general_ledger.django.models import Book, Ledger from general_ledger.resources import TaxTypeResource diff --git a/general_ledger/admin/coa.py b/general_ledger/admin/coa.py index 02a24d8..680c909 100644 --- a/general_ledger/admin/coa.py +++ b/general_ledger/admin/coa.py @@ -5,7 +5,7 @@ from django.forms import TextInput from django.utils.html import format_html -from general_ledger.models import ChartOfAccounts, Account +from general_ledger.django.models import ChartOfAccounts, Account from general_ledger.utils import update_items diff --git a/general_ledger/admin/contact.py b/general_ledger/admin/contact.py index beab0a6..28497a7 100644 --- a/general_ledger/admin/contact.py +++ b/general_ledger/admin/contact.py @@ -1,6 +1,6 @@ from django.contrib import admin -from general_ledger.models import Contact +from general_ledger.django.models import Contact @admin.register(Contact) diff --git a/general_ledger/admin/file_upload.py b/general_ledger/admin/file_upload.py index ce7960f..9a70cca 100644 --- a/general_ledger/admin/file_upload.py +++ b/general_ledger/admin/file_upload.py @@ -1,7 +1,7 @@ from django import forms from django.contrib import admin -from general_ledger.models.file_upload import FileUpload +from general_ledger.django.models.file_upload import FileUpload from general_ledger.utils import update_items @@ -17,6 +17,7 @@ class FileUploadAdmin(admin.ModelAdmin): actions = [ update_items, ] + class Meta: model = FileUpload fields = "__all__" diff --git a/general_ledger/admin/invoice.py b/general_ledger/admin/invoice.py index d58fe59..ad34b75 100644 --- a/general_ledger/admin/invoice.py +++ b/general_ledger/admin/invoice.py @@ -6,8 +6,8 @@ from django.utils.html import format_html from import_export.admin import ImportExportModelAdmin -from general_ledger.models import Invoice, InvoiceLine -from general_ledger.models.invoice_transaction import InvoiceTransaction +from general_ledger.django.models import Invoice, InvoiceLine +from general_ledger.django.models.invoice_transaction import InvoiceTransaction from general_ledger.utils import update_items diff --git a/general_ledger/admin/ledger.py b/general_ledger/admin/ledger.py index b70392f..a3f4d10 100644 --- a/general_ledger/admin/ledger.py +++ b/general_ledger/admin/ledger.py @@ -3,7 +3,7 @@ from django.shortcuts import render, redirect from django.urls import path -from general_ledger.models import Ledger +from general_ledger.django.models import Ledger from general_ledger.resources.transaction import TransactionResource from general_ledger.utils import update_items diff --git a/general_ledger/admin/payment.py b/general_ledger/admin/payment.py index 2c64920..d259cad 100644 --- a/general_ledger/admin/payment.py +++ b/general_ledger/admin/payment.py @@ -3,7 +3,7 @@ from django.contrib import admin from django.utils.html import format_html -from general_ledger.models import Bank, Payment, Payment, PaymentItem +from general_ledger.django.models import Bank, Payment, Payment, PaymentItem from general_ledger.utils import update_items diff --git a/general_ledger/admin/payment_item.py b/general_ledger/admin/payment_item.py index 2a13b53..8c5b0f7 100644 --- a/general_ledger/admin/payment_item.py +++ b/general_ledger/admin/payment_item.py @@ -3,7 +3,7 @@ from django.contrib import admin from django.utils.html import format_html -from general_ledger.models import Bank, Payment, Payment, PaymentItem +from general_ledger.django.models import Bank, Payment, Payment, PaymentItem from general_ledger.utils import update_items diff --git a/general_ledger/admin/payment_transaction.py b/general_ledger/admin/payment_transaction.py index e6e1541..1af6f4e 100644 --- a/general_ledger/admin/payment_transaction.py +++ b/general_ledger/admin/payment_transaction.py @@ -5,8 +5,8 @@ from django.utils.html import format_html from import_export.admin import ImportExportModelAdmin -from general_ledger.models import Invoice, InvoiceLine, PaymentTransaction -from general_ledger.models.invoice_transaction import InvoiceTransaction +from general_ledger.django.models import Invoice, InvoiceLine, PaymentTransaction +from general_ledger.django.models.invoice_transaction import InvoiceTransaction from general_ledger.utils import update_items diff --git a/general_ledger/admin/tax_rate.py b/general_ledger/admin/tax_rate.py index fd3d702..51961a1 100644 --- a/general_ledger/admin/tax_rate.py +++ b/general_ledger/admin/tax_rate.py @@ -1,7 +1,7 @@ from django.contrib import admin from import_export.admin import ImportExportModelAdmin -from general_ledger.models import Book, Ledger, TaxRate +from general_ledger.django.models import Book, Ledger, TaxRate from general_ledger.resources import TaxRateResource from general_ledger.utils import update_items diff --git a/general_ledger/admin/tax_type.py b/general_ledger/admin/tax_type.py index 895f24b..4ccad2d 100644 --- a/general_ledger/admin/tax_type.py +++ b/general_ledger/admin/tax_type.py @@ -1,7 +1,7 @@ from django.contrib import admin from import_export.admin import ImportExportModelAdmin -from general_ledger.models import TaxType +from general_ledger.django.models import TaxType from general_ledger.resources import TaxTypeResource from general_ledger.utils import update_items from general_ledger.utils import PrettyYAML diff --git a/general_ledger/admin/transaction.py b/general_ledger/admin/transaction.py index 558b565..524c65d 100644 --- a/general_ledger/admin/transaction.py +++ b/general_ledger/admin/transaction.py @@ -4,7 +4,7 @@ from django.http import HttpResponse from general_ledger.forms.transaction import TransactionLedgerImportForm -from general_ledger.models import Transaction, Entry +from general_ledger.django.models import Transaction, Entry from general_ledger.resources.transaction import TransactionResource from import_export.admin import ImportExportActionModelAdmin, ImportExportModelAdmin diff --git a/general_ledger/admin/xero_gl_import.py b/general_ledger/admin/xero_gl_import.py index 1690a78..e582076 100644 --- a/general_ledger/admin/xero_gl_import.py +++ b/general_ledger/admin/xero_gl_import.py @@ -1,12 +1,10 @@ from django.contrib import admin -from general_ledger.models.xero_gl_import import XeroGlImport +from general_ledger.django.models.xero_gl_import import XeroGlImport -from import_export import resources from import_export.admin import ImportExportModelAdmin -from import_export.fields import Field -from general_ledger.resources.xero_gl_import import XeroGlImportResource, OtherResource +from general_ledger.resources.xero_gl_import import XeroGlImportResource @admin.register(XeroGlImport) diff --git a/general_ledger/builders/README.md b/general_ledger/builders/README.md new file mode 100644 index 0000000..25a2863 --- /dev/null +++ b/general_ledger/builders/README.md @@ -0,0 +1,8 @@ +# builders + +the idea of the builder pattern is to allow the progressive construction of complex objects. the idea is that you can have a pattern which can be used to produce a variety of representations of the same object. + +Here, they are mostly being used (misused?) to wrap the django create method with convenience methods, and to allow creation of objects complete with all their related objects. + +The builder object generally implements any atomic methods. using a builder also tries to avoid making customizations to the `save(...)` methods of the models. + diff --git a/general_ledger/builders/__init__.py b/general_ledger/builders/__init__.py index d3159bc..e69de29 100644 --- a/general_ledger/builders/__init__.py +++ b/general_ledger/builders/__init__.py @@ -1,2 +0,0 @@ -from .transaction import TransactionBuilder -from .book import BookBuilder diff --git a/general_ledger/builders/account_set_summary_builder.py b/general_ledger/builders/account_set_summary_builder.py new file mode 100644 index 0000000..35ead8c --- /dev/null +++ b/general_ledger/builders/account_set_summary_builder.py @@ -0,0 +1,47 @@ +from typing import List + +from general_ledger.builders.account_summary_builder import AccountSummary +from general_ledger.builders.mixins import StartEndBuilderMixin +from general_ledger.django.models.ledger import Ledger +from general_ledger.statements.account_summary_set import AccountSetSummary + + +class AccountSetSummaryBuilder( + StartEndBuilderMixin, +): + """Builder for summary sets + + Args: + ledger (Ledger): The ledger to which all this belongs + summary_set (List[AccountSummary]: this of account summaries + """ + + def __init__(self, **kwargs): + """ + Initialize the builder + """ + # print(f"AccountSetSummaryBuilder kwargs: {kwargs}") + super().__init__(**kwargs) + for key, value in kwargs.items(): + setattr(self, key, value) + + ledger: Ledger = None + summary_set: List[AccountSummary] = None + caption = None + title = None + currency = None + + def with_summary_set(self, summary_set): + self.summary_set = summary_set + return self + + def build(self): + super().build() + if not self.summary_set: + raise ValueError("Summary set is required") + + return AccountSetSummary( + start_date=self.start_date, + end_date=self.end_date, + summary_set=self.summary_set, + ) diff --git a/general_ledger/builders/account_summary_builder.py b/general_ledger/builders/account_summary_builder.py new file mode 100644 index 0000000..21a3322 --- /dev/null +++ b/general_ledger/builders/account_summary_builder.py @@ -0,0 +1,105 @@ +from typing import Optional + +from general_ledger.builders.mixins import StartEndBuilderMixin +from general_ledger.managers.transaction_entry import EntryQuerySet +from general_ledger.django.models.account import Account +from general_ledger.django.models.ledger import Ledger +from general_ledger.django.models.transaction_entry import Entry +from general_ledger.statements.account_summary import AccountSummary + + +# @rich.repr.auto +class AccountSummaryBuilder( + StartEndBuilderMixin, +): + """ + This is a view over entries, grouped by interval, usually from an account and ledger. + However, it can summarize any set of entries which is sometimes useful + + Summaries are produced by interval, and can be filtered by date range. + this object is intended to be used to produce balanced off accounts + representations for T-accounts and trial balances. + """ + + def __init__(self, **kwargs): + """ + Initialize the builder + """ + super().__init__(**kwargs) + self.ledger: Optional[Ledger] = None + self.account: Optional[Account] = None + self.entry_set: Optional[EntryQuerySet] = None + self.balance_interval = None # which periods to calculate balances for + self.caption = None + self.title = None + self.currency = None + self.group_intervals = None + self.final_balance = None + + def with_ledger(self, ledger): + self.ledger = ledger + return self + + def with_account(self, account): + self.account = account + return self + + def with_entry_set(self, entry_set: EntryQuerySet): + self.entry_set = entry_set + return self + + def with_balance_interval(self, balance_interval): + self.balance_interval = balance_interval + return self + + def with_details(self, title, caption, currency): + self.title = title + self.caption = caption + self.currency = currency + return self + + def with_by_group_intervals(self, group_intervals): + self.group_intervals = group_intervals + return self + + def with_final_balance(self, final_balance=True): + self.final_balance = final_balance + return self + + def build(self): + super().build() + if self.entry_set: + entries = self.entry_set + elif self.ledger and self.account: + entries = Entry.objects.filter( + transaction__ledger=self.ledger, + account=self.account, + ) + else: + raise ValueError("Either (ledger and account) or entry_set are required") + + title = ( + self.title + if self.title + else (self.account.name if self.account else "placeholder") + ) + caption = self.caption if self.caption else None + currency = self.currency or ( + self.account.currency + if self.account + else (entries.first().account.currency if entries.first() else "GBP") + ) + group_intervals = ( + self.group_intervals if self.group_intervals is not None else ["year"] + ) + return AccountSummary( + entries=entries, + balance_interval=self.balance_interval, + start_date=self.start_date, + end_date=self.end_date, + title=title, + caption=caption, + currency=currency, + group_intervals=group_intervals, + final_balance=self.final_balance, + ) diff --git a/general_ledger/builders/balance_sheet.py b/general_ledger/builders/balance_sheet.py index 0f04e72..d3056e0 100644 --- a/general_ledger/builders/balance_sheet.py +++ b/general_ledger/builders/balance_sheet.py @@ -1,9 +1,7 @@ from datetime import date -from general_ledger.utils.balance_sheet import BalanceSheet - -class BalanceSheetBuilder(): +class BalanceSheetBuilder: def __init__( self, *, @@ -21,4 +19,4 @@ def set_date(self, date): return self def build(self): - return BalanceSheet(self.ledger) \ No newline at end of file + return None diff --git a/general_ledger/builders/book.py b/general_ledger/builders/book.py index 727eda8..0e65c0e 100644 --- a/general_ledger/builders/book.py +++ b/general_ledger/builders/book.py @@ -2,7 +2,8 @@ from abc import ABC, abstractmethod from typing import List from django.contrib.auth import get_user_model -from general_ledger.models import Transaction, Book +from general_ledger.django.models.transaction import Transaction +from general_ledger.django.models.book import Book class BookBuilderAbstract(ABC): diff --git a/general_ledger/builders/invoice_builder.py b/general_ledger/builders/invoice_builder.py index d17755d..5d70203 100644 --- a/general_ledger/builders/invoice_builder.py +++ b/general_ledger/builders/invoice_builder.py @@ -2,13 +2,13 @@ from loguru import logger -from general_ledger.models import ( +from general_ledger.django.models import ( Invoice, DocumentNumberSequence as DocNumSeq, Account, TaxRate, ) -from general_ledger.models import InvoiceLine +from general_ledger.django.models import InvoiceLine class InvoiceBuilder: diff --git a/general_ledger/builders/mixins.py b/general_ledger/builders/mixins.py new file mode 100644 index 0000000..fae1efa --- /dev/null +++ b/general_ledger/builders/mixins.py @@ -0,0 +1,47 @@ +from datetime import date +from datetime import datetime + +from loguru import logger + +from general_ledger.statements.mixins import StartEndMixin + + +class StartEndBuilderMixin(StartEndMixin): + """ + Mixin class for building start and end dates. + start and end dates are required for most reports + """ + + def with_start_date(self, start_date): + if isinstance(start_date, date): + self.start_date = start_date + elif isinstance(start_date, str): + self.start_date = datetime.strptime(start_date, "%Y-%m-%d").date() + else: + logger.warning("Invalid start date: %s", start_date) + return self + + def with_end_date(self, dt): + if isinstance(dt, date): + self.end_date = dt + elif isinstance(dt, datetime): + self.end_date = dt.date() + elif isinstance(dt, str): + self.end_date = datetime.strptime(dt, "%Y-%m-%d").date() + + return self + + def with_date_range(self, start_date, end_date): + self.with_start_date(start_date) + self.with_end_date(end_date) + return self + + def build(self): + if self.strict_dates and not self.start_date: + raise ValueError("start date is required") + if not self.strict_dates and not self.start_date: + self.start_date = date(1970, 1, 1) + if self.strict_dates and not self.end_date: + raise ValueError("end date is required (builder)") + if not self.strict_dates and not self.end_date: + self.end_date = date.today() diff --git a/general_ledger/builders/payment.py b/general_ledger/builders/payment.py index 43e928f..05ef750 100644 --- a/general_ledger/builders/payment.py +++ b/general_ledger/builders/payment.py @@ -7,7 +7,7 @@ from rich import print, inspect from general_ledger.builders.invoice_builder import InvoiceBuilder -from general_ledger.models import ( +from general_ledger.django.models import ( Ledger, Payment, Invoice, diff --git a/general_ledger/builders/transaction.py b/general_ledger/builders/transaction.py index 96f4c57..45cb786 100644 --- a/general_ledger/builders/transaction.py +++ b/general_ledger/builders/transaction.py @@ -1,13 +1,17 @@ from abc import ABC, abstractmethod +from datetime import date, datetime from decimal import Decimal, InvalidOperation -from typing import List +from typing import List, Optional +import rich.repr from django.db import transaction -from django.utils import timezone +from loguru import logger -from general_ledger.models import Account, Transaction, Ledger, Entry, Direction - -import logging +from general_ledger.django.models.account import Account +from general_ledger.django.models.direction import Direction +from general_ledger.django.models.ledger import Ledger +from general_ledger.django.models.transaction import Transaction +from general_ledger.django.models.transaction_entry import Entry class TransactionBuilderAbstract(ABC): @@ -25,18 +29,23 @@ def build(self) -> Transaction: pass +@rich.repr.auto class TransactionBuilder(TransactionBuilderAbstract): - logger = logging.getLogger(__name__) - def __init__( self, ledger: Ledger = None, description: str = "", + trans_date: Optional[date] = None, ): + self.trans_date = trans_date + self.ledger = ledger + self.description = description + # @TODO this is dumb. make the tx and entries during build self.tx = Transaction( ledger=ledger, description=description, + trans_date=trans_date, ) self.entries: List[Entry] = [] @@ -44,6 +53,20 @@ def reset(self): self.tx = Transaction() self.entries = [] + def add_debit( + self, + account: Account, + amount: Decimal | int | str, + ): + return self.add_entry(account, amount, Direction.DEBIT) + + def add_credit( + self, + account: Account, + amount: Decimal | int | str, + ): + return self.add_entry(account, amount, Direction.CREDIT) + def add_entry( self, account: Account, @@ -63,6 +86,14 @@ def add_entry( "Invalid 'amount' value. It must be convertible to a Decimal. {str(e)}" ) + if amount < 0: + logger.warning( + "The 'amount' of this entry is negative. account:{} amount:{} type:{}", + account, + amount, + tx_type, + ) + entry = Entry( account=account, amount=amount, @@ -80,7 +111,10 @@ def set_ledger(self, ledger: Account): self.tx.ledger = ledger return self - def set_trans_date(self, trans_date: timezone): + def set_trans_date(self, trans_date: date | datetime | str): + if isinstance(trans_date, str): + trans_date = datetime.strptime(trans_date, "%Y-%m-%d").date() + self.tx.trans_date = trans_date return self @@ -94,7 +128,7 @@ def build(self) -> Transaction: accts.add(entry.account.pk) for entry in self.entries: - self.logger.debug(entry.tx_type) + logger.debug(entry.tx_type) if entry.tx_type == Direction.CREDIT: bals[entry.account.pk]["CREDITS"] += entry.amount elif entry.tx_type == Direction.DEBIT: @@ -104,6 +138,9 @@ def build(self) -> Transaction: # self.logger.info(bals) if self.tx is not None: + # self.tx.trans_date = self.trans_date + # self.tx.description = self.tx.description + # self.tx.ledger = self.tx.ledger self.tx.save() else: raise Exception("Transaction not saved") diff --git a/general_ledger/config.yml b/general_ledger/config.yml new file mode 100644 index 0000000..a4cda68 --- /dev/null +++ b/general_ledger/config.yml @@ -0,0 +1,29 @@ +--- + +renderer: + rich: + trial_balance: + date_format: "%-m.%-d" + decimal_format: "8,.0f" + highlight_debtors: false + highlight_creditors: false + colors: + debit: "green" + credit: "red" + header: "bold white" + t_account: + date_format: "%-m.%-d" + decimal_format: "8.2f" + highlight_debtors: false + highlight_creditors: false + colors: + debit: "green" + credit: "red" + header: "bold white" + three_column: + date_format: "%y.%-m.%-d" + decimal_format: "8.2f" + colors: + header: "bold yellow" + debit_column: "green" + credit_column: "red" diff --git a/general_ledger/django/__init__.py b/general_ledger/django/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/general_ledger/filters/__init__.py b/general_ledger/django/filters/__init__.py similarity index 63% rename from general_ledger/filters/__init__.py rename to general_ledger/django/filters/__init__.py index 5d39e59..7bc2cc5 100644 --- a/general_ledger/filters/__init__.py +++ b/general_ledger/django/filters/__init__.py @@ -3,3 +3,5 @@ from .account import AccountFilter from .invoice import InvoiceFilter from .bill import BillFilter +from .bank_account_filter import BankAccountFilter +from .bank_transaction import BankStatementFilter diff --git a/general_ledger/filters/account.py b/general_ledger/django/filters/account.py similarity index 84% rename from general_ledger/filters/account.py rename to general_ledger/django/filters/account.py index 5cd2bf0..905c2b0 100644 --- a/general_ledger/filters/account.py +++ b/general_ledger/django/filters/account.py @@ -1,6 +1,6 @@ import django_filters -from general_ledger.models import Contact, Account +from general_ledger.django.models import Contact, Account class AccountFilter(django_filters.FilterSet): diff --git a/general_ledger/django/filters/bank_account_filter.py b/general_ledger/django/filters/bank_account_filter.py new file mode 100644 index 0000000..848cb5d --- /dev/null +++ b/general_ledger/django/filters/bank_account_filter.py @@ -0,0 +1,19 @@ +import django_filters +import timezone_field +from django_filters import rest_framework as filters + +from general_ledger.django.models import Bank + + +class BankAccountFilter(filters.FilterSet): + class Meta: + model = Bank + fields = ["name", "account_number", "sort_code"] + filter_overrides = { + timezone_field.TimeZoneField: { + "filter_class": django_filters.CharFilter, + "extra": lambda f: { + "lookup_expr": "icontains", + }, + }, + } diff --git a/general_ledger/filters/bank_transaction.py b/general_ledger/django/filters/bank_transaction.py similarity index 77% rename from general_ledger/filters/bank_transaction.py rename to general_ledger/django/filters/bank_transaction.py index 02866c7..308589f 100644 --- a/general_ledger/filters/bank_transaction.py +++ b/general_ledger/django/filters/bank_transaction.py @@ -1,11 +1,8 @@ -from rest_framework import generics +import uuid + from django_filters import rest_framework as filters -from django.db.models import Sum -from django.db.models.functions import TruncDate -from rest_framework import serializers -from general_ledger.models import BankStatementLine -import uuid +from general_ledger.django.models import BankStatementLine class UUIDFilter(filters.UUIDFilter): diff --git a/general_ledger/filters/bill.py b/general_ledger/django/filters/bill.py similarity index 69% rename from general_ledger/filters/bill.py rename to general_ledger/django/filters/bill.py index 6a8c489..46a4c94 100644 --- a/general_ledger/filters/bill.py +++ b/general_ledger/django/filters/bill.py @@ -1,7 +1,7 @@ import django_filters -from general_ledger.models import Contact -from general_ledger.models.invoice_purchaseinvoice import PurchaseInvoice +from general_ledger.django.models import Contact +from general_ledger.django.models.invoice_purchaseinvoice import PurchaseInvoice class BillFilter(django_filters.FilterSet): diff --git a/general_ledger/filters/contact.py b/general_ledger/django/filters/contact.py similarity index 95% rename from general_ledger/filters/contact.py rename to general_ledger/django/filters/contact.py index d53e569..4acebb3 100644 --- a/general_ledger/filters/contact.py +++ b/general_ledger/django/filters/contact.py @@ -1,6 +1,6 @@ import django_filters -from general_ledger.models import Contact +from general_ledger.django.models import Contact class ContactFilter( diff --git a/general_ledger/filters/invoice.py b/general_ledger/django/filters/invoice.py similarity index 85% rename from general_ledger/filters/invoice.py rename to general_ledger/django/filters/invoice.py index 5ba738e..dfc451f 100644 --- a/general_ledger/filters/invoice.py +++ b/general_ledger/django/filters/invoice.py @@ -1,6 +1,6 @@ import django_filters -from general_ledger.models import Invoice +from general_ledger.django.models import Invoice class InvoiceFilter(django_filters.FilterSet): diff --git a/general_ledger/filters/transaction.py b/general_ledger/django/filters/transaction.py similarity index 91% rename from general_ledger/filters/transaction.py rename to general_ledger/django/filters/transaction.py index d3ed903..2d087a8 100644 --- a/general_ledger/filters/transaction.py +++ b/general_ledger/django/filters/transaction.py @@ -1,7 +1,7 @@ import django_filters from django import forms -from general_ledger.models import Transaction +from general_ledger.django.models import Transaction class TransactionFilter(django_filters.FilterSet): diff --git a/general_ledger/models/__init__.py b/general_ledger/django/models/__init__.py similarity index 100% rename from general_ledger/models/__init__.py rename to general_ledger/django/models/__init__.py index ce26cc0..3023400 100644 --- a/general_ledger/models/__init__.py +++ b/general_ledger/django/models/__init__.py @@ -1,28 +1,28 @@ # @formatter:off -from .direction import Direction -from .tax_type import TaxType -from .tax_rate import TaxRate -from .coa import ChartOfAccounts +from .account import Account from .account_type import AccountType +from .bank_account import Bank +from .bank_balance import BankBalance +from .bank_statement_line import BankStatementLine from .book import Book -from .ledger import Ledger -from .account import Account -from .transaction import Transaction -from .transaction_entry import Entry +from .coa import ChartOfAccounts +from .contact import Contact +from .direction import Direction +from .document_sequence import DocumentNumberSequence +from .file_upload import FileUpload from .invoice import Invoice from .invoice_line import InvoiceLine -from .xero_gl_import import XeroGlImport -from .contact import Contact from .invoice_purchaseinvoice import PurchaseInvoice from .invoice_purchaseinvoice_line import PurchaseInvoiceLine -from .bank_account import Bank -from .permissions import UserBookAccess -from .bank_statement_line import BankStatementLine -from .file_upload import FileUpload -from .bank_balance import BankBalance -from .document_sequence import DocumentNumberSequence +from .ledger import Ledger from .payment import Payment from .payment_item import PaymentItem from .payment_transaction import PaymentTransaction +from .permissions import UserBookAccess +from .tax_rate import TaxRate +from .tax_type import TaxType +from .transaction import Transaction +from .transaction_entry import Entry +from .xero_gl_import import XeroGlImport # @formatter:on diff --git a/general_ledger/models/account.py b/general_ledger/django/models/account.py similarity index 72% rename from general_ledger/models/account.py rename to general_ledger/django/models/account.py index a03a41d..897bfa5 100644 --- a/general_ledger/models/account.py +++ b/general_ledger/django/models/account.py @@ -1,20 +1,24 @@ import logging +import rich.repr from django.core.exceptions import ObjectDoesNotExist from django.db import models from django.db.models import F, Window, Sum from forex_python.converter import CurrencyCodes -from loguru import logger +from rich.console import Console, ConsoleOptions, RenderResult +from rich.measure import Measurement +from rich.panel import Panel -from general_ledger.managers.account import AccountManager, AccountQuerySet -from general_ledger.models.direction import Direction -from general_ledger.models.mixins import ( +from general_ledger.django.models.direction import Direction +from general_ledger.django.models.mixins import ( NameDescriptionMixin, CreatedUpdatedMixin, UuidMixin, SlugMixin, LinksMixin, ) +from general_ledger.managers.account import AccountManager, AccountQuerySet +from general_ledger.render.utility_rich import fmt class Account( @@ -83,7 +87,6 @@ def currency_symbol(self): currency_codes = CurrencyCodes() return currency_codes.get_symbol(self.currency) - tax_rate = models.ForeignKey( "TaxRate", on_delete=models.CASCADE, @@ -101,6 +104,12 @@ def currency_symbol(self): # limit_choices_to=limit1, ) + @property + def direction(self): + if isinstance(self.type.direction, str): + return Direction(self.type.direction) + return self.type.direction + # @TODO this makes no sense if the coa that the account # belongs to is attached to other ledgers. balance = models.DecimalField( @@ -112,24 +121,8 @@ def currency_symbol(self): def save(self, *args, **kwargs): super().save(*args, **kwargs) - def __str__(self): - # print(f"self.account_type: xxx {self.account_type}") - out = "" - quote = "'" - obrack = "(" - cbrack = ")" - try: - name = getattr(self.type, "name", "None") - except ObjectDoesNotExist: - name = "None" - out += f"{quote+self.name+quote: <20} {obrack+name+cbrack: <25}" - - try: - out += f" {self.slug: <20}" - except AttributeError: - pass - - return out + # def __rich_console__(self, console, options): + # return "test" # entries = self.entry_set.select_related('transaction').annotate( # running_balance=Window( @@ -140,7 +133,6 @@ def __str__(self): # ) # ) - def calculate_running_balance(self): running_balance = 0 running_balances = {} @@ -176,8 +168,52 @@ def annotate_running_balance(self): ) return entries - # def get_balance(self): - # amount = Entry.objects.filter(account=self).aggregate(models.Sum("amount"))[ - # "amount__sum" - # ] - # print(amount) + def __rich_console__( + self, console: Console, options: ConsoleOptions + ) -> RenderResult: + panel = Panel.fit(f"Hello, [red]{fmt(self.id)} \n duxk od", title=self.name) + yield panel + + def __rich_measure__( + self, console: Console, options: ConsoleOptions + ) -> Measurement: + # minW = min([len(str(self.id)), len(str(self.name)), len(str(self.code))]) + # maxW = max([len(str(self.name)), len(str(self.code))]) + 10 + # rprint(f"minW: {minW}, maxW: {maxW}") + minW = 20 + maxW = 35 + return Measurement( + minW, + maxW, + ) + + # def __rich_repr__(self) -> rich.repr.Result: + # yield self.name + # yield "code", self.code, None + # yield "type", self.type + # yield "balance", self.balance + # yield "currency", self.currency + # yield "tax_rate", self.tax_rate + # yield "is_system", self.is_system, False + # yield "is_placeholder", self.is_placeholder, False + # yield "is_hidden", self.is_hidden, False + # yield "code", self.code, None + + def __str__(self): + # print(f"self.account_type: xxx {self.account_type}") + out = "" + quote = "'" + obrack = "(" + cbrack = ")" + try: + type_name = getattr(self.type, "name", "None") + except ObjectDoesNotExist: + type_name = "None" + out += f"{quote+self.name+quote: <12} {obrack+type_name+cbrack: <12}" + + try: + out += f" {self.slug: <10}" + except AttributeError: + pass + + return out diff --git a/general_ledger/models/account_type.py b/general_ledger/django/models/account_type.py similarity index 84% rename from general_ledger/models/account_type.py rename to general_ledger/django/models/account_type.py index 437559c..3c32078 100644 --- a/general_ledger/models/account_type.py +++ b/general_ledger/django/models/account_type.py @@ -2,10 +2,16 @@ from django.db import models from django.utils.translation import gettext_lazy as _ +from rich.console import Console +from rich.console import ConsoleOptions, RenderResult -from general_ledger.models.mixins import NameDescriptionMixin, UuidMixin, SlugMixin -from .direction import Direction -from ..managers.account_type import AccountTypeManager, AccountTypeQuerySet +from general_ledger.django.models.direction import Direction +from general_ledger.django.models.mixins import ( + NameDescriptionMixin, + UuidMixin, + SlugMixin, +) +from general_ledger.managers.account_type import AccountTypeManager, AccountTypeQuerySet class AccountType( @@ -17,7 +23,6 @@ class AccountType( logger = logging.getLogger(__name__) objects = AccountTypeManager.from_queryset(queryset_class=AccountTypeQuerySet)() - class Meta: verbose_name = "Account Type" verbose_name_plural = "Account Types" @@ -70,6 +75,7 @@ class Liquidity(models.IntegerChoices): """ represents the liquidity of the account type when used on a balance sheet. Generally only useful for asset and liability """ + CASH = 100, _("Cash or Cash Equivalent") BANK = 90, _("Bank") ACCOUNTS_RECEIVABLE = 80, _("Accounts Receivable") @@ -94,3 +100,8 @@ class Liquidity(models.IntegerChoices): def __str__(self): return self.name + + def __rich_console__( + self, console: Console, options: ConsoleOptions + ) -> RenderResult: + yield self.name diff --git a/general_ledger/models/bank_account.py b/general_ledger/django/models/bank_account.py similarity index 93% rename from general_ledger/models/bank_account.py rename to general_ledger/django/models/bank_account.py index 15d2810..4242a0d 100644 --- a/general_ledger/models/bank_account.py +++ b/general_ledger/django/models/bank_account.py @@ -7,11 +7,11 @@ from timezone_field import TimeZoneField from general_ledger.managers.bank import BankManager -from general_ledger.models.mixins import ( +from general_ledger.django.models.mixins import ( CreatedUpdatedMixin, SlugMixin, ) -from general_ledger.models.mixins import LinksMixin +from general_ledger.django.models.mixins import LinksMixin class Bank( @@ -155,9 +155,10 @@ def get_unreconciled_count(self): def save(self, *args, **kwargs): logger.trace(f"BankAccount: [saving] {self._state}") - #if self._state.adding: + # if self._state.adding: if len(self.sort_code) == 6: - self.sort_code = f"{self.sort_code[:2]}-{self.sort_code[2:4]}-{self.sort_code[4:]}" + self.sort_code = ( + f"{self.sort_code[:2]}-{self.sort_code[2:4]}-{self.sort_code[4:]}" + ) super().save(*args, **kwargs) - diff --git a/general_ledger/models/bank_balance.py b/general_ledger/django/models/bank_balance.py similarity index 97% rename from general_ledger/models/bank_balance.py rename to general_ledger/django/models/bank_balance.py index a27a7e1..758df88 100644 --- a/general_ledger/models/bank_balance.py +++ b/general_ledger/django/models/bank_balance.py @@ -3,7 +3,7 @@ from django.db import models from general_ledger.managers.bank_balance import BankBalanceManager -from general_ledger.models.mixins import ( +from general_ledger.django.models.mixins import ( UuidMixin, CreatedUpdatedMixin, ) diff --git a/general_ledger/models/bank_balance_type.py b/general_ledger/django/models/bank_balance_type.py similarity index 100% rename from general_ledger/models/bank_balance_type.py rename to general_ledger/django/models/bank_balance_type.py diff --git a/general_ledger/models/bank_statement_line.py b/general_ledger/django/models/bank_statement_line.py similarity index 93% rename from general_ledger/models/bank_statement_line.py rename to general_ledger/django/models/bank_statement_line.py index 9e04740..207237f 100644 --- a/general_ledger/models/bank_statement_line.py +++ b/general_ledger/django/models/bank_statement_line.py @@ -14,12 +14,12 @@ BankStatementLineManager, BankStatementLineManagerQuerySet, ) -from general_ledger.models.mixins import LinksMixin -from general_ledger.models.mixins import ( +from general_ledger.django.models.mixins import LinksMixin +from general_ledger.django.models.mixins import ( UuidMixin, CreatedUpdatedMixin, ) -from general_ledger.models.payment import Payment +from general_ledger.django.models.payment import Payment from django.db.models import Q @@ -205,7 +205,12 @@ def save(self, *args, **kwargs): self.date = self.datetime.date() elif self.date and not self.datetime: - self.datetime = datetime.datetime(self.date.year, self.date.month, self.date.day, tzinfo=self.bank.tz,) + self.datetime = datetime.datetime( + self.date.year, + self.date.month, + self.date.day, + tzinfo=self.bank.tz, + ) if not self.hash: self.hash = self.get_hash() @@ -221,7 +226,7 @@ def save(self, *args, **kwargs): try: self.full_clean() except ValidationError as e: - # inspect(self) + logger.error(f"BankTransaction: [validation] {e} '{self}'") raise e super().save(*args, **kwargs) diff --git a/general_ledger/models/bank_statement_line_type.py b/general_ledger/django/models/bank_statement_line_type.py similarity index 100% rename from general_ledger/models/bank_statement_line_type.py rename to general_ledger/django/models/bank_statement_line_type.py diff --git a/general_ledger/models/book.py b/general_ledger/django/models/book.py similarity index 96% rename from general_ledger/models/book.py rename to general_ledger/django/models/book.py index 5544c16..134d691 100644 --- a/general_ledger/models/book.py +++ b/general_ledger/django/models/book.py @@ -6,12 +6,12 @@ from general_ledger.helpers.book import BookHelper from general_ledger.managers.book import BookManager, BookQuerySet -from general_ledger.models.mixins import ( +from general_ledger.django.models.mixins import ( BusinessAccountsMixin, DocumentSequencePrefixMixin, ) -from general_ledger.models.permissions import UserBookAccess -from general_ledger.models.mixins import ( +from general_ledger.django.models.permissions import UserBookAccess +from general_ledger.django.models.mixins import ( CreatedUpdatedMixin, UuidMixin, SlugMixin, diff --git a/general_ledger/models/coa.py b/general_ledger/django/models/coa.py similarity index 56% rename from general_ledger/models/coa.py rename to general_ledger/django/models/coa.py index c8dbe90..c47a11f 100644 --- a/general_ledger/models/coa.py +++ b/general_ledger/django/models/coa.py @@ -1,21 +1,14 @@ import logging from django.db import models +from django.db.models import Q -from general_ledger.models.mixins import NameDescriptionMixin, UuidMixin, SlugMixin - - -class ChartOfAccountsManager(models.Manager): - def get_queryset(self): - qs = super().get_queryset() - return qs.select_related( - "book", - ) - - def for_book(self, book): - return self.get_queryset().filter( - book=book, - ) +from general_ledger.managers.chart_of_accounts import ChartOfAccountsManager +from general_ledger.django.models.mixins import ( + NameDescriptionMixin, + UuidMixin, + SlugMixin, +) class ChartOfAccounts( @@ -55,7 +48,26 @@ def get_sales_account(self): name="Sales", ) + # @TODO this is wrong - returning accoutn instead of rate def get_sales_tax_rate(self): return self.account_set.get( name="Sales", ) + + def getac(self, query): + return self.account_set.get( + Q(name__iexact=query) | Q(slug__iexact=query), + ) + + def get_or_create(self, name, type_slug=None, tax_rate_slug=None): + defaults = {} + if tax_rate_slug: + defaults["tax_rate"] = self.book.taxrate_set.get(slug=tax_rate_slug) + if type_slug: + defaults["type"] = self.book.accounttype_set.get(slug=type_slug) + + acc, _ = self.account_set.update_or_create( + name=name, + defaults=defaults, + ) + return acc diff --git a/general_ledger/models/contact.py b/general_ledger/django/models/contact.py similarity index 95% rename from general_ledger/models/contact.py rename to general_ledger/django/models/contact.py index 3f22e29..c7101fb 100644 --- a/general_ledger/models/contact.py +++ b/general_ledger/django/models/contact.py @@ -3,9 +3,9 @@ from django.db import models from general_ledger.managers.contact import ContactManager, ContactQuerySet -from general_ledger.models.mixins import LinksMixin -from general_ledger.models.mixins import UuidMixin, CreatedUpdatedMixin -from general_ledger.models.tax_inclusive import TaxInclusive +from general_ledger.django.models.mixins import LinksMixin +from general_ledger.django.models.mixins import UuidMixin, CreatedUpdatedMixin +from general_ledger.django.models.tax_inclusive import TaxInclusive class Contact( diff --git a/general_ledger/models/direction.py b/general_ledger/django/models/direction.py similarity index 100% rename from general_ledger/models/direction.py rename to general_ledger/django/models/direction.py diff --git a/general_ledger/models/document_sequence.py b/general_ledger/django/models/document_sequence.py similarity index 100% rename from general_ledger/models/document_sequence.py rename to general_ledger/django/models/document_sequence.py diff --git a/general_ledger/models/document_status.py b/general_ledger/django/models/document_status.py similarity index 100% rename from general_ledger/models/document_status.py rename to general_ledger/django/models/document_status.py diff --git a/general_ledger/models/exchange_rate.py b/general_ledger/django/models/exchange_rate.py similarity index 100% rename from general_ledger/models/exchange_rate.py rename to general_ledger/django/models/exchange_rate.py diff --git a/general_ledger/models/file_upload.py b/general_ledger/django/models/file_upload.py similarity index 100% rename from general_ledger/models/file_upload.py rename to general_ledger/django/models/file_upload.py diff --git a/general_ledger/models/invoice.py b/general_ledger/django/models/invoice.py similarity index 97% rename from general_ledger/models/invoice.py rename to general_ledger/django/models/invoice.py index b8b4e23..e745cf8 100644 --- a/general_ledger/models/invoice.py +++ b/general_ledger/django/models/invoice.py @@ -2,7 +2,6 @@ from datetime import date from decimal import Decimal, ROUND_HALF_EVEN -from django.core.exceptions import ValidationError from django.db import models from django.db.models import F from django.utils.translation import gettext_lazy as _ @@ -10,10 +9,9 @@ from simple_history.models import HistoricalRecords from general_ledger.managers.invoice import InvoiceManager, InvoiceQuerySet -from general_ledger.models import Book -from general_ledger.models.invoice_base import InvoiceBaseMixin -from general_ledger.models.tax_inclusive import TaxInclusive -from general_ledger.models.validators import validate_is_customer +from general_ledger.django.models import Book +from general_ledger.django.models.invoice_base import InvoiceBaseMixin +from general_ledger.django.models.tax_inclusive import TaxInclusive class Invoice( @@ -242,7 +240,7 @@ def total_inclusive(self): """ return Decimal( sum(line.line_total_inclusive() for line in self.invoice_lines.all()) - ) + ).quantize(Decimal("1.0000")) def total_by_tax_type(self): """ @@ -421,4 +419,3 @@ def do_posted(self): self.do_next() if self.is_awaiting_approval() and self.can_post(): self.do_next() - diff --git a/general_ledger/models/invoice_base.py b/general_ledger/django/models/invoice_base.py similarity index 94% rename from general_ledger/models/invoice_base.py rename to general_ledger/django/models/invoice_base.py index 38c881d..7851207 100644 --- a/general_ledger/models/invoice_base.py +++ b/general_ledger/django/models/invoice_base.py @@ -7,18 +7,18 @@ from loguru import logger from rich import inspect -from general_ledger.models.mixins import ( +from general_ledger.django.models.mixins import ( BusinessAccountsMixin, LinksMixin, ValidatableModelMixin, EditableMixin, ) -from general_ledger.models.mixins import ( +from general_ledger.django.models.mixins import ( UuidMixin, CreatedUpdatedMixin, SlugMixin, ) -from general_ledger.models.tax_inclusive import TaxInclusive +from general_ledger.django.models.tax_inclusive import TaxInclusive # this is a bunch of app specific mixins @@ -78,7 +78,6 @@ def name(self): def clean(self): super().clean() - # inspect(self) if not self.contact.is_customer: raise ValidationError( diff --git a/general_ledger/models/invoice_line.py b/general_ledger/django/models/invoice_line.py similarity index 97% rename from general_ledger/models/invoice_line.py rename to general_ledger/django/models/invoice_line.py index 58d84b9..aac12a1 100644 --- a/general_ledger/models/invoice_line.py +++ b/general_ledger/django/models/invoice_line.py @@ -7,8 +7,8 @@ from rich import inspect from general_ledger.managers.invoice_line import InvoiceLineManager -from general_ledger.models.tax_inclusive import TaxInclusive -from general_ledger.models.mixins import ( +from general_ledger.django.models.tax_inclusive import TaxInclusive +from general_ledger.django.models.mixins import ( NameDescriptionMixin, UuidMixin, ) diff --git a/general_ledger/models/invoice_line_base.py b/general_ledger/django/models/invoice_line_base.py similarity index 97% rename from general_ledger/models/invoice_line_base.py rename to general_ledger/django/models/invoice_line_base.py index afbd233..c7a3d09 100644 --- a/general_ledger/models/invoice_line_base.py +++ b/general_ledger/django/models/invoice_line_base.py @@ -7,8 +7,8 @@ from rich import inspect from general_ledger.managers.invoice_line import InvoiceLineManager -from general_ledger.models.tax_inclusive import TaxInclusive -from general_ledger.models.mixins import ( +from general_ledger.django.models.tax_inclusive import TaxInclusive +from general_ledger.django.models.mixins import ( NameDescriptionMixin, UuidMixin, ) diff --git a/general_ledger/models/invoice_purchaseinvoice.py b/general_ledger/django/models/invoice_purchaseinvoice.py similarity index 87% rename from general_ledger/models/invoice_purchaseinvoice.py rename to general_ledger/django/models/invoice_purchaseinvoice.py index cf9df70..dde62ff 100644 --- a/general_ledger/models/invoice_purchaseinvoice.py +++ b/general_ledger/django/models/invoice_purchaseinvoice.py @@ -2,10 +2,10 @@ from django.db import models -from general_ledger.models.document_status import DocumentStatus -from general_ledger.models.invoice_base import InvoiceBaseMixin +from general_ledger.django.models.document_status import DocumentStatus +from general_ledger.django.models.invoice_base import InvoiceBaseMixin -from xstate_machine import FSMField, transition +from xstate_machine import FSMField class BillManager(models.Manager): diff --git a/general_ledger/models/invoice_purchaseinvoice_line.py b/general_ledger/django/models/invoice_purchaseinvoice_line.py similarity index 87% rename from general_ledger/models/invoice_purchaseinvoice_line.py rename to general_ledger/django/models/invoice_purchaseinvoice_line.py index 39b2f84..a8c3c67 100644 --- a/general_ledger/models/invoice_purchaseinvoice_line.py +++ b/general_ledger/django/models/invoice_purchaseinvoice_line.py @@ -7,9 +7,9 @@ from rich import inspect from general_ledger.managers.invoice_line import InvoiceLineManager -from general_ledger.models.invoice_line_base import InvoiceLineBase -from general_ledger.models.tax_inclusive import TaxInclusive -from general_ledger.models.mixins import ( +from general_ledger.django.models.invoice_line_base import InvoiceLineBase +from general_ledger.django.models.tax_inclusive import TaxInclusive +from general_ledger.django.models.mixins import ( NameDescriptionMixin, UuidMixin, ) diff --git a/general_ledger/models/invoice_transaction.py b/general_ledger/django/models/invoice_transaction.py similarity index 100% rename from general_ledger/models/invoice_transaction.py rename to general_ledger/django/models/invoice_transaction.py diff --git a/general_ledger/models/ledger.py b/general_ledger/django/models/ledger.py similarity index 59% rename from general_ledger/models/ledger.py rename to general_ledger/django/models/ledger.py index ce668f3..3cb045f 100644 --- a/general_ledger/models/ledger.py +++ b/general_ledger/django/models/ledger.py @@ -1,15 +1,18 @@ +from datetime import timedelta + +import rich.repr from django.db import models +from loguru import logger -from general_ledger.managers.ledger import LedgerManager, LedgerQuerySet -from general_ledger.models import Direction -from general_ledger.models.mixins import ( +from general_ledger.managers.ledger_manager import LedgerManager, LedgerQuerySet +from general_ledger.django.models.direction import Direction +from general_ledger.django.models.mixins import ( CreatedUpdatedMixin, NameDescriptionMixin, UuidMixin, SlugMixin, ) -from general_ledger.models.transaction_entry import Entry -from datetime import datetime, timedelta +from general_ledger.django.models.transaction_entry import Entry class Ledger( @@ -45,6 +48,10 @@ class Ledger( on_delete=models.CASCADE, ) + @property + def account_set(self): + return self.coa.account_set + is_posted = models.BooleanField(default=False) is_locked = models.BooleanField(default=False) is_system = models.BooleanField(default=False) @@ -60,11 +67,11 @@ class Meta: ] ordering = ["name"] - def __str__(self): - return self.name + # def __str__(self): + # return super(CreatedUpdatedMixin, self).__str__() def inventory_accounts(self): - return self.coa.account_set.filter( + return self.account_set.filter( type__slug="inventory", ) @@ -82,7 +89,15 @@ def balance_by_type_slug( balance_date=None, balance_at_close=True, ): - accounts = self.coa.account_set.filter( + """ + get the balance for all accounts of a given type + defaults to closing balance, but can be set to opening + :param type_slug: + :param balance_date: + :param balance_at_close: + :return: + """ + accounts = self.account_set.filter( type__slug=type_slug, ) @@ -98,7 +113,7 @@ def balance_by_slug( balance_date=None, balance_at_close=True, ): - accounts = self.coa.account_set.filter( + accounts = self.account_set.filter( slug=slug, ) @@ -114,18 +129,22 @@ def balance_for_accounts( balance_date=None, balance_at_close=True, ): - """ - return the appropriate debit or credit balance - this currently won't handle the case when the list - of accounts are a mix of CREDIT/DEBIT types - :param accounts: - :param balance_date: - :param balance_at_close: - :return: - """ + return sum( + [ + self.balance_for_account(account, balance_date, balance_at_close) + for account in accounts + ] + ) + + def balance_for_account( + self, + account, + balance_date=None, + balance_at_close=True, + ): combined_entries = Entry.objects.filter( transaction__ledger=self, - account__in=accounts, + account=account, ) if not balance_at_close: balance_date = balance_date - timedelta(days=1) @@ -135,8 +154,8 @@ def balance_for_accounts( transaction__trans_date__lte=balance_date, ) - direction = accounts.first().type.direction - print(f"direction: {direction} ") + direction = account.direction + logger.trace(f"direction: {direction} ") if direction == Direction.DEBIT: balance = combined_entries.debit_balance() @@ -144,3 +163,28 @@ def balance_for_accounts( balance = combined_entries.credit_balance() return balance + + def __rich_repr__(self) -> rich.repr.Result: + yield self.name + yield "book", self.book if self.book_id else None + yield "coa", self.coa if self.coa_id else None + yield "description", self.description, None + yield "id", self.id + yield "slug", self.slug + yield "is_posted", self.is_posted, False + yield "is_locked", self.is_locked, False + yield "is_system", self.is_system, False + yield "is_hidden", self.is_hidden, False + if self.coa_id: + yield "accounts", self.coa.account_set.all().count() + if hasattr(self, "transaction_set"): + yield "transactions", self.transaction_set.count() + if hasattr(self, "_state"): + yield "adding", self._state.adding, False + + # __rich_repr__.angular = True + + # def __rich_console__( + # self, console: Console, options: ConsoleOptions + # ) -> RenderResult: + # yield "rgeijerog" diff --git a/general_ledger/models/mixins/__init__.py b/general_ledger/django/models/mixins/__init__.py similarity index 100% rename from general_ledger/models/mixins/__init__.py rename to general_ledger/django/models/mixins/__init__.py diff --git a/general_ledger/models/mixins/business_accounts.py b/general_ledger/django/models/mixins/business_accounts.py similarity index 96% rename from general_ledger/models/mixins/business_accounts.py rename to general_ledger/django/models/mixins/business_accounts.py index e2c1edc..705cf88 100644 --- a/general_ledger/models/mixins/business_accounts.py +++ b/general_ledger/django/models/mixins/business_accounts.py @@ -7,9 +7,7 @@ from datetime import date from loguru import logger -from general_ledger.models.tax_inclusive import TaxInclusive - - +from general_ledger.django.models.tax_inclusive import TaxInclusive class BusinessAccountsMixin(models.Model): diff --git a/general_ledger/models/mixins/created_updated.py b/general_ledger/django/models/mixins/created_updated.py similarity index 88% rename from general_ledger/models/mixins/created_updated.py rename to general_ledger/django/models/mixins/created_updated.py index 11f6ae9..2d629dd 100644 --- a/general_ledger/models/mixins/created_updated.py +++ b/general_ledger/django/models/mixins/created_updated.py @@ -24,5 +24,5 @@ class Meta: abstract = True ordering = ["-created_at"] - def __str__(self): - return f"c:{self.created_at} u:{self.updated_at}" + # def __str__(self): + # return f"c:{self.created_at} u:{self.updated_at}" diff --git a/general_ledger/models/mixins/document_sequence.py b/general_ledger/django/models/mixins/document_sequence.py similarity index 93% rename from general_ledger/models/mixins/document_sequence.py rename to general_ledger/django/models/mixins/document_sequence.py index 170a129..7dbad2b 100644 --- a/general_ledger/models/mixins/document_sequence.py +++ b/general_ledger/django/models/mixins/document_sequence.py @@ -7,8 +7,7 @@ from datetime import date from loguru import logger -from general_ledger.models.tax_inclusive import TaxInclusive - +from general_ledger.django.models.tax_inclusive import TaxInclusive class DocumentSequencePrefixMixin(models.Model): diff --git a/general_ledger/models/mixins/editable.py b/general_ledger/django/models/mixins/editable.py similarity index 68% rename from general_ledger/models/mixins/editable.py rename to general_ledger/django/models/mixins/editable.py index 0200c06..4c4b4bc 100644 --- a/general_ledger/models/mixins/editable.py +++ b/general_ledger/django/models/mixins/editable.py @@ -1,13 +1,5 @@ -from decimal import Decimal - from django.core.exceptions import ValidationError from django.db import models -from django.urls import reverse -from django.utils.html import format_html -from datetime import date -from loguru import logger - -from general_ledger.models.tax_inclusive import TaxInclusive class EditableMixin(models.Model): diff --git a/general_ledger/models/mixins/links.py b/general_ledger/django/models/mixins/links.py similarity index 94% rename from general_ledger/models/mixins/links.py rename to general_ledger/django/models/mixins/links.py index 57fc6e7..cf25a1c 100644 --- a/general_ledger/models/mixins/links.py +++ b/general_ledger/django/models/mixins/links.py @@ -7,7 +7,7 @@ from datetime import date from loguru import logger -from general_ledger.models.tax_inclusive import TaxInclusive +from general_ledger.django.models.tax_inclusive import TaxInclusive class LinksMixin(models.Model): @@ -19,7 +19,7 @@ class Meta: abstract = True # generic view class attributes - #links_detail = f"general_ledger:generic-detail" + # links_detail = f"general_ledger:generic-detail" # links_list = "general_ledger:bank-list" # links_create = "general_ledger:bank-create" # links_edit = "general_ledger:bank-update" diff --git a/general_ledger/models/mixins/named_description.py b/general_ledger/django/models/mixins/named_description.py similarity index 100% rename from general_ledger/models/mixins/named_description.py rename to general_ledger/django/models/mixins/named_description.py diff --git a/general_ledger/models/mixins/slug.py b/general_ledger/django/models/mixins/slug.py similarity index 96% rename from general_ledger/models/mixins/slug.py rename to general_ledger/django/models/mixins/slug.py index 316a8df..1bd0be2 100644 --- a/general_ledger/models/mixins/slug.py +++ b/general_ledger/django/models/mixins/slug.py @@ -59,5 +59,5 @@ def save(self, *args, **kwargs): # self.slug = slugify(self.name)[:22] super().save(*args, **kwargs) - def __str__(self): - return self.slug + # def __str__(self): + # return self.slug diff --git a/general_ledger/models/mixins/uuid.py b/general_ledger/django/models/mixins/uuid.py similarity index 100% rename from general_ledger/models/mixins/uuid.py rename to general_ledger/django/models/mixins/uuid.py diff --git a/general_ledger/models/mixins/validatable.py b/general_ledger/django/models/mixins/validatable.py similarity index 91% rename from general_ledger/models/mixins/validatable.py rename to general_ledger/django/models/mixins/validatable.py index 88e14d3..cd8bed9 100644 --- a/general_ledger/models/mixins/validatable.py +++ b/general_ledger/django/models/mixins/validatable.py @@ -7,7 +7,7 @@ from datetime import date from loguru import logger -from general_ledger.models.tax_inclusive import TaxInclusive +from general_ledger.django.models.tax_inclusive import TaxInclusive # this is a bunch of app specific mixins diff --git a/general_ledger/models/payment.py b/general_ledger/django/models/payment.py similarity index 96% rename from general_ledger/models/payment.py rename to general_ledger/django/models/payment.py index df0560c..a16f2d8 100644 --- a/general_ledger/models/payment.py +++ b/general_ledger/django/models/payment.py @@ -5,13 +5,13 @@ from general_ledger.helpers.payment import PaymentHelper from general_ledger.managers.payment import PaymentManager -from general_ledger.models.document_status import DocumentStatus -from general_ledger.models.mixins import ( +from general_ledger.django.models.document_status import DocumentStatus +from general_ledger.django.models.mixins import ( LinksMixin, ValidatableModelMixin, EditableMixin, ) -from general_ledger.models.mixins import UuidMixin, CreatedUpdatedMixin +from general_ledger.django.models.mixins import UuidMixin, CreatedUpdatedMixin class Payment( diff --git a/general_ledger/models/payment_item.py b/general_ledger/django/models/payment_item.py similarity index 100% rename from general_ledger/models/payment_item.py rename to general_ledger/django/models/payment_item.py diff --git a/general_ledger/models/payment_transaction.py b/general_ledger/django/models/payment_transaction.py similarity index 100% rename from general_ledger/models/payment_transaction.py rename to general_ledger/django/models/payment_transaction.py diff --git a/general_ledger/models/permissions.py b/general_ledger/django/models/permissions.py similarity index 100% rename from general_ledger/models/permissions.py rename to general_ledger/django/models/permissions.py diff --git a/general_ledger/models/tax_inclusive.py b/general_ledger/django/models/tax_inclusive.py similarity index 100% rename from general_ledger/models/tax_inclusive.py rename to general_ledger/django/models/tax_inclusive.py diff --git a/general_ledger/models/tax_rate.py b/general_ledger/django/models/tax_rate.py similarity index 80% rename from general_ledger/models/tax_rate.py rename to general_ledger/django/models/tax_rate.py index b325788..86ab67a 100644 --- a/general_ledger/models/tax_rate.py +++ b/general_ledger/django/models/tax_rate.py @@ -1,14 +1,18 @@ import logging from django.db import models +from rich.console import Console +from rich.console import ConsoleOptions, RenderResult +from rich.table import Table -from general_ledger.managers.tax_rate import TaxRateManager -from general_ledger.models.mixins import ( +from general_ledger.django.models.mixins import ( NameDescriptionMixin, UuidMixin, CreatedUpdatedMixin, SlugMixin, ) +from general_ledger.managers.tax_rate import TaxRateManager +from general_ledger.render.utility_rich import fmt class TaxRate( @@ -105,3 +109,19 @@ def natural_key(self): null=True, blank=True, ) + + # def __rich_repr__(self): + # yield "TaxRate", {} + # yield "name", self.name + + def __rich_console__( + self, console: Console, options: ConsoleOptions + ) -> RenderResult: + yield self.as_cols() + + def as_cols(self): + yield from [ + self.name, + self.short_name, + # fmt(self.rate), + ] diff --git a/general_ledger/models/tax_type.py b/general_ledger/django/models/tax_type.py similarity index 94% rename from general_ledger/models/tax_type.py rename to general_ledger/django/models/tax_type.py index b8c6120..5783a55 100644 --- a/general_ledger/models/tax_type.py +++ b/general_ledger/django/models/tax_type.py @@ -7,7 +7,7 @@ CreatedUpdatedMixin, SlugMixin, ) -from ..managers.tax_type import TaxTypeManager +from general_ledger.managers.tax_type import TaxTypeManager class TaxType( diff --git a/general_ledger/models/transaction.py b/general_ledger/django/models/transaction.py similarity index 80% rename from general_ledger/models/transaction.py rename to general_ledger/django/models/transaction.py index f501ab6..2587ef9 100644 --- a/general_ledger/models/transaction.py +++ b/general_ledger/django/models/transaction.py @@ -1,4 +1,5 @@ import logging +from datetime import date, datetime from django.db.models import Sum from django.utils import timezone @@ -9,12 +10,14 @@ from rich.table import Table from general_ledger.managers.transaction import TransactionQuerySet -from general_ledger.models import Direction -from general_ledger.models.mixins import UuidMixin +from general_ledger.django.models.direction import Direction +from general_ledger.django.models.mixins import UuidMixin from loguru import logger +import rich.repr +# @rich.repr.auto class Transaction( UuidMixin, ): @@ -35,18 +38,10 @@ class Meta: ledger = models.ForeignKey( "Ledger", + related_name="transaction_set", on_delete=models.CASCADE, ) - """ - this is the date at which this transaction was posted to the ledger - """ - post_date = models.DateTimeField( - "post_date", - null=True, - blank=True, - ) - """ This is the date from the bank statement, or the date that was entered into the invoice etc """ @@ -62,6 +57,15 @@ class Meta: blank=True, ) + """ + this is the date at which this transaction was posted to the ledger + """ + post_date = models.DateTimeField( + "post_date", + null=True, + blank=True, + ) + is_posted = models.BooleanField(default=False) is_locked = models.BooleanField(default=False) @@ -70,22 +74,6 @@ def save(self, *args, **kwargs): super().save(*args, **kwargs) - def __str__(self): - return f"{self.post_date} {self.description}" - - def __rich__(self) -> str: - return "[bold cyan]MyObject()" - - def __rich_repr__(self): - yield "Transaction", {} - yield "Ledger", self.ledger - yield "Date", self.trans_date - yield "Debits", self.debit_amount - yield "Credits", self.credit_amount - yield "Is_Posted", self.is_posted - yield "Entries", self.get_entries() - # yield "Balance", Pretty(self.balance(), precision=2) - def get_entries(self, get_accounts: bool = False): if get_accounts: return self.entry_set.all().select_related("account") @@ -118,6 +106,7 @@ def is_valid(self): """ return all( [ + isinstance(self.trans_date, (date, datetime)), self.is_balance_valid(), ] ) @@ -127,7 +116,7 @@ def is_balance_valid(self): Check that the transaction is valid. This means that the sum of the debits and credits is zero. """ total = 0 - logger.debug(f"len(self.entry_set.all()): {len(self.entry_set.all())}") + logger.debug(f"{len(self.entry_set.all())=}") tot_credits = 0 tot_debits = 0 for entry in self.entry_set.all(): @@ -185,8 +174,9 @@ def post(self): self.is_posted = True self.post_date = timezone.now() self.save() - return True - raise ValueError("Transaction cannot be posted") + return self + logger.error(f"can't post {self!r}") + raise ValueError(f"Transaction cannot be posted [{self!r}]") @transaction.atomic def unpost(self): @@ -196,5 +186,24 @@ def unpost(self): if self.can_unpost(): self.is_posted = False self.save() - return True + return self raise ValueError("Transaction cannot be unposted") + + def __repr__(self): + return f"{self.trans_date} {self.description} [{self.entry_set}]" + + def __str__(self): + return f"{self.trans_date} {self.description}" + + # def __rich__(self) -> str: + # return "[bold cyan]MyObject()" + + # def __rich_repr__(self): + # yield "Transaction", {} + # yield "Ledger", self.ledger + # yield "Date", self.trans_date + # yield "Debits", self.debit_amount + # yield "Credits", self.credit_amount + # yield "Is_Posted", self.is_posted + # yield "Entries", self.get_entries() + # # yield "Balance", Pretty(self.balance(), precision=2) diff --git a/general_ledger/models/transaction_entry.py b/general_ledger/django/models/transaction_entry.py similarity index 89% rename from general_ledger/models/transaction_entry.py rename to general_ledger/django/models/transaction_entry.py index 3f7ec89..52cb682 100644 --- a/general_ledger/models/transaction_entry.py +++ b/general_ledger/django/models/transaction_entry.py @@ -7,9 +7,9 @@ from django.db.models import Q from django.utils.translation import gettext_lazy as _ -from general_ledger.models.mixins import CreatedUpdatedMixin +from general_ledger.django.models.mixins import CreatedUpdatedMixin from .direction import Direction -from ..managers.transaction_entry import EntryQuerySet, EntryManager +from general_ledger.managers.transaction_entry import EntryQuerySet, EntryManager class Entry( @@ -131,7 +131,7 @@ def get_counter_entry(self): def __str__(self): try: - account_name = self.account.name[:16] + account_name = self.account.name[:12] except ObjectDoesNotExist: account_name = "Unset" try: @@ -139,4 +139,9 @@ def __str__(self): except ObjectDoesNotExist: tx_type = "Unset" - return f"{account_name: <16} {tx_type} {self.debit_amount: >10.2f} {self.credit_amount: >10.2f}" + try: + trans_date = self.trans_date + except ObjectDoesNotExist: + trans_date = "Unset" + + return f"{tx_type} {trans_date} {account_name:<10} {self.debit_amount: >8.2f} {self.credit_amount: >8.2f}" diff --git a/general_ledger/models/validators.py b/general_ledger/django/models/validators.py similarity index 100% rename from general_ledger/models/validators.py rename to general_ledger/django/models/validators.py diff --git a/general_ledger/models/xero_gl_import.py b/general_ledger/django/models/xero_gl_import.py similarity index 95% rename from general_ledger/models/xero_gl_import.py rename to general_ledger/django/models/xero_gl_import.py index 4307926..188e804 100644 --- a/general_ledger/models/xero_gl_import.py +++ b/general_ledger/django/models/xero_gl_import.py @@ -2,7 +2,7 @@ from django.db import models -from general_ledger.models.mixins import UuidMixin +from general_ledger.django.models.mixins import UuidMixin class XeroGlImport(UuidMixin): diff --git a/general_ledger/factories/account_factory.py b/general_ledger/factories/account_factory.py index fd9e83c..2c3e5f2 100644 --- a/general_ledger/factories/account_factory.py +++ b/general_ledger/factories/account_factory.py @@ -2,7 +2,7 @@ from factory import LazyAttribute from factory.django import DjangoModelFactory -from general_ledger.models import Account +from general_ledger.django.models import Account from factory import post_generation, SubFactory diff --git a/general_ledger/factories/bank_account.py b/general_ledger/factories/bank_account.py index a1382f3..7256e27 100644 --- a/general_ledger/factories/bank_account.py +++ b/general_ledger/factories/bank_account.py @@ -7,7 +7,7 @@ from general_ledger.factories.account_factory import AccountFactory from general_ledger.factories.bank_statement_line_factory import BankTransactionFactory from general_ledger.factories.faker_utils import rand_sort_code, suffixes -from general_ledger.models import Bank +from general_ledger.django.models import Bank fake = Faker() diff --git a/general_ledger/factories/bank_statement_line_factory.py b/general_ledger/factories/bank_statement_line_factory.py index 00c290d..586767b 100644 --- a/general_ledger/factories/bank_statement_line_factory.py +++ b/general_ledger/factories/bank_statement_line_factory.py @@ -5,10 +5,11 @@ from factory.django import DjangoModelFactory as DjangoModelFactory from factory.fuzzy import FuzzyDate from faker import Faker + # from django.utils import timezone from datetime import timezone -from general_ledger.models import BankStatementLine -from general_ledger.models.bank_statement_line_type import BankStatementLineType +from general_ledger.django.models import BankStatementLine +from general_ledger.django.models.bank_statement_line_type import BankStatementLineType fake = Faker() @@ -21,10 +22,12 @@ class Meta: "general_ledger.factories.BankAccountFactory", ) - #date = factory.Faker("date_this_year") + # date = factory.Faker("date_this_year") date = factory.Faker( "date_time_between", - start_date=factory.fuzzy.FuzzyDate(datetime.date(2022, 1, 1)), # Default start date + start_date=factory.fuzzy.FuzzyDate( + datetime.date(2022, 1, 1) + ), # Default start date end_date=factory.fuzzy.FuzzyDate(datetime.date.today()), # Default end date tzinfo=timezone.utc, ) @@ -121,7 +124,8 @@ def create_transfers( raise ValueError("Cannot transfer to the same bank") transaction_date = fake.date_between( - start_date=datetime.datetime.now() - datetime.timedelta(days=years_ago * 365), + start_date=datetime.datetime.now() + - datetime.timedelta(days=years_ago * 365), end_date="today", ) diff --git a/general_ledger/factories/book.py b/general_ledger/factories/book.py index fedb572..2ff05c0 100644 --- a/general_ledger/factories/book.py +++ b/general_ledger/factories/book.py @@ -3,7 +3,7 @@ from factory import post_generation, SubFactory from faker import Faker -from general_ledger.models import Book +from general_ledger.django.models import Book import sys fake = Faker() diff --git a/general_ledger/factories/contact.py b/general_ledger/factories/contact.py index 1a8416f..9fe424d 100644 --- a/general_ledger/factories/contact.py +++ b/general_ledger/factories/contact.py @@ -1,5 +1,5 @@ import factory -from general_ledger.models import Contact +from general_ledger.django.models import Contact class ContactFactory(factory.django.DjangoModelFactory): diff --git a/general_ledger/factories/invoice.py b/general_ledger/factories/invoice.py index db77167..3611723 100644 --- a/general_ledger/factories/invoice.py +++ b/general_ledger/factories/invoice.py @@ -6,8 +6,8 @@ from factory import SubFactory, LazyAttribute, post_generation from factory.django import DjangoModelFactory -from general_ledger.models import Invoice, InvoiceLine -from general_ledger.models.tax_inclusive import TaxInclusive +from general_ledger.django.models import Invoice, InvoiceLine +from general_ledger.django.models.tax_inclusive import TaxInclusive from faker import Faker diff --git a/general_ledger/factories/ledger.py b/general_ledger/factories/ledger.py index d0570dd..3b00435 100644 --- a/general_ledger/factories/ledger.py +++ b/general_ledger/factories/ledger.py @@ -2,7 +2,7 @@ from factory import SubFactory from faker import Faker -from general_ledger.models import Ledger +from general_ledger.django.models import Ledger fake = Faker() diff --git a/general_ledger/factories/transaction.py b/general_ledger/factories/transaction.py index 256631b..7df21c4 100644 --- a/general_ledger/factories/transaction.py +++ b/general_ledger/factories/transaction.py @@ -8,7 +8,7 @@ from rich import inspect from general_ledger.factories.ledger import LedgerFactory -from general_ledger.models import Transaction, Entry, Direction +from general_ledger.django.models import Transaction, Entry, Direction fake = Faker() diff --git a/general_ledger/filters/bank_account_filter.py b/general_ledger/filters/bank_account_filter.py deleted file mode 100644 index c919e98..0000000 --- a/general_ledger/filters/bank_account_filter.py +++ /dev/null @@ -1,25 +0,0 @@ -import django_filters -import timezone_field -from rest_framework import generics -from django_filters import rest_framework as filters -from django.db.models import Sum -from django.db.models.functions import TruncDate -from rest_framework import serializers - -from general_ledger.models import BankStatementLine, Bank -import uuid - - - -class BankAccountFilter(filters.FilterSet): - class Meta: - model = Bank - fields = ['name', 'account_number', 'sort_code'] - filter_overrides = { - timezone_field.TimeZoneField: { - 'filter_class': django_filters.CharFilter, - 'extra': lambda f: { - 'lookup_expr': 'icontains', - }, - }, - } \ No newline at end of file diff --git a/general_ledger/fixtures/account_types.yaml b/general_ledger/fixtures/account_types.yaml index 66c6081..913c0a3 100644 --- a/general_ledger/fixtures/account_types.yaml +++ b/general_ledger/fixtures/account_types.yaml @@ -107,15 +107,6 @@ description: '' book: 1c16c522-2837-4b78-a95a-5f7aa4612e51 category: X -- model: general_ledger.accounttype - pk: d5d12848-1e9a-4807-8ccd-b8a32fbff811 - fields: - name: Inventory - slug: inventory - description: '' - book: 1c16c522-2837-4b78-a95a-5f7aa4612e51 - category: CA - liquidity: 70 - model: general_ledger.accounttype pk: dab895be-e395-4fb9-a946-9a1d4add9d6b fields: @@ -157,10 +148,27 @@ name: Current Liability category: CL - model: general_ledger.accounttype - pk: dab895be-e395-4fb9-a946-eeed4add9d6b + pk: dab895be-e395-4fb9-6746-eeed41dd9d6b fields: name: Purchases slug: purchases description: '' book: 1c16c522-2837-4b78-a95a-5f7aa4612e51 category: X +- model: general_ledger.accounttype + pk: da3895be-e195-4fb9-a946-eeed41dd9d6b + fields: + name: Purchases Returns + slug: purchases-returns + description: '' + book: 1c16c522-2837-4b78-a95a-5f7aa4612e51 + category: X +- model: general_ledger.accounttype + pk: d5d12848-1e9a-4807-8ccd-b8a32fbff811 + fields: + name: Inventory + slug: inventory + description: '' + book: 1c16c522-2837-4b78-a95a-5f7aa4612e51 + category: CA + liquidity: 70 diff --git a/general_ledger/forms/bank.py b/general_ledger/forms/bank.py index 234cb19..81f34d5 100644 --- a/general_ledger/forms/bank.py +++ b/general_ledger/forms/bank.py @@ -4,7 +4,7 @@ from django.db import transaction from rich import inspect -from general_ledger.models import ( +from general_ledger.django.models import ( Bank, ) @@ -37,17 +37,10 @@ def __init__(self, *args, **kwargs): def save(self, commit=True): if not commit: raise ValueError("Cannot save without commit=True") - # inspect(self.instance, title=f"self.instance {self.instance._meta.model=}") - - # inspect(self.instance, title="self.instance1") # @TODO can just call is valid? bank_account = super().save(commit=False) - # inspect(self.instance, title="self.instance2") - - # inspect(bank_account, title="bank_account") - # is_new = not Bank.objects.filter(pk=self.instance.pk).exists() bank = Bank.objects.create_with_account( diff --git a/general_ledger/forms/bank_transaction.py b/general_ledger/forms/bank_transaction.py index 7134cc5..c993891 100644 --- a/general_ledger/forms/bank_transaction.py +++ b/general_ledger/forms/bank_transaction.py @@ -1,6 +1,6 @@ from django import forms -from general_ledger.models import BankStatementLine +from general_ledger.django.models import BankStatementLine class BankTransactionForm(forms.ModelForm): diff --git a/general_ledger/forms/contact.py b/general_ledger/forms/contact.py index f267baa..3056ce1 100644 --- a/general_ledger/forms/contact.py +++ b/general_ledger/forms/contact.py @@ -7,7 +7,7 @@ from django.forms import models from formset.widgets import Selectize -from general_ledger.models import Contact, Account, TaxRate +from general_ledger.django.models import Contact, Account, TaxRate class ContactUpdateForm( diff --git a/general_ledger/forms/contact_filter.py b/general_ledger/forms/contact_filter.py index 85ab041..e41e796 100644 --- a/general_ledger/forms/contact_filter.py +++ b/general_ledger/forms/contact_filter.py @@ -1,6 +1,6 @@ from django import forms -from general_ledger.models import Contact +from general_ledger.django.models import Contact class ContactFilterForm(forms.ModelForm): diff --git a/general_ledger/forms/contact_inline.py b/general_ledger/forms/contact_inline.py index bb80977..7eaa4f9 100644 --- a/general_ledger/forms/contact_inline.py +++ b/general_ledger/forms/contact_inline.py @@ -6,7 +6,7 @@ ) from django.forms import models -from general_ledger.models import Contact +from general_ledger.django.models import Contact class ContactInlineForm( diff --git a/general_ledger/forms/formset/invoice_formsetified.py b/general_ledger/forms/formset/invoice_formsetified.py index 4a91a2e..66af8a1 100644 --- a/general_ledger/forms/formset/invoice_formsetified.py +++ b/general_ledger/forms/formset/invoice_formsetified.py @@ -10,7 +10,7 @@ from formset.utils import FormMixin from loguru import logger -from general_ledger.models import ( +from general_ledger.django.models import ( Invoice, Contact, Ledger, diff --git a/general_ledger/forms/formset/invoice_line_collection.py b/general_ledger/forms/formset/invoice_line_collection.py index 36ac980..37c867f 100644 --- a/general_ledger/forms/formset/invoice_line_collection.py +++ b/general_ledger/forms/formset/invoice_line_collection.py @@ -4,7 +4,7 @@ from loguru import logger from general_ledger.forms.formset.invoice_line_formsetified import InvoiceLineForm -from general_ledger.models import InvoiceLine +from general_ledger.django.models import InvoiceLine class InvoiceLineCollection( diff --git a/general_ledger/forms/formset/invoice_line_formsetified.py b/general_ledger/forms/formset/invoice_line_formsetified.py index 429f033..3c461ef 100644 --- a/general_ledger/forms/formset/invoice_line_formsetified.py +++ b/general_ledger/forms/formset/invoice_line_formsetified.py @@ -10,7 +10,7 @@ from formset.utils import FormMixin from loguru import logger -from general_ledger.models import ( +from general_ledger.django.models import ( InvoiceLine, TaxRate, ) diff --git a/general_ledger/forms/invoice.py b/general_ledger/forms/invoice.py index 0f6dd9c..c5482d7 100644 --- a/general_ledger/forms/invoice.py +++ b/general_ledger/forms/invoice.py @@ -6,10 +6,12 @@ from general_ledger.forms.invoice_line import InvoiceLineForm from general_ledger.forms_widgets.contact_widget import ContactWidget -from general_ledger.models import ( +from general_ledger.django.models import ( Invoice, InvoiceLine, - Ledger, Account, TaxRate, + Ledger, + Account, + TaxRate, ) @@ -27,7 +29,7 @@ def create_invoice_line_formset(book, data=None, instance=None): formset=BaseInvoiceLineFormSet, form=InvoiceLineForm, extra=1, - can_delete=True + can_delete=True, ) class InvoiceLineFormSetWithBook(InvoiceLineFormSet): @@ -35,10 +37,11 @@ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) for form in self.forms: # form.fields['account'].queryset = Account.objects.filter(coa=book.get_default_coa()) - form.fields['vat_rate'].queryset = TaxRate.objects.filter(book=book) + form.fields["vat_rate"].queryset = TaxRate.objects.filter(book=book) return InvoiceLineFormSetWithBook(data=data, instance=instance) + class InvoiceForm(forms.ModelForm): logger = logging.getLogger(f"{__name__}.{__qualname__}") @@ -80,9 +83,7 @@ def __init__(self, *args, **kwargs): self.fields["contact"].extra_classes = "form-control" if book: - self.fields["ledger"].queryset = Ledger.objects.filter( - book=book - ) + self.fields["ledger"].queryset = Ledger.objects.filter(book=book) def get_context(self): context = super().get_context() diff --git a/general_ledger/forms/invoice_line.py b/general_ledger/forms/invoice_line.py index 339da2c..58e5c2e 100644 --- a/general_ledger/forms/invoice_line.py +++ b/general_ledger/forms/invoice_line.py @@ -2,7 +2,7 @@ from django import forms -from general_ledger.models import InvoiceLine, TaxRate +from general_ledger.django.models import InvoiceLine, TaxRate class InvoiceLineForm(forms.ModelForm): diff --git a/general_ledger/forms/invoice_status.py b/general_ledger/forms/invoice_status.py index 3fedc68..10b0f59 100644 --- a/general_ledger/forms/invoice_status.py +++ b/general_ledger/forms/invoice_status.py @@ -1,6 +1,6 @@ from django.forms import ModelForm -from general_ledger.models import Invoice +from general_ledger.django.models import Invoice class InvoiceStatusForm(ModelForm): diff --git a/general_ledger/forms/payment.py b/general_ledger/forms/payment.py index e3b230e..65e2d04 100644 --- a/general_ledger/forms/payment.py +++ b/general_ledger/forms/payment.py @@ -4,7 +4,7 @@ from django import forms from django_select2.forms import ModelSelect2Widget -from general_ledger.models import ( +from general_ledger.django.models import ( TaxRate, Account, Contact, diff --git a/general_ledger/forms/payment_edit.py b/general_ledger/forms/payment_edit.py index e65e023..ac503c8 100644 --- a/general_ledger/forms/payment_edit.py +++ b/general_ledger/forms/payment_edit.py @@ -1,7 +1,7 @@ from django import forms from rich import inspect -from general_ledger.models import ( +from general_ledger.django.models import ( Payment, ) @@ -17,7 +17,6 @@ def is_valid(self): return super().is_valid() def save(self, commit=True): - # inspect(self.instance) if "promote" in self.data: self.instance.promote() elif "demote" in self.data: diff --git a/general_ledger/forms/transaction.py b/general_ledger/forms/transaction.py index e07f04b..5dd4fd0 100644 --- a/general_ledger/forms/transaction.py +++ b/general_ledger/forms/transaction.py @@ -2,7 +2,7 @@ from import_export import resources, fields from import_export.forms import ImportForm, ConfirmImportForm -from general_ledger.models import Ledger, Transaction +from general_ledger.django.models import Ledger, Transaction import logging diff --git a/general_ledger/guff/__init__.py b/general_ledger/guff/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/general_ledger/guff/stuff.py b/general_ledger/guff/stuff.py new file mode 100644 index 0000000..eb8305b --- /dev/null +++ b/general_ledger/guff/stuff.py @@ -0,0 +1,44 @@ +from abc import ABC, abstractmethod +from datetime import date +from decimal import Decimal +from typing import List + +from general_ledger.django.models import Account + + +class AccountingStrategy(ABC): + """Strategy pattern for different accounting methods""" + + @abstractmethod + def calculate_sales( + self, ledger, accounts: List["Account"], start_date: date, end_date: date + ) -> Decimal: + pass + + +class PeriodicAccounting(AccountingStrategy): + """Calculate figures using periodic accounting method""" + + def calculate_sales( + self, ledger, accounts: List["Account"], start_date: date, end_date: date + ) -> Decimal: + """Calculate sales as difference between closing and opening balances""" + opening_balance = ledger.balance_for_accounts( + accounts, balance_date=start_date, balance_at_close=False + ) + closing_balance = ledger.balance_for_accounts( + accounts, balance_date=end_date, balance_at_close=True + ) + return closing_balance - opening_balance + + +class PerpetualAccounting(AccountingStrategy): + """Calculate figures using perpetual accounting method""" + + def calculate_sales( + self, ledger, accounts: List["Account"], start_date: date, end_date: date + ) -> Decimal: + """Calculate sales as sum of transactions in period""" + return ledger.sum_transactions_for_accounts( + accounts, start_date=start_date, end_date=end_date + ) diff --git a/general_ledger/guff/test1.py b/general_ledger/guff/test1.py new file mode 100644 index 0000000..cb4457d --- /dev/null +++ b/general_ledger/guff/test1.py @@ -0,0 +1,204 @@ +from dataclasses import dataclass, field +from decimal import Decimal +from datetime import date +from enum import Enum +from typing import Dict, List, Optional, Any, Union +from uuid import UUID + +from rich import inspect + + +class StatementType(Enum): + BALANCE_SHEET = "balance_sheet" + INCOME_STATEMENT = "income_statement" + CASH_FLOW = "cash_flow" + PROFIT_AND_LOSS = "profit_and_loss" + DEPARTMENTAL = "departmental" + + +class ComparisonAxis(Enum): + TIME = "time" + DEPARTMENT = "department" + DIVISION = "division" + PRODUCT = "product" + LOCATION = "location" + CUSTOM = "custom" + + +@dataclass +class Dimension: + """Represents a comparison dimension (e.g. time period, department)""" + + axis: ComparisonAxis + value: Any + label: str + sort_order: int = 0 + + +@dataclass +class AccountNode: + """Represents a single account line item in a statement""" + + id: UUID + code: str + name: str + parent: Optional["AccountNode"] = None + children: List["AccountNode"] = field(default_factory=list) + values: Dict[tuple, Decimal] = field( + default_factory=dict + ) # (dimension_tuple) -> value + presentation_order: int = 0 + is_subtotal: bool = False + is_total: bool = False + sign_reversal: bool = False # For items like "Less depreciation" + + +@dataclass +class StatementSection: + """Represents a major section of a financial statement (e.g. Current Assets)""" + + name: str + accounts: List[AccountNode] + presentation_order: int = 0 + show_subtotal: bool = True + + +@dataclass +class FinancialStatement: + """Base class for all financial statements""" + + statement_type: StatementType + dimensions: List[Dimension] + sections: List[StatementSection] + title: str + subtitle: Optional[str] = None + date_range: Optional[tuple[date, date]] = None + notes: Dict[str, str] = field(default_factory=dict) + + def get_dimension_values(self, axis: ComparisonAxis) -> List[Any]: + """Get all values for a particular comparison axis""" + return [d.value for d in self.dimensions if d.axis == axis] + + def get_value(self, account_id: UUID, dimension_values: tuple) -> Optional[Decimal]: + """Get value for an account at specific dimension values""" + for section in self.sections: + for account in section.accounts: + if account.id == account_id: + return account.values.get(dimension_values) + return None + + +@dataclass +class BalanceSheet(FinancialStatement): + """Balance Sheet specific implementation""" + + def __post_init__(self): + self.statement_type = StatementType.BALANCE_SHEET + + def validate(self) -> bool: + """Validate that assets = liabilities + equity for each dimension""" + for dim_values in self.get_all_dimension_combinations(): + assets = self.get_total_assets(dim_values) + liab_equity = self.get_total_liabilities( + dim_values + ) + self.get_total_equity(dim_values) + if assets != liab_equity: + return False + return True + + +@dataclass +class IncomeStatement(FinancialStatement): + """Income Statement specific implementation""" + + def __post_init__(self): + self.statement_type = StatementType.INCOME_STATEMENT + + +@dataclass +class DepartmentalStatement(FinancialStatement): + """Statement broken down by department""" + + departments: List[str] = field(default_factory=list) + + def __post_init__(self): + self.statement_type = StatementType.DEPARTMENTAL + # Add department dimension automatically + self.dimensions.append( + Dimension( + axis=ComparisonAxis.DEPARTMENT, + value=self.departments, + label="Departments", + ) + ) + + +# Example builder class for constructing statements +class StatementBuilder: + """Builder for creating financial statements""" + + def __init__(self, statement_type: StatementType): + self.statement_type = statement_type + self.dimensions: List[Dimension] = [] + self.sections: List[StatementSection] = [] + self.title = "" + + def add_dimension( + self, axis: ComparisonAxis, value: Any, label: str + ) -> "StatementBuilder": + self.dimensions.append(Dimension(axis=axis, value=value, label=label)) + return self + + def add_section(self, section: StatementSection) -> "StatementBuilder": + self.sections.append(section) + return self + + def set_title(self, title: str) -> "StatementBuilder": + self.title = title + return self + + def build(self) -> FinancialStatement: + if self.statement_type == StatementType.BALANCE_SHEET: + return BalanceSheet( + statement_type=self.statement_type, + dimensions=self.dimensions, + sections=self.sections, + title=self.title, + ) + elif self.statement_type == StatementType.INCOME_STATEMENT: + return IncomeStatement( + statement_type=self.statement_type, + dimensions=self.dimensions, + sections=self.sections, + title=self.title, + ) + # Add other statement types as needed + raise ValueError(f"Unsupported statement type: {self.statement_type}") + + +def test_claude1(): + builder = StatementBuilder(StatementType.BALANCE_SHEET) + builder.add_dimension(ComparisonAxis.TIME, date(2023, 12, 31), "2023") + builder.add_dimension(ComparisonAxis.TIME, date(2022, 12, 31), "2022") + builder.set_title("Company XYZ Balance Sheet") + + # Add sections and accounts + current_assets = StatementSection( + name="Current Assets", + accounts=[ + AccountNode( + id=UUID("45368a0d-3509-4d3c-9f88-84c53d598771"), + code="1100", + name="Cash", + values={(2023,): Decimal("1000.00"), (2022,): Decimal("800.00")}, + ) + ], + ) + builder.add_section(current_assets) + + statement = builder.build() + inspect(statement) + + +if __name__ == "__main__": + test_claude1() diff --git a/general_ledger/helpers/__init__.py b/general_ledger/helpers/__init__.py index 1e48b2d..e69de29 100644 --- a/general_ledger/helpers/__init__.py +++ b/general_ledger/helpers/__init__.py @@ -1 +0,0 @@ -from .ledger_helper import LedgerHelper diff --git a/general_ledger/helpers/bank_balance_helper.py b/general_ledger/helpers/bank_balance_helper.py index 07afd2f..b39ec3c 100644 --- a/general_ledger/helpers/bank_balance_helper.py +++ b/general_ledger/helpers/bank_balance_helper.py @@ -8,7 +8,7 @@ from loguru import logger from rich import inspect -from general_ledger.models import BankBalance +from general_ledger.django.models import BankBalance def inspects(qs): @@ -37,9 +37,6 @@ def get_balance( dts = qs.dates("date", "day") - # inspect(qs) - # inspect(dts) - balances = [] for dt in dts: sls_qs = qs.filter(date__lte=dt) diff --git a/general_ledger/helpers/bank_statement.py b/general_ledger/helpers/bank_statement.py index 65e61de..b842159 100644 --- a/general_ledger/helpers/bank_statement.py +++ b/general_ledger/helpers/bank_statement.py @@ -1,7 +1,7 @@ import logging from ofxparse import OfxParser -from general_ledger.models import FileUpload, Bank, Book +from general_ledger.django.models import FileUpload, Bank, Book class BankStatementHelper: diff --git a/general_ledger/helpers/book.py b/general_ledger/helpers/book.py index dcbac97..62109c5 100644 --- a/general_ledger/helpers/book.py +++ b/general_ledger/helpers/book.py @@ -2,12 +2,12 @@ from loguru import logger from dashboard import settings -from general_ledger.models.ledger import Ledger -from general_ledger.models.account import Account -from general_ledger.models.account_type import AccountType -from general_ledger.models.tax_rate import TaxRate -from general_ledger.models.coa import ChartOfAccounts -from general_ledger.models.tax_type import ( +from general_ledger.django.models.ledger import Ledger +from general_ledger.django.models.account import Account +from general_ledger.django.models.account_type import AccountType +from general_ledger.django.models.tax_rate import TaxRate +from general_ledger.django.models.coa import ChartOfAccounts +from general_ledger.django.models.tax_type import ( TaxType, ) diff --git a/general_ledger/helpers/invoice.py b/general_ledger/helpers/invoice.py index eeb039e..8ee02c9 100644 --- a/general_ledger/helpers/invoice.py +++ b/general_ledger/helpers/invoice.py @@ -9,8 +9,8 @@ various operations that can be performed on an invoice. This class will be used to handle the various operations """ -from general_ledger.builders import TransactionBuilder -from general_ledger.models import Invoice, Direction +from general_ledger.builders.transaction import TransactionBuilder +from general_ledger.django.models import Invoice, Direction from loguru import logger diff --git a/general_ledger/helpers/ledger_helper.py b/general_ledger/helpers/ledger_helper.py index a018488..b80c234 100644 --- a/general_ledger/helpers/ledger_helper.py +++ b/general_ledger/helpers/ledger_helper.py @@ -1,37 +1,37 @@ -import itertools -import logging -from rich import inspect -from rich.pretty import pprint -from colorama import Fore, Style, Back +from decimal import Decimal +from typing import List from django.db import transaction -from django.db.models import CharField from django.db.models import DecimalField -from django.db.models import Q from django.db.models import Sum, Count -from django.db.models.functions import Cast from django.db.models.functions import Coalesce -from django.utils import timezone +from loguru import logger +from rich.console import Console +from rich.text import Text -from general_ledger import constants -from general_ledger.models import Direction -from general_ledger.models.transaction import Transaction -from general_ledger.models.transaction_entry import Entry -from general_ledger.models.account import Account -from general_ledger.models.ledger import Ledger -from general_ledger.utils.account_balanced import AccountBalancer -from general_ledger.utils.consoler import pr_account_balanced +from general_ledger.builders.transaction import TransactionBuilder +from general_ledger.builders.account_set_summary_builder import AccountSetSummaryBuilder +from general_ledger.builders.account_summary_builder import AccountSummaryBuilder +from general_ledger.django.models.account import Account +from general_ledger.django.models.direction import Direction +from general_ledger.django.models.ledger import Ledger +from general_ledger.django.models.transaction_entry import Entry +from general_ledger.render.consoler import pr_account_balanced +from general_ledger.render.format_table_rich_trial_balance import TrialBalance +from general_ledger.render.renderer_rich import RichConsoleRenderer +from general_ledger.render.utility_rich import lists_to_grid_cols +from general_ledger.statements.account_summary import AccountSummary +from general_ledger.statements.ledger_account import LedgerAccount +console = Console() -class LedgerHelper: - logger = logging.getLogger(__name__) +class LedgerHelper: def __init__(self, ledger: Ledger): self.ledger = ledger def get_ledger(self): - return self.ledger def get_ledger_name(self): @@ -40,99 +40,91 @@ def get_ledger_name(self): def get_ledger_book(self): return self.ledger.book - def transfer(self, from_account: Account, to_account: Account, amount: float): - """ - this basically creates a transaction between two accounts - but ensures that the transaction is a positive amount. Has a check - for the appropriate account type and should be 2 single entries - :param from_account: - :param to_account: - :param amount: - :return: - """ - assert ( - from_account.ledger == to_account.ledger - ), "Accounts must be in the same book" - assert amount > 0, "Amount must be positive" - # assert ( - # from_account.ledger.book == self.ledger.book - # ), "Account must be in the same book as the ledger" - # assert ( - # to_account.ledger.book == self.ledger.book - # ), "Account must be in the same book as the ledger" - assert from_account != to_account, "From and to accounts must be different" + @staticmethod + def account(ledger, account): + return LedgerAccount(ledger, account) - if from_account.account_type == constants.TxType.DEBIT: - from_entry = Entry( - account=from_account, - amount=amount, - tx_type=constants.TxType.CREDIT, + @staticmethod + def accounts(ledger): + return ledger.coa.account_set.all() + + @staticmethod + def ledger_accounts(ledger): + return [ + LedgerAccount(ledger, account) for account in ledger.coa.account_set.all() + ] + + @staticmethod + def close_out_by_type_slug( + ledger, slug, to_account: Account, date, description=None + ): + assert to_account is not None, "To account must be provided" + + if not description: + description = f"Transfer from type '{slug}' -> {to_account.name}" + + from_accounts = Account.objects.filter( + coa=ledger.coa, + type__slug=slug, + ) + if not from_accounts.count(): + raise ValueError(f"No accounts found for type '{slug}'") + entries = [] + for from_account in from_accounts: + amount = ledger.balance_for_account(from_account, date) + entries.append( + (from_account, amount, from_account.type.direction.opposite()) ) - else: - from_entry = Entry( - account=from_account, - amount=amount, - tx_type=constants.TxType.DEBIT, + entries.append((to_account, amount, from_account.type.direction)) + logger.trace( + f"from_account: <{from_account}> type: '{from_account.type.direction.opposite()}' amount: '{amount}'" ) - - if to_account.account_type == constants.TxType.DEBIT: - to_entry = Entry( - account=to_account, - amount=amount, - tx_type=constants.TxType.DEBIT, + logger.trace( + f"to_account: <{to_account}> type: '{from_account.type.direction}' amount: '{amount}'" ) - else: - to_entry = Entry( - account=to_account, - amount=amount, - tx_type=constants.TxType.CREDIT, + + LedgerHelper.post_transaction(ledger, description, date, entries) + + @staticmethod + def transfer( + ledger, + from_account: Account, + to_account: Account, + date, + amount: Decimal, + description=None, + ): + assert from_account.coa == to_account.coa, "Accounts must be in the same coa" + assert amount > 0, "Amount must be positive" + assert from_account != to_account, "From and to accounts must be different" + if not description: + description = ( + f"Transfer from type '{to_account.name}' -> {to_account.name}" ) - from_entry.save() - to_entry.save() + entries = [] + entries.append((from_account, amount, from_account.type.direction.opposite())) + entries.append((to_account, amount, from_account.type.direction)) + LedgerHelper.post_transaction(ledger, description, date, entries) - @transaction.atomic - def build_transaction(self, description: str, entries: list): + @staticmethod + def post_transaction(ledger, description, date, entries): """ - This method builds a transaction from a list of entries + post a simple transaction + :param ledger: :param description: + :param date: :param entries: :return: """ - tx: Transaction = None - - # try: - with transaction.atomic(): - tx = Transaction( - ledger=self.ledger, - description=description, - post_date=timezone.now(), - ) - if tx is not None: - tx.save() - else: - raise Exception("Transaction not created") - - models_to_create = [] - for entry in entries: - models_to_create.append( - Entry( - account=Account.objects.get( - code=entry["account"], - coa=self.ledger.coa, - ), - amount=entry["amount"], - tx_type=entry["tx_type"], - transaction=tx, - ) - ) - Entry.objects.bulk_create(models_to_create) - - return tx - # except Exception as e: - # # Handle exception if needed - # print(f"Error: {e}") - # return None + tb = TransactionBuilder( + ledger=ledger, + description=description, + ) + tb.set_trans_date(date) + for account, amount, direction in entries: + tb.add_entry(account, amount, direction) + return tb.build().post() @classmethod @transaction.atomic @@ -159,79 +151,224 @@ def get_account_balance(cls, account: Account): output_field=DecimalField(), ), )["total"] - cls.logger.debug(f"cr: {cr} db: {db}") + logger.debug(f"cr: {cr} db: {db}") return db - cr + def get_accounts_summary_as_list(self): + output = [] + for account in Account.objects.annotate(num_txs=Count("entry")).filter( + num_txs__gt=0, coa__ledger=self.ledger + ): + balanced = ( + AccountSummaryBuilder(strict_dates=False) + .with_account(account) + .with_ledger(self.ledger) + .build() + ) + balanced.balance_off() + output.append( + pr_account_balanced(balanced.entries_grouped, title=account.name) + ) + return output + def get_account_summary(self): output = "" for account in Account.objects.annotate(num_txs=Count("entry")).filter( - num_txs__gt=0, coa__ledger=self.ledger + num_txs__gt=0, + coa__ledger=self.ledger, ): - balanced = AccountBalancer( - account=account, - ledger=self.ledger, + balanced = ( + AccountSummaryBuilder(strict_dates=False) + .with_account(account) + .with_ledger(self.ledger) + .build() ) - output = pr_account_balanced(balanced.grouped_entries) + balanced.balance_off() + output += pr_account_balanced(balanced.entries_grouped, title=account.name) return output - def get_entry_summary(self, entryset, account): + @staticmethod + def balanced_to_account_sets(balanced, *args, **kwargs): + """this is some dumb stuff to get from accounts to account_sets""" + summary_set = ( + AccountSetSummaryBuilder() + # .with_entry_set(entry_set) + .with_summary_set(balanced) + .with_start_date(kwargs.pop("start_date", None)) + .with_end_date(kwargs.pop("end_date", None)) + .build() + ) + return summary_set - years = sorted(list(set([entry.trans_date.year for entry in entryset]))) - debits = entryset.filter(tx_type=Direction.DEBIT) - creditz = entryset.filter(tx_type=Direction.CREDIT) + @staticmethod + def render_account_set_summary(summary_set, **kwargs): + summary_set.set_renderer(RichConsoleRenderer()) + summary_set.set_table_format( + TrialBalance(), + **kwargs, + ) + return summary_set.render() - output = "" - for year in years: - debits1 = entryset.filter( - tx_type=Direction.DEBIT, - transaction__trans_date__year=year, + @staticmethod + def accounts_to_summaries(ledger_accounts, **kwargs) -> List[AccountSummary]: + summaries = [ + LedgerHelper.account_to_summary(ledger_account, **kwargs) + for ledger_account in ledger_accounts + ] + return summaries + + @staticmethod + def account_to_summary(ledger_account, **kwargs): + summary = ( + AccountSummaryBuilder(strict_dates=False) + # .with_entry_set(entry_set) + .with_account(ledger_account.account) + .with_ledger(ledger_account.ledger) + .with_start_date(kwargs.get("start_date", None)) + .with_end_date(kwargs.get("end_date", None)) + .with_balance_interval(kwargs.get("balance_interval", "month")) + .with_details( + title=ledger_account.account.name, + caption="This is a caption", + currency=ledger_account.account.currency, ) - creditz1 = entryset.filter( - tx_type=Direction.CREDIT, - transaction__trans_date__year=year, + .with_final_balance(kwargs.get("final_balance", False)) + .build() + ) + if not summary.entries: + logger.trace("account {account.name} has no entries, skipping") + else: + return summary + + @staticmethod + def summaries_to_balanced(summaries): + return [ + LedgerHelper.summary_to_balanced(summary) + for summary in summaries + if summary and summary.entries + ] + + @staticmethod + def summary_to_balanced(summary): + return summary.balance_off() + + @staticmethod + def balanced_to_t_accounts(summaries): + items = [] + for summary in summaries: + summary.set_renderer( + RichConsoleRenderer( + decimal_format="8,.0f", + date_format="%b %e", + # highlight_debitors=True, + highlight_creditors=False, + ) ) - zipped = list(itertools.zip_longest(debits1, creditz1, fillvalue=None)) - if len(zipped) == 0: - continue - # self.logger.info(f"year: {year} account: {account}") - output += self.get_year_header_row(year, account, debits1, creditz1) + items.append(summary.render()) + return items - # print(len(zipped)) - # print(f"type of zipped: {type(zipped)}") - for e in zipped: - # print(f"type of e: {type(e)}") - # print(f"type of e[0]: {type(e[0])}") - output += f"{self.get_entry_row(e[0])}|{self.get_entry_row(e[1])}\n" + @staticmethod + def accounts_to_renderables(ledger_account): + return LedgerHelper.balanced_to_t_accounts( + LedgerHelper.summaries_to_balanced( + LedgerHelper.accounts_to_summaries(ledger_account) + ) + ) - # output += self.get_totals_row(account) + @staticmethod + def t_accounts_to_grid_col(accounts, ledger): + items = [] + for account in accounts: + summary = ( + AccountSummaryBuilder(strict_dates=False) + # .with_entry_set(entry_set) + .with_account(account) + .with_ledger(ledger) + .with_balance_interval("month") + .with_details( + title=None, + caption="This is a caption", + currency=None, + ) + .with_final_balance() + # .with_by_group_intervals( + # [], + # ) + # .with_start_date("2023-06-01") + .build() + ) + if summary.entries: + # print(f"processing account {account} ledger {ledger}") + summary.balance_off() + summary.set_renderer( + RichConsoleRenderer( + decimal_format="8,.0f", + date_format="%b %e", + # highlight_debitors=True, + highlight_creditors=False, + ) + ) + items.append(summary.render()) + return items - return output + @staticmethod + def do_account_balancer_accounts(ledger=None, accounts=None, entry_set=None): + ab = ( + AccountSummaryBuilder( + strict_dates=False, + ) + .with_entry_set(entry_set) + .build() + ) + ab.balance_off() + output = pr_account_balanced(ab.entries_grouped) + return Text.from_ansi(output) - def get_year_header_row(self, year, account, debits1, creditz1) -> str: - output = "" - if len(debits1) and len(creditz1): - output += f" {Style.BRIGHT}{Fore.CYAN}{year: <31}{Style.RESET_ALL} {account.currency_symbol.center(6)} | {Style.BRIGHT}{Fore.CYAN}{year: <28}{Style.RESET_ALL} {account.currency_symbol.center(10): >10}\n" - elif len(debits1): - output += f" {Style.BRIGHT}{Fore.CYAN}{year: <31}{Style.RESET_ALL} {account.currency_symbol.center(6): >6} | {' '*39}\n" - elif len(creditz1): - output += f" {' '*38} | {Style.BRIGHT}{Fore.CYAN}{year: <28}{Style.RESET_ALL} {account.currency_symbol.center(10): >10}\n" - return output + @staticmethod + def do_stuff1( + ledger_accounts, + do_print=True, + *args, + **kwargs, + ): + # print(pr_account_list(ledger, accounts)) - def get_entry_row(self, entry): - # print(type(entry)) - output = "" - if entry: - # print(self.get_counter_entry(entry)) - tmp = entry.get_counter_entry() - # print(f"tmp: '{tmp}'") - output += f" {entry.transaction.trans_date.strftime('%b %e'): <8}{tmp: <19} {entry.amount : >10.2f} " - else: - output += f" {' '*38} " - return output + summaries = LedgerHelper.accounts_to_summaries( + ledger_accounts, + final_balance=True, + **kwargs, + ) + balanced = LedgerHelper.summaries_to_balanced(summaries) + t_accounts = LedgerHelper.balanced_to_t_accounts(balanced) - def get_totals_row(self, account): - output = "" - output += f" {'------'.rjust(38)} | {'-------'.rjust(38)}\n" - output += f" {'totals'.rjust(38)} | {'1234.00'.rjust(38)}\n" - output += f" {'======'.rjust(38)} | {'======='.rjust(38)}\n" - return output + summary_set = LedgerHelper.balanced_to_account_sets( + balanced, + **kwargs, + ) + + # inspect(col_left) + + if do_print: + console.print( + lists_to_grid_cols( + t_accounts, + LedgerHelper.render_account_set_summary(summary_set), + random_styles=False, + ) + ) + + return summary_set + + @staticmethod + def quick_summary_set(**kwargs): + ledger = kwargs.get("ledger") + + ledger_accounts = LedgerHelper.ledger_accounts(ledger) + + # return a list of AccountSummary with entries by date period + summaries = LedgerHelper.accounts_to_summaries(ledger_accounts, **kwargs) + balanced = LedgerHelper.summaries_to_balanced(summaries) + return LedgerHelper.balanced_to_account_sets( + balanced, + **kwargs, + ) diff --git a/general_ledger/helpers/matcher.py b/general_ledger/helpers/matcher.py index 1c3400e..a08b09f 100644 --- a/general_ledger/helpers/matcher.py +++ b/general_ledger/helpers/matcher.py @@ -5,7 +5,7 @@ from rich import print as inspect from general_ledger.builders.payment import PaymentBuilder -from general_ledger.models import Invoice, BankStatementLine, Payment +from general_ledger.django.models import Invoice, BankStatementLine, Payment class MatcherHelper: @@ -67,7 +67,6 @@ def reconcile_bank_statement(self): # match transfers. find the from account first for bank_transaction in unreconciled_bank_transactions.filter(amount__lt=0): - # inspect(bank_transaction) if bank_transaction in self.candidates["matched_bank_statement_lines"]: logger.trace("already matched") continue @@ -169,10 +168,7 @@ def process_matches(self): payment_item.save() for candidate in self.candidates["transfer"]: logger.info("processing transfer match") - # inspect(candidate) bsl_from, bsl_to = candidate - # inspect(bsl_from) - # inspect(bsl_to) pb = PaymentBuilder( ledger=bsl_from.bank.book.get_default_ledger(), date=bsl_from.date, diff --git a/general_ledger/helpers/payment.py b/general_ledger/helpers/payment.py index 91a67a3..d7ecce1 100644 --- a/general_ledger/helpers/payment.py +++ b/general_ledger/helpers/payment.py @@ -10,12 +10,9 @@ """ import logging -from loguru import logger -from rich import inspect -from general_ledger.builders import TransactionBuilder -from general_ledger.models import Direction -from general_ledger.models.document_status import DocumentStatus +from general_ledger.builders.transaction import TransactionBuilder +from general_ledger.django.models.direction import Direction class PaymentHelper: diff --git a/general_ledger/io/csv_parser.py b/general_ledger/io/csv_parser.py index 35cd65b..f2c5a82 100644 --- a/general_ledger/io/csv_parser.py +++ b/general_ledger/io/csv_parser.py @@ -92,13 +92,11 @@ def parse(self, file_path): # balance and balance date. if data["transactions"]: final_transaction = data["transactions"][-1] - # inspect(final_transaction) if "balance" in final_transaction and final_transaction["balance"]: data["balance"] = final_transaction["balance"] data["balance_date"] = final_transaction["date"] data["balance_source"] = "csv" else: - # inspect(data) raise ParsingError("No transactions found") return data except Exception as e: @@ -197,7 +195,6 @@ def parse_format_barc(self, reader): "balance": None, } ) - # inspect(row) data["account_number"] = row["Account"].split(" ")[-1] data["sort_code"] = row["Account"].split(" ")[0].replace("-", "") # csv files are in reverse order diff --git a/general_ledger/io/ofx_parser.py b/general_ledger/io/ofx_parser.py index 66a75ab..f2d7ea6 100644 --- a/general_ledger/io/ofx_parser.py +++ b/general_ledger/io/ofx_parser.py @@ -54,8 +54,8 @@ def parseAccount(self, account): # inspect(account) - #print(f"{sort_code=}") - #print(f"{account_number=}") + # print(f"{sort_code=}") + # print(f"{account_number=}") if len(sort_code) == 6: sort_code = f"{sort_code[:2]}-{sort_code[2:4]}-{sort_code[4:]}" @@ -65,7 +65,6 @@ def parseAccount(self, account): data["account_type"] = account_type statement = account.statement - #inspect(statement) # at least barclays these are completely wrong data["start_date"] = statement.start_date @@ -105,4 +104,3 @@ def parseTransaction(self, transaction): raise ParsingError( f"Error parsing OFX file txs: {str(e)} for {transaction}" ) - diff --git a/general_ledger/management/commands/demo.py b/general_ledger/management/commands/demo.py index 057c71f..7cb1e88 100644 --- a/general_ledger/management/commands/demo.py +++ b/general_ledger/management/commands/demo.py @@ -2,7 +2,7 @@ from django.core.management.base import BaseCommand from django.contrib.auth import get_user_model -from general_ledger.models import Book +from general_ledger.django.models import Book class Command(BaseCommand): diff --git a/general_ledger/management/commands/generate.py b/general_ledger/management/commands/generate.py index f710557..71b50cf 100644 --- a/general_ledger/management/commands/generate.py +++ b/general_ledger/management/commands/generate.py @@ -15,7 +15,7 @@ get_or_create_customers, get_or_create_banks, ) -from general_ledger.models import Bank +from general_ledger.django.models import Bank class Command(BaseCommand): diff --git a/general_ledger/management/commands/generate_balances.py b/general_ledger/management/commands/generate_balances.py index 2fd922c..673b289 100644 --- a/general_ledger/management/commands/generate_balances.py +++ b/general_ledger/management/commands/generate_balances.py @@ -7,7 +7,7 @@ import pytz from general_ledger.helpers.bank_balance_helper import BankBalanceHelper -from general_ledger.models import BankBalance, Bank +from general_ledger.django.models import BankBalance, Bank class Command(BaseCommand): @@ -52,5 +52,4 @@ def handle(self, *args, **kwargs): bbh = BankBalanceHelper(self.bank1) balance = bbh.get_balance() - self.stdout.write(self.style.SUCCESS("Successfully inserted running balances")) diff --git a/general_ledger/management/commands/generate_txs.py b/general_ledger/management/commands/generate_txs.py index 40b0b62..d280eac 100644 --- a/general_ledger/management/commands/generate_txs.py +++ b/general_ledger/management/commands/generate_txs.py @@ -7,7 +7,7 @@ get_book, get_or_create_banks, ) -from general_ledger.models import Bank +from general_ledger.django.models import Bank class Command(BaseCommand): @@ -64,6 +64,7 @@ def add_arguments(self, parser): def __init__(self, stdout=None, stderr=None, no_color=False, force_color=False): super().__init__(stdout, stderr, no_color, force_color) + self.user = None self.bank2 = None self.bank1 = None self.num_banks = None @@ -89,7 +90,6 @@ def handle(self, *args, **kwargs): self.other1() - def other1(self, *args, **kwargs): checking_banks = get_or_create_banks( diff --git a/general_ledger/management/commands/generate_xfer.py b/general_ledger/management/commands/generate_xfer.py index c511fc6..7d4774e 100644 --- a/general_ledger/management/commands/generate_xfer.py +++ b/general_ledger/management/commands/generate_xfer.py @@ -9,10 +9,11 @@ get_book, get_or_create_banks, ) -from general_ledger.models import Bank, BankStatementLine, Payment, PaymentItem +from general_ledger.django.models import Bank, BankStatementLine, Payment, PaymentItem from django.db.models import Q + class Command(BaseCommand): help = "generate Book data" @@ -97,14 +98,21 @@ def do_xfers(self): self.bank2.bankstatementline_set.all().delete() qs = Payment.objects.filter( - Q(items__from_object_id__in=self.bank1.bankstatementline_set.values_list('id', flat=True)) | - Q(items__to_object_id__in=self.bank2.bankstatementline_set.values_list('id', flat=True)) + Q( + items__from_object_id__in=self.bank1.bankstatementline_set.values_list( + "id", flat=True + ) + ) + | Q( + items__to_object_id__in=self.bank2.bankstatementline_set.values_list( + "id", flat=True + ) + ) ) # print(qs.query) print(qs.count()) qs.delete() - txferss = BankTransactionFactory.create_transfers( 10, banks=[self.bank1, self.bank2], diff --git a/general_ledger/management/commands/process_bank_statement.py b/general_ledger/management/commands/process_bank_statement.py index a1ef08d..c52494a 100644 --- a/general_ledger/management/commands/process_bank_statement.py +++ b/general_ledger/management/commands/process_bank_statement.py @@ -2,10 +2,17 @@ from rich import inspect from general_ledger.io import ParserFactory -from general_ledger.models import Book, FileUpload, BankStatementLine, Bank, BankBalance +from general_ledger.django.models import ( + Book, + FileUpload, + BankStatementLine, + Bank, + BankBalance, +) from django.contrib.auth import get_user_model from loguru import logger -# from general_ledger.models.account_dl_treebeard import AccountClass + +# from general_ledger.django.models.account_dl_treebeard import AccountClass class Command(BaseCommand): @@ -34,6 +41,9 @@ def handle(self, *args, **kwargs): if kwargs.get("bank1"): self.bank1 = Bank.objects.search(kwargs.get("bank1")) + else: + logger.error("Bank1 is required") + raise ValueError("Bank1 is required") file_upload = FileUpload.objects.get(id=kwargs["file_id"]) file_path = file_upload.file.path @@ -51,7 +61,7 @@ def handle(self, *args, **kwargs): print(f"{parsed_data['balance']=}") print(f"{parsed_data['balance_date']=}") balance, created = BankBalance.objects.get_or_create( - bank=bank, + bank=self.bank1, balance=parsed_data["balance"], balance_date=parsed_data["balance_date"], balance_source=parsed_data["balance_source"], @@ -66,7 +76,7 @@ def handle(self, *args, **kwargs): for data in parsed_data["transactions"]: line, created = BankStatementLine.objects.get_or_create( - bank=bank, + bank=self.bank1, hash=data["hash"], date=data["date"], amount=data["amount"], diff --git a/general_ledger/management/commands/process_bank_statement2.py b/general_ledger/management/commands/process_bank_statement2.py index ecd4656..50b04b7 100644 --- a/general_ledger/management/commands/process_bank_statement2.py +++ b/general_ledger/management/commands/process_bank_statement2.py @@ -5,10 +5,17 @@ from general_ledger.helpers.sort_code_lookup import sort_codes from general_ledger.io import ParserFactory from general_ledger.management.utils import get_book -from general_ledger.models import Book, FileUpload, BankStatementLine, Bank, BankBalance +from general_ledger.django.models import ( + Book, + FileUpload, + BankStatementLine, + Bank, + BankBalance, +) from django.contrib.auth import get_user_model from loguru import logger -# from general_ledger.models.account_dl_treebeard import AccountClass + +# from general_ledger.django.models.account_dl_treebeard import AccountClass class Command(BaseCommand): @@ -60,7 +67,7 @@ def handle(self, *args, **kwargs): # account_number=parsed_data["account_number"], # ) - for account in parsed_data["accounts"]: + for account in parsed_data["accounts"]: print(f"{account['balance']=}") print(f"{account['balance_date']=}") print(f"{account['sort_code']=}") @@ -70,7 +77,6 @@ def handle(self, *args, **kwargs): trie = create_sort_code_trie(sort_codes) - print(trie.lookup("60-24-77")) inspect(self.book.bank_set.all()) @@ -102,7 +108,6 @@ def handle(self, *args, **kwargs): type=account_type, ) - # if not parsed_data["transactions"]: # print("No transactions found") # return diff --git a/general_ledger/management/commands/process_matches.py b/general_ledger/management/commands/process_matches.py index 660b2f5..a878ad3 100644 --- a/general_ledger/management/commands/process_matches.py +++ b/general_ledger/management/commands/process_matches.py @@ -10,11 +10,9 @@ class Command(BaseCommand): help = "generate sample banks" def handle(self, *args, **kwargs): - # inspect(logger) logger.info("Processing matches") matcher = MatcherHelper() matcher.reconcile_bank_statement() - # inspect(matcher.candidates) for candidate in matcher.candidates["exact"]: logger.info(candidate) diff --git a/general_ledger/management/commands/process_payments.py b/general_ledger/management/commands/process_payments.py index d7c6a39..a967bba 100644 --- a/general_ledger/management/commands/process_payments.py +++ b/general_ledger/management/commands/process_payments.py @@ -7,7 +7,7 @@ from general_ledger.builders.payment import PaymentBuilder from general_ledger.factories import ContactFactory, BankAccountFactory -from general_ledger.models import Book +from general_ledger.django.models import Book logger.add( sys.stderr, diff --git a/general_ledger/management/commands/reset_gl.py b/general_ledger/management/commands/reset_gl.py index 9d80f3f..f6572b9 100644 --- a/general_ledger/management/commands/reset_gl.py +++ b/general_ledger/management/commands/reset_gl.py @@ -5,7 +5,7 @@ from django.contrib.admin.sites import AlreadyRegistered from django.apps import apps -# from general_ledger.models.account_dl_treebeard import AccountClass +# from general_ledger.django.models.account_dl_treebeard import AccountClass class Command(BaseCommand): diff --git a/general_ledger/management/utils.py b/general_ledger/management/utils.py index 1ee27c9..605816c 100644 --- a/general_ledger/management/utils.py +++ b/general_ledger/management/utils.py @@ -3,7 +3,7 @@ from loguru import logger from general_ledger.factories import ContactFactory, BankAccountFactory -from general_ledger.models import Book, Contact +from general_ledger.django.models import Book, Contact def get_book(book_str: str) -> Book: diff --git a/general_ledger/managers/account.py b/general_ledger/managers/account.py index 7d167be..b7b4dac 100644 --- a/general_ledger/managers/account.py +++ b/general_ledger/managers/account.py @@ -1,23 +1,16 @@ -import logging +from uuid import UUID -from django.core.exceptions import ObjectDoesNotExist from django.db import models -from django.db.models import F, Window, Sum -from forex_python.converter import CurrencyCodes +from django.db.models import Q +from rich.console import Console +from rich.console import ConsoleOptions, RenderResult +from rich.table import Table -from general_ledger.models.account_type import AccountType -from general_ledger.models.direction import Direction -from general_ledger.models.tax_rate import TaxRate -from general_ledger.models.mixins import ( - NameDescriptionMixin, - CreatedUpdatedMixin, - UuidMixin, - SlugMixin, -) +from general_ledger.django.models.account_type import AccountType +from general_ledger.render.utility_rich import model_table_generator class AccountQuerySet(models.QuerySet): - def current_asset(self): return self.filter( type__category=AccountType.Category.ASSET_CURRENT, @@ -59,6 +52,49 @@ def inventory(self): type__slug="inventory", ) + def filter_kwargs(self, **kwargs): + account_fields = [field.name for field in self.model._meta.get_fields()] + filtered_kwargs = { + key: value for key, value in kwargs.items() if key in account_fields + } + return filtered_kwargs + + def get_fuzzy(self, value): + # Try to convert the value to UUID + try: + uuid_value = UUID(value) + is_valid_uuid = True + except (ValueError, TypeError) as e: + is_valid_uuid = False + + # Create a Q object with multiple conditions + filter_condition = Q(name__iexact=value) | Q(slug__iexact=value) + + # Add UUID condition if the value is a valid UUID + if is_valid_uuid: + filter_condition |= Q(pk=uuid_value) + + # Apply the filter to the queryset + return self.get(filter_condition) + + def get_by_natural_key(self, owner, slug): + return self.get( + owner=owner, + slug=slug, + ) + + def __rich_repr__(self): + yield "AccountQuerySet", {} + yield "Count", self.count() + # yield "Accounts", str(self) + yield from self.iterator() + + def __rich_console__( + self, console: Console, options: ConsoleOptions + ) -> RenderResult: + yield from model_table_generator(self, self.model) + + class AccountManager(models.Manager): def get_queryset(self): qs = super().get_queryset() @@ -81,10 +117,3 @@ def get_by_natural_key(self, name, book): name=name, book=book, ) - - def filter_kwargs(self, **kwargs): - account_fields = [field.name for field in self.model._meta.get_fields()] - filtered_kwargs = { - key: value for key, value in kwargs.items() if key in account_fields - } - return filtered_kwargs diff --git a/general_ledger/managers/account_type.py b/general_ledger/managers/account_type.py index dbf2050..8a03e24 100644 --- a/general_ledger/managers/account_type.py +++ b/general_ledger/managers/account_type.py @@ -1,19 +1,10 @@ -import logging - from django.db import models -from general_ledger.models import Direction -from general_ledger.models.mixins import ( - NameDescriptionMixin, - UuidMixin, - CreatedUpdatedMixin, - SlugMixin, -) - class AccountTypeQuerySet(models.QuerySet): pass + class AccountTypeManager(models.Manager): def for_book(self, book): return self.get_queryset().filter( diff --git a/general_ledger/managers/bank.py b/general_ledger/managers/bank.py index af9a8a8..3fdd613 100644 --- a/general_ledger/managers/bank.py +++ b/general_ledger/managers/bank.py @@ -3,9 +3,10 @@ from django.db import models from django.db.models import Q from loguru import logger -from rich import inspect -from general_ledger.models import Account, TaxRate, AccountType +from general_ledger.django.models.account_type import AccountType +from general_ledger.django.models.tax_rate import TaxRate +from general_ledger.django.models.account import Account # LOGGING_CONSOLE = Console( diff --git a/general_ledger/managers/bank_balance.py b/general_ledger/managers/bank_balance.py index 39bab92..03ddf46 100644 --- a/general_ledger/managers/bank_balance.py +++ b/general_ledger/managers/bank_balance.py @@ -2,7 +2,7 @@ from django.db import models -from general_ledger.models.mixins import ( +from general_ledger.django.models.mixins import ( UuidMixin, CreatedUpdatedMixin, ) @@ -19,4 +19,3 @@ def for_bank(self, bank): return self.get_queryset().filter( bank=bank, ) - diff --git a/general_ledger/managers/book.py b/general_ledger/managers/book.py index 09242af..4792d94 100644 --- a/general_ledger/managers/book.py +++ b/general_ledger/managers/book.py @@ -17,6 +17,8 @@ def get_queryset(self): ) def for_user(self, user): + if user.is_superuser: + return self.get_queryset() return self.get_queryset().filter( Q(owner=user) | Q(users__in=[user]), ) diff --git a/general_ledger/managers/chart_of_accounts.py b/general_ledger/managers/chart_of_accounts.py new file mode 100644 index 0000000..10e71e6 --- /dev/null +++ b/general_ledger/managers/chart_of_accounts.py @@ -0,0 +1,22 @@ +import logging + +from django.db import models + +from general_ledger.django.models.mixins import ( + NameDescriptionMixin, + UuidMixin, + SlugMixin, +) + + +class ChartOfAccountsManager(models.Manager): + def get_queryset(self): + qs = super().get_queryset() + return qs.select_related( + "book", + ) + + def for_book(self, book): + return self.get_queryset().filter( + book=book, + ) diff --git a/general_ledger/managers/file_upload.py b/general_ledger/managers/file_upload.py index acb4303..87538ab 100644 --- a/general_ledger/managers/file_upload.py +++ b/general_ledger/managers/file_upload.py @@ -1,23 +1,12 @@ -import logging - from django.core.exceptions import ObjectDoesNotExist from django.db import models -from django.db.models import F, Window, Sum from forex_python.converter import CurrencyCodes -from general_ledger.models.account_type import AccountType -from general_ledger.models.direction import Direction -from general_ledger.models.tax_rate import TaxRate -from general_ledger.models.mixins import ( - NameDescriptionMixin, - CreatedUpdatedMixin, - UuidMixin, - SlugMixin, -) +from general_ledger.django.models.tax_rate import TaxRate class FileUploadManager(models.Manager): def for_book(self, book): return self.get_queryset().filter( book=book, - ) \ No newline at end of file + ) diff --git a/general_ledger/managers/invoice.py b/general_ledger/managers/invoice.py index d3cf148..65282b2 100644 --- a/general_ledger/managers/invoice.py +++ b/general_ledger/managers/invoice.py @@ -1,6 +1,11 @@ from datetime import timezone, datetime from django.db import models +from rich.console import Console +from rich.console import ConsoleOptions, RenderResult +from rich.table import Table + +from general_ledger.render.utility_rich import model_table_generator class InvoiceQuerySet(models.QuerySet): @@ -35,6 +40,11 @@ def overdue(self): due_date__lt=datetime.today(), ) + def __rich_console__( + self, console: Console, options: ConsoleOptions + ) -> RenderResult: + yield from model_table_generator(self, self.model) + class InvoiceManager(models.Manager): def get_queryset(self): diff --git a/general_ledger/managers/ledger.py b/general_ledger/managers/ledger_manager.py similarity index 65% rename from general_ledger/managers/ledger.py rename to general_ledger/managers/ledger_manager.py index 6fe6b05..9b69bb2 100644 --- a/general_ledger/managers/ledger.py +++ b/general_ledger/managers/ledger_manager.py @@ -2,13 +2,7 @@ from django.db import models from general_ledger.managers.mixins import CommonBooleanMixins -from general_ledger.models.account import Account -from general_ledger.models.mixins import ( - CreatedUpdatedMixin, - NameDescriptionMixin, - UuidMixin, - SlugMixin, -) +from general_ledger.django.models.account import Account class LedgerQuerySet(CommonBooleanMixins): @@ -50,14 +44,4 @@ def retrieve_account( } ) - # type = defaults.get("type", None) - # if not type: - # print(f"AccountModelManager.get_or_create2: type is None") - # type = AccountType.objects.get(name="Asset") - # tax_rate = defaults.get("tax_rate", None) - # if not tax_rate: - # print(f"AccountModelManager.get_or_create2: tax_rate is None") - # tax_rate = TaxRate.objects.get(name="No VAT") - return Account.objects.get_or_create(defaults, **kwargs) - diff --git a/general_ledger/managers/payment.py b/general_ledger/managers/payment.py index 4a840f6..825adfe 100644 --- a/general_ledger/managers/payment.py +++ b/general_ledger/managers/payment.py @@ -3,13 +3,13 @@ from xstate_machine import FSMField, transition from general_ledger.helpers.payment import PaymentHelper -from general_ledger.models.document_status import DocumentStatus -from general_ledger.models.mixins import ( +from general_ledger.django.models.document_status import DocumentStatus +from general_ledger.django.models.mixins import ( LinksMixin, ValidatableModelMixin, EditableMixin, ) -from general_ledger.models.mixins import UuidMixin, CreatedUpdatedMixin +from general_ledger.django.models.mixins import UuidMixin, CreatedUpdatedMixin class PaymentManager(models.Manager): diff --git a/general_ledger/managers/tax_rate.py b/general_ledger/managers/tax_rate.py index 4f25562..8dd2174 100644 --- a/general_ledger/managers/tax_rate.py +++ b/general_ledger/managers/tax_rate.py @@ -2,7 +2,7 @@ from django.db import models -from general_ledger.models.mixins import ( +from general_ledger.django.models.mixins import ( NameDescriptionMixin, UuidMixin, CreatedUpdatedMixin, diff --git a/general_ledger/managers/transaction.py b/general_ledger/managers/transaction.py index e531ceb..700f25a 100644 --- a/general_ledger/managers/transaction.py +++ b/general_ledger/managers/transaction.py @@ -1,13 +1,9 @@ -import logging - -from django.db.models import Sum -from django.utils import timezone - from django.db import models -from django.db import transaction +from django.db import models +from rich.console import Console +from rich.console import ConsoleOptions, RenderResult -from general_ledger.models import Direction -from general_ledger.models.mixins import UuidMixin +from general_ledger.render.utility_rich import model_table_generator class TransactionQuerySet(models.QuerySet): @@ -22,3 +18,8 @@ def locked(self): def unlocked(self): return self.filter(is_locked=False) + + def __rich_console__( + self, console: Console, options: ConsoleOptions + ) -> RenderResult: + yield from model_table_generator(self, self.model) diff --git a/general_ledger/managers/transaction_entry.py b/general_ledger/managers/transaction_entry.py index f8dc26b..8329e39 100644 --- a/general_ledger/managers/transaction_entry.py +++ b/general_ledger/managers/transaction_entry.py @@ -1,16 +1,42 @@ from collections import defaultdict +from datetime import datetime, timezone from decimal import Decimal from django.db import models from django.db.models import Case, When, IntegerField -from django.db.models import F, Func, Value, CharField -from django.db.models.functions import TruncYear, TruncMonth, TruncWeek, TruncDay -from loguru import logger -from rich import inspect from django.db.models import CharField +from django.db.models import F from django.db.models.functions import Cast +from django.db.models.functions import TruncYear, TruncMonth, TruncWeek, TruncDay +from loguru import logger + from general_ledger.managers.mixins import CommonAggregationMixins -from general_ledger.models import Direction +from general_ledger.django.models.direction import Direction + + +class RichDefaultDict(defaultdict): + def __rich_repr__(self): + yield "Count", len(self) + yield "prefix", self.get("prefix", "") + for key in self["meta"]["interval_keys"]: + yield "interval", RichIntervalDict(self[key]) + yield "suffix", self.get("suffix", "") + + +class RichIntervalDict(dict): + def __rich_repr__(self): + yield "interval_key", self["interval_key"] + yield "Count", len(self["entries"]) + yield f"Entries", self["entries"] + yield f"status", self["status"] if "status" in self else "empty" + yield "debit_bd", self["debit_bd"] if "debit_bd" in self else Decimal("0.00"), 0 + yield f"credit_bd", ( + self["credit_bd"] if "credit_bd" in self else Decimal("0.00") + ) + yield f"debit_cd", self["debit_cd"] if "debit_cd" in self else Decimal("0.00") + yield f"credit_cd", ( + self["credit_cd"] if "credit_cd" in self else Decimal("0.00") + ) class EntryQuerySet( @@ -24,10 +50,8 @@ def __init__(self, model=None, query=None, using=None, hints=None): self.credit_bd = Decimal("0.00") def set_balances_bd(self, debit_bd=None, credit_bd=None): - if debit_bd: - self.debit_bd = debit_bd - if credit_bd: - self.credit_bd = credit_bd + self.debit_bd = debit_bd or self.debit_bd + self.credit_bd = credit_bd or self.credit_bd return self def debits(self): @@ -36,6 +60,11 @@ def debits(self): def credits(self): return self.filter(tx_type=Direction.CREDIT) + # @TODO this is dumb. need to create LedgerAccount object + # to encapsulate the idea of an account on a ledger + def balance(self): + return abs(self.debit_total() - self.credit_total()) + def debit_balance(self): """ this is the total from the perspective of the debit side @@ -78,7 +107,7 @@ def is_balanced( self, ): """ - is the entry set balanced? optionally correct for any balance brought down + is the entry set balanced? :param debit_bd: :param credit_bd: :return: @@ -108,17 +137,31 @@ def annotate_by_financial_year(self, start_month=4): def get_grouped_entries(self, interval): if "interval_key" in self.query.annotations: - logger.debug("Already annotated with interval_key") + logger.warning("Already annotated with interval_key") else: - logger.debug("No interval annotation found") + logger.trace("No interval annotation found") + + grouped_entries = RichDefaultDict(dict) + + if interval is None: + first_entry = self.first() + grouped_entries["all"] = { + "entries": self, + "interval_key_dt": ( + first_entry.trans_date + if first_entry + else datetime.fromtimestamp(0, tz=timezone.utc) + ), + "interval_key": "all", + } + grouped_entries["meta"]["interval_keys"] = ["all"] + return grouped_entries entry_set = self.annotate_by_interval(interval) entry_set = entry_set.annotate( interval_key_str=Cast("interval_key", CharField()) ) - grouped_entries = defaultdict(dict) - interval_keys = ( entry_set.values_list("interval_key_str", flat=True) .distinct() @@ -134,7 +177,7 @@ def get_grouped_entries(self, interval): # inspect(interval_keys) # inspect(interval_keys_dt) - grouped_entries["meta"]["interval_keys"] = interval_keys + grouped_entries["meta"]["interval_keys"] = list(interval_keys) for i, interval_key in enumerate(interval_keys): grouped_entries[interval_key]["entries"] = entry_set.filter( @@ -146,10 +189,9 @@ def get_grouped_entries(self, interval): return grouped_entries def __rich_repr__(self): - yield "Transaction", {} yield "Count", self.count() - yield "Entries", str(self) - yield + yield "Db_total", self.debit_total() + yield "Cr_total", self.credit_total() class EntryManager(models.Manager): diff --git a/general_ledger/migrations/0001_initial.py b/general_ledger/migrations/0001_initial.py index 7250ebe..dcfc771 100644 --- a/general_ledger/migrations/0001_initial.py +++ b/general_ledger/migrations/0001_initial.py @@ -1,10 +1,12 @@ -# Generated by Django 5.1.1 on 2024-10-01 02:00 +# Generated by Django 5.1.1 on 2024-11-04 22:44 import datetime import django.core.validators import django.db.models.deletion -import general_ledger.models.file_upload +import general_ledger.django.models.file_upload +import general_ledger.validators import simple_history.models +import timezone_field.fields import uuid import xstate_machine from django.conf import settings @@ -40,23 +42,44 @@ class Migration(migrations.Migration): "category", models.CharField( choices=[ - ("A", "Asset"), - ("L", "Liability"), ("E", "Equity"), ("R", "Revenue"), ("X", "Expense"), + ("NCA", "Non-Current Asset"), + ("CA", "Current Asset"), + ("NCL", "Non-Current Liability"), + ("CL", "Current Liability"), ], - default="A", max_length=20, ), ), + ( + "liquidity", + models.IntegerField( + choices=[ + (100, "Cash or Cash Equivalent"), + (90, "Bank"), + (80, "Accounts Receivable"), + (75, "Accounts Payable"), + (70, "Inventory"), + (65, "Loans Due within 12 months"), + (60, "Other Current Assets"), + (50, "Motor Vehicles"), + (40, "Machinery and Equipment"), + (30, "Fixtures and Fittings"), + (20, "Land and Buildings"), + (0, "None"), + ], + default=0, + ), + ), ("is_deprecated", models.BooleanField(default=False)), ], options={ "verbose_name": "Account Type", "verbose_name_plural": "Account Types", "db_table": "gl_account_type", - "ordering": ["name"], + "ordering": ["category", "liquidity", "name"], }, ), migrations.CreateModel( @@ -75,40 +98,6 @@ class Migration(migrations.Migration): ("last_number", models.PositiveIntegerField(default=0)), ], ), - migrations.CreateModel( - name="FileUpload", - fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ( - "file", - models.FileField( - upload_to=general_ledger.models.file_upload.generate_unique_filename - ), - ), - ("uploaded_at", models.DateTimeField(auto_now_add=True)), - ("name", models.CharField(blank=True, max_length=255, null=True)), - ( - "uploaded_name", - models.CharField(blank=True, max_length=100, null=True), - ), - ( - "original_file_name", - models.CharField(blank=True, max_length=100, null=True), - ), - ("file_type", models.CharField(blank=True, max_length=50, null=True)), - ("is_processed", models.BooleanField(default=False)), - ("processing_error", models.TextField(blank=True, null=True)), - ("sha256", models.CharField(blank=True, max_length=64, null=True)), - ], - ), migrations.CreateModel( name="XeroGlImport", fields=[ @@ -178,7 +167,7 @@ class Migration(migrations.Migration): options={ "verbose_name_plural": "accounts", "db_table": "gl_account", - "ordering": ["name"], + "ordering": ["type__category", "type__liquidity", "name"], }, ), migrations.CreateModel( @@ -196,6 +185,8 @@ class Migration(migrations.Migration): unique=True, ), ), + ("open_date", models.DateField(blank=True, null=True)), + ("close_date", models.DateField(blank=True, null=True)), ("name", models.CharField(max_length=255)), ("account_number", models.CharField(max_length=20, unique=True)), ( @@ -203,6 +194,7 @@ class Migration(migrations.Migration): models.CharField(blank=True, max_length=20, null=True), ), ("sort_code", models.CharField(max_length=20, null=True)), + ("tz", timezone_field.fields.TimeZoneField(default="Europe/London")), ( "type", models.CharField( @@ -245,10 +237,18 @@ class Migration(migrations.Migration): ("updated_at", models.DateTimeField(auto_now=True)), ("balance", models.DecimalField(decimal_places=4, max_digits=10)), ("balance_date", models.DateTimeField()), + ( + "balance_date_tz", + timezone_field.fields.TimeZoneField(default="Europe/London"), + ), ( "balance_source", models.CharField(blank=True, default="", max_length=255), ), + ( + "balance_type", + models.CharField(blank=True, default="", max_length=255), + ), ( "bank", models.ForeignKey( @@ -261,8 +261,31 @@ class Migration(migrations.Migration): "verbose_name": "Bank Balance", "verbose_name_plural": "Bank Balances", "db_table": "gl_bank_balance", + "ordering": ["balance_date"], }, ), + migrations.AddField( + model_name="bank", + name="closing_balance", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to="general_ledger.bankbalance", + ), + ), + migrations.AddField( + model_name="bank", + name="opening_balance", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to="general_ledger.bankbalance", + ), + ), migrations.CreateModel( name="BankStatementLine", fields=[ @@ -277,7 +300,19 @@ class Migration(migrations.Migration): ), ("created_at", models.DateTimeField(auto_now_add=True, null=True)), ("updated_at", models.DateTimeField(auto_now=True)), + ("tz", timezone_field.fields.TimeZoneField(default="Europe/London")), ("date", models.DateField()), + ("datetime", models.DateTimeField()), + ("amount", models.DecimalField(decimal_places=4, max_digits=16)), + ("type", models.CharField(blank=True, max_length=255, null=True)), + ("ofx_fitid", models.CharField(blank=True, max_length=255)), + ("ofx_name", models.CharField(blank=True, max_length=255)), + ("ofx_memo", models.CharField(blank=True, max_length=255)), + ( + "ofx_dtposted", + models.CharField(blank=True, max_length=32, null=True), + ), + ("ofx_trntype", models.CharField(blank=True, max_length=32, null=True)), ( "name", models.CharField( @@ -286,20 +321,18 @@ class Migration(migrations.Migration): ), ), ("payee", models.CharField(blank=True, max_length=255, null=True)), + ( + "transaction_id", + models.CharField(blank=True, max_length=124, null=True), + ), ("hash", models.CharField(blank=True, max_length=50)), ("index", models.PositiveIntegerField(default=0)), - ("amount", models.DecimalField(decimal_places=4, max_digits=16)), ( "balance", models.DecimalField( blank=True, decimal_places=4, max_digits=16, null=True ), ), - ( - "transaction_id", - models.CharField(blank=True, max_length=124, null=True), - ), - ("type", models.CharField(blank=True, max_length=255, null=True)), ("is_matched", models.BooleanField(default=False)), ("is_reconciled", models.BooleanField(default=False)), ( @@ -314,7 +347,7 @@ class Migration(migrations.Migration): "verbose_name": "Bank Statement Line", "verbose_name_plural": "Bank Statement Lines", "db_table": "gl_bank_statement_line", - "ordering": ["date", "name"], + "ordering": ["date", "index"], }, ), migrations.CreateModel( @@ -619,6 +652,54 @@ class Migration(migrations.Migration): "ordering": ["name"], }, ), + migrations.CreateModel( + name="FileUpload", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "file", + models.FileField( + upload_to=general_ledger.django.models.file_upload.generate_unique_filename, + validators=[general_ledger.validators.validate_file_size], + ), + ), + ("uploaded_at", models.DateTimeField(auto_now_add=True)), + ("name", models.CharField(blank=True, max_length=255, null=True)), + ( + "uploaded_name", + models.CharField(blank=True, max_length=100, null=True), + ), + ( + "original_file_name", + models.CharField(blank=True, max_length=100, null=True), + ), + ("file_type", models.CharField(blank=True, max_length=50, null=True)), + ("is_processed", models.BooleanField(default=False)), + ("processing_error", models.TextField(blank=True, null=True)), + ("sha256", models.CharField(blank=True, max_length=64, null=True)), + ( + "book", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="general_ledger.book", + ), + ), + ], + options={ + "verbose_name": "Uploaded file", + "verbose_name_plural": "Uploaded files", + "db_table": "gl_file_upload", + "ordering": ["-uploaded_at"], + }, + ), migrations.CreateModel( name="Ledger", fields=[ @@ -660,7 +741,6 @@ class Migration(migrations.Migration): "verbose_name_plural": "Ledgers", "db_table": "gl_ledger", "ordering": ["name"], - "unique_together": {("name", "book"), ("slug", "book")}, }, ), migrations.CreateModel( @@ -707,7 +787,7 @@ class Migration(migrations.Migration): ), ), ("description", models.CharField(blank=True, max_length=255)), - ("invoice_number", models.CharField(max_length=20, unique=True)), + ("invoice_number", models.CharField(max_length=20)), ("date", models.DateField(default=datetime.date.today)), ("due_date", models.DateField(blank=True, null=True)), ("is_active", models.BooleanField(default=True)), @@ -817,6 +897,79 @@ class Migration(migrations.Migration): "ordering": ["-created_at"], }, ), + migrations.CreateModel( + name="HistoricalPayment", + fields=[ + ("id", models.UUIDField(db_index=True, default=uuid.uuid4)), + ( + "created_at", + models.DateTimeField(blank=True, editable=False, null=True), + ), + ("updated_at", models.DateTimeField(blank=True, editable=False)), + ("date", models.DateField()), + ( + "amount", + models.DecimalField(decimal_places=4, default=0.0, max_digits=16), + ), + ("is_posted", models.BooleanField(default=False)), + ("is_paid", models.BooleanField(default=False)), + ("is_locked", models.BooleanField(default=False)), + ("is_system", models.BooleanField(default=False)), + ( + "state", + xstate_machine.FSMField( + choices=[ + ("DR", "Draft"), + ("RE", "Recorded"), + ("PO", "Posted"), + ("PA", "Partial"), + ("CO", "Complete"), + ("CA", "Cancelled"), + ("VO", "Void"), + ], + default="DR", + max_length=50, + ), + ), + ("history_id", models.AutoField(primary_key=True, serialize=False)), + ("history_date", models.DateTimeField(db_index=True)), + ("history_change_reason", models.CharField(max_length=100, null=True)), + ( + "history_type", + models.CharField( + choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")], + max_length=1, + ), + ), + ( + "history_user", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "ledger", + models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to="general_ledger.ledger", + ), + ), + ], + options={ + "verbose_name": "historical Payment", + "verbose_name_plural": "historical Payments", + "ordering": ("-history_date", "-history_id"), + "get_latest_by": ("history_date", "history_id"), + }, + bases=(simple_history.models.HistoricalChanges, models.Model), + ), migrations.CreateModel( name="Payment", fields=[ @@ -972,7 +1125,7 @@ class Migration(migrations.Migration): ), ), ("description", models.CharField(blank=True, max_length=255)), - ("invoice_number", models.CharField(max_length=20, unique=True)), + ("invoice_number", models.CharField(max_length=20)), ("date", models.DateField(default=datetime.date.today)), ("due_date", models.DateField(blank=True, null=True)), ("is_active", models.BooleanField(default=True)), @@ -1298,7 +1451,7 @@ class Migration(migrations.Migration): ), ), ("description", models.CharField(blank=True, max_length=255)), - ("invoice_number", models.CharField(db_index=True, max_length=20)), + ("invoice_number", models.CharField(max_length=20)), ("date", models.DateField(default=datetime.date.today)), ("due_date", models.DateField(blank=True, null=True)), ("is_active", models.BooleanField(default=True)), @@ -1567,12 +1720,6 @@ class Migration(migrations.Migration): ), ), ("description", models.CharField(max_length=200)), - ( - "post_date", - models.DateTimeField( - blank=True, null=True, verbose_name="post_date" - ), - ), ( "trans_date", models.DateField(blank=True, null=True, verbose_name="trans_date"), @@ -1583,12 +1730,19 @@ class Migration(migrations.Migration): blank=True, null=True, verbose_name="trans_date_time" ), ), + ( + "post_date", + models.DateTimeField( + blank=True, null=True, verbose_name="post_date" + ), + ), ("is_posted", models.BooleanField(default=False)), ("is_locked", models.BooleanField(default=False)), ( "ledger", models.ForeignKey( on_delete=django.db.models.deletion.CASCADE, + related_name="transaction_set", to="general_ledger.ledger", ), ), @@ -1720,6 +1874,7 @@ class Migration(migrations.Migration): "transaction", models.ForeignKey( on_delete=django.db.models.deletion.CASCADE, + related_name="entry_set", to="general_ledger.transaction", ), ), @@ -1769,14 +1924,34 @@ class Migration(migrations.Migration): to=settings.AUTH_USER_MODEL, ), ), - migrations.AlterUniqueTogether( - name="bank", - unique_together={("book", "slug")}, + migrations.AddConstraint( + model_name="bankbalance", + constraint=models.UniqueConstraint( + fields=("bank", "balance_date", "balance_type"), + name="bankbalance_uniq_bank_date_balance_type", + ), + ), + migrations.AddConstraint( + model_name="bankstatementline", + constraint=models.UniqueConstraint( + fields=("bank", "date", "index"), + name="bankstatementline_uniq_bank_date_index", + ), + ), + migrations.AddConstraint( + model_name="bank", + constraint=models.UniqueConstraint( + fields=("book", "slug"), name="bank_account_uniq_bank_slug" + ), ), migrations.AlterUniqueTogether( name="accounttype", unique_together={("slug", "book")}, ), + migrations.AlterUniqueTogether( + name="ledger", + unique_together={("name", "book"), ("slug", "book")}, + ), migrations.AddConstraint( model_name="contact", constraint=models.UniqueConstraint( diff --git a/general_ledger/migrations/0001_squashed_0007_bankbalance_balance_type.py b/general_ledger/migrations/0001_squashed_0007_bankbalance_balance_type.py deleted file mode 100644 index 5d1fe91..0000000 --- a/general_ledger/migrations/0001_squashed_0007_bankbalance_balance_type.py +++ /dev/null @@ -1,1904 +0,0 @@ -# Generated by Django 5.1.1 on 2024-10-03 13:13 - -import datetime -import django.core.validators -import django.db.models.deletion -import general_ledger.models.file_upload -import simple_history.models -import uuid -import xstate_machine -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - replaces = [ - ("general_ledger", "0001_initial"), - ("general_ledger", "0002_alter_historicalinvoice_invoice_number_and_more"), - ("general_ledger", "0003_bankstatementline_ofx_fitid_and_more"), - ("general_ledger", "0004_alter_bankstatementline_options"), - ( - "general_ledger", - "0005_bankstatementline_bankstatementline_uniq_bank_date_index", - ), - ("general_ledger", "0006_alter_bankstatementline_options"), - ("general_ledger", "0007_bankbalance_balance_type"), - ] - - initial = True - - dependencies = [ - ("contenttypes", "0002_remove_content_type_name"), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.CreateModel( - name="AccountType", - fields=[ - ("slug", models.SlugField(blank=True, max_length=22, null=True)), - ( - "id", - models.UUIDField( - default=uuid.uuid4, - primary_key=True, - serialize=False, - unique=True, - ), - ), - ("description", models.TextField(blank=True)), - ("name", models.CharField(max_length=100)), - ( - "category", - models.CharField( - choices=[ - ("A", "Asset"), - ("L", "Liability"), - ("E", "Equity"), - ("R", "Revenue"), - ("X", "Expense"), - ], - default="A", - max_length=20, - ), - ), - ("is_deprecated", models.BooleanField(default=False)), - ], - options={ - "verbose_name": "Account Type", - "verbose_name_plural": "Account Types", - "db_table": "gl_account_type", - "ordering": ["name"], - }, - ), - migrations.CreateModel( - name="DocumentNumberSequence", - fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ("prefix", models.CharField(max_length=10, unique=True)), - ("last_number", models.PositiveIntegerField(default=0)), - ], - ), - migrations.CreateModel( - name="FileUpload", - fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ( - "file", - models.FileField( - upload_to=general_ledger.models.file_upload.generate_unique_filename - ), - ), - ("uploaded_at", models.DateTimeField(auto_now_add=True)), - ("name", models.CharField(blank=True, max_length=255, null=True)), - ( - "uploaded_name", - models.CharField(blank=True, max_length=100, null=True), - ), - ( - "original_file_name", - models.CharField(blank=True, max_length=100, null=True), - ), - ("file_type", models.CharField(blank=True, max_length=50, null=True)), - ("is_processed", models.BooleanField(default=False)), - ("processing_error", models.TextField(blank=True, null=True)), - ("sha256", models.CharField(blank=True, max_length=64, null=True)), - ], - ), - migrations.CreateModel( - name="XeroGlImport", - fields=[ - ( - "id", - models.UUIDField( - default=uuid.uuid4, - primary_key=True, - serialize=False, - unique=True, - ), - ), - ("journal_number", models.CharField(max_length=12)), - ("journal_date", models.DateField()), - ("account_name", models.CharField(max_length=200, null=True)), - ("account_code", models.CharField(max_length=20)), - ("name", models.CharField(max_length=20)), - ("net_amount", models.DecimalField(decimal_places=2, max_digits=12)), - ("gst_amount", models.DecimalField(decimal_places=2, max_digits=12)), - ("gross_amount", models.DecimalField(decimal_places=2, max_digits=12)), - ("tax_code", models.CharField(max_length=10, null=True)), - ("reference", models.CharField(blank=True, max_length=200)), - ("description", models.CharField(blank=True, max_length=200)), - ("bank_account_code", models.CharField(max_length=20, null=True)), - ], - options={ - "verbose_name": "XeroGlImport", - "verbose_name_plural": "XeroGlImports", - "db_table": "gl_xero_gl_import", - "ordering": ["journal_date"], - }, - ), - migrations.CreateModel( - name="Account", - fields=[ - ("slug", models.SlugField(blank=True, max_length=22, null=True)), - ( - "id", - models.UUIDField( - default=uuid.uuid4, - primary_key=True, - serialize=False, - unique=True, - ), - ), - ("created_at", models.DateTimeField(auto_now_add=True, null=True)), - ("updated_at", models.DateTimeField(auto_now=True)), - ("name", models.CharField(max_length=100)), - ("description", models.TextField(blank=True)), - ("code", models.CharField(blank=True, max_length=20)), - ("currency", models.CharField(default="GBP", max_length=3)), - ("is_system", models.BooleanField(default=False)), - ("is_placeholder", models.BooleanField(default=False)), - ("is_hidden", models.BooleanField(default=False)), - ( - "balance", - models.DecimalField(decimal_places=2, default=0.0, max_digits=12), - ), - ( - "type", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - to="general_ledger.accounttype", - ), - ), - ], - options={ - "verbose_name_plural": "accounts", - "db_table": "gl_account", - "ordering": ["name"], - }, - ), - migrations.CreateModel( - name="Bank", - fields=[ - ("slug", models.SlugField(blank=True, max_length=22, null=True)), - ("created_at", models.DateTimeField(auto_now_add=True, null=True)), - ("updated_at", models.DateTimeField(auto_now=True)), - ( - "id", - models.UUIDField( - default=uuid.uuid4, - primary_key=True, - serialize=False, - unique=True, - ), - ), - ("name", models.CharField(max_length=255)), - ("account_number", models.CharField(max_length=20, unique=True)), - ( - "routing_number", - models.CharField(blank=True, max_length=20, null=True), - ), - ("sort_code", models.CharField(max_length=20, null=True)), - ( - "type", - models.CharField( - choices=[("CH", "Checking"), ("SA", "Savings")], - default="CH", - max_length=2, - ), - ), - ("is_active", models.BooleanField(default=True)), - ("is_locked", models.BooleanField(default=False)), - ( - "account", - models.OneToOneField( - on_delete=django.db.models.deletion.CASCADE, - related_name="bank_account", - to="general_ledger.account", - ), - ), - ], - options={ - "verbose_name": "Bank", - "verbose_name_plural": "Banks", - "db_table": "gl_bank", - "ordering": ["name"], - }, - ), - migrations.CreateModel( - name="BankBalance", - fields=[ - ( - "id", - models.UUIDField( - default=uuid.uuid4, - primary_key=True, - serialize=False, - unique=True, - ), - ), - ("created_at", models.DateTimeField(auto_now_add=True, null=True)), - ("updated_at", models.DateTimeField(auto_now=True)), - ("balance", models.DecimalField(decimal_places=4, max_digits=10)), - ("balance_date", models.DateTimeField()), - ( - "balance_source", - models.CharField(blank=True, default="", max_length=255), - ), - ( - "bank", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - to="general_ledger.bank", - ), - ), - ], - options={ - "verbose_name": "Bank Balance", - "verbose_name_plural": "Bank Balances", - "db_table": "gl_bank_balance", - }, - ), - migrations.CreateModel( - name="BankStatementLine", - fields=[ - ( - "id", - models.UUIDField( - default=uuid.uuid4, - primary_key=True, - serialize=False, - unique=True, - ), - ), - ("created_at", models.DateTimeField(auto_now_add=True, null=True)), - ("updated_at", models.DateTimeField(auto_now=True)), - ("date", models.DateField()), - ( - "name", - models.CharField( - help_text="This is the description of the transaction. It is confusingly called 'name' by the OFX specification. It is populated from the description when provided", - max_length=255, - ), - ), - ("payee", models.CharField(blank=True, max_length=255, null=True)), - ("hash", models.CharField(blank=True, max_length=50)), - ("index", models.PositiveIntegerField(default=0)), - ("amount", models.DecimalField(decimal_places=4, max_digits=16)), - ( - "balance", - models.DecimalField( - blank=True, decimal_places=4, max_digits=16, null=True - ), - ), - ( - "transaction_id", - models.CharField(blank=True, max_length=124, null=True), - ), - ("type", models.CharField(blank=True, max_length=255, null=True)), - ("is_matched", models.BooleanField(default=False)), - ("is_reconciled", models.BooleanField(default=False)), - ( - "bank", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - to="general_ledger.bank", - ), - ), - ], - options={ - "verbose_name": "Bank Statement Line", - "verbose_name_plural": "Bank Statement Lines", - "db_table": "gl_bank_statement_line", - "ordering": ["date", "name"], - }, - ), - migrations.CreateModel( - name="Book", - fields=[ - ("slug", models.SlugField(blank=True, max_length=22, null=True)), - ( - "id", - models.UUIDField( - default=uuid.uuid4, - primary_key=True, - serialize=False, - unique=True, - ), - ), - ("created_at", models.DateTimeField(auto_now_add=True, null=True)), - ("updated_at", models.DateTimeField(auto_now=True)), - ( - "sales_tax_inclusive", - models.CharField( - blank=True, - choices=[ - ("Inc", "Inclusive"), - ("Exc", "Exclusive"), - ("Non", "None"), - ], - default="Exc", - max_length=3, - null=True, - ), - ), - ( - "purchases_tax_inclusive", - models.CharField( - blank=True, - choices=[ - ("Inc", "Inclusive"), - ("Exc", "Exclusive"), - ("Non", "None"), - ], - default="Exc", - max_length=3, - null=True, - ), - ), - ( - "invoice_sequence", - models.PositiveIntegerField( - default=1, help_text="The next number to use for invoices" - ), - ), - ( - "invoice_prefix", - models.CharField( - default="INV", - help_text="The prefix to use for invoices", - max_length=10, - ), - ), - ( - "bill_sequence", - models.PositiveIntegerField( - default=1, help_text="The next number to use for bills" - ), - ), - ( - "bill_prefix", - models.CharField( - default="BILL", - help_text="The prefix to use for Bill numbers", - max_length=10, - ), - ), - ( - "name", - models.CharField( - help_text="The name must be at least 3 characters long.", - max_length=70, - unique=True, - validators=[django.core.validators.MinLengthValidator(3)], - verbose_name="Book Name", - ), - ), - ("is_demo", models.BooleanField(default=False)), - ("is_vat_registered", models.BooleanField(default=False)), - ( - "business_name", - models.CharField(blank=True, max_length=100, null=True), - ), - ( - "business_address", - models.CharField(blank=True, max_length=100, null=True), - ), - ("vat_number", models.CharField(blank=True, max_length=12, null=True)), - ( - "company_number", - models.CharField(blank=True, max_length=12, null=True), - ), - ("business_website", models.URLField(blank=True, null=True)), - ( - "business_phone", - models.CharField(blank=True, max_length=20, null=True), - ), - ( - "business_email", - models.EmailField(blank=True, max_length=254, null=True), - ), - ( - "accounts_payable", - models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.CASCADE, - related_name="+", - to="general_ledger.account", - ), - ), - ( - "accounts_receivable", - models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.CASCADE, - related_name="+", - to="general_ledger.account", - ), - ), - ( - "owner", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - to=settings.AUTH_USER_MODEL, - ), - ), - ( - "purchases_account", - models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.CASCADE, - related_name="+", - to="general_ledger.account", - ), - ), - ( - "sales_account", - models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.CASCADE, - related_name="+", - to="general_ledger.account", - ), - ), - ], - options={ - "verbose_name_plural": "books", - "db_table": "gl_book", - }, - ), - migrations.AddField( - model_name="bank", - name="book", - field=models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, to="general_ledger.book" - ), - ), - migrations.AddField( - model_name="accounttype", - name="book", - field=models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, to="general_ledger.book" - ), - ), - migrations.CreateModel( - name="ChartOfAccounts", - fields=[ - ("slug", models.SlugField(blank=True, max_length=22, null=True)), - ( - "id", - models.UUIDField( - default=uuid.uuid4, - primary_key=True, - serialize=False, - unique=True, - ), - ), - ("name", models.CharField(max_length=255)), - ("description", models.TextField(blank=True, null=True)), - ("is_system", models.BooleanField(default=False)), - ("is_placeholder", models.BooleanField(default=False)), - ("is_hidden", models.BooleanField(default=False)), - ( - "book", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - to="general_ledger.book", - ), - ), - ], - options={ - "verbose_name": "Chart of Accounts", - "verbose_name_plural": "Charts of Accounts", - "db_table": "gl_coa", - "ordering": ["name"], - }, - ), - migrations.AddField( - model_name="account", - name="coa", - field=models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - to="general_ledger.chartofaccounts", - ), - ), - migrations.CreateModel( - name="Contact", - fields=[ - ( - "id", - models.UUIDField( - default=uuid.uuid4, - primary_key=True, - serialize=False, - unique=True, - ), - ), - ("created_at", models.DateTimeField(auto_now_add=True, null=True)), - ("updated_at", models.DateTimeField(auto_now=True)), - ("name", models.CharField(max_length=100)), - ("address", models.CharField(blank=True, max_length=100)), - ("phone", models.CharField(blank=True, max_length=100)), - ("email", models.EmailField(blank=True, max_length=254)), - ( - "company_number", - models.CharField(blank=True, max_length=12, null=True), - ), - ("is_customer", models.BooleanField(default=True)), - ("is_supplier", models.BooleanField(default=False)), - ("is_demo", models.BooleanField(default=False)), - ("is_vat_registered", models.BooleanField(default=False)), - ("vat_number", models.CharField(blank=True, max_length=12, null=True)), - ( - "sales_tax_inclusive", - models.CharField( - blank=True, - choices=[ - ("Inc", "Inclusive"), - ("Exc", "Exclusive"), - ("Non", "None"), - ], - default="Exc", - max_length=3, - null=True, - ), - ), - ( - "purchases_tax_inclusive", - models.CharField( - blank=True, - choices=[ - ("Inc", "Inclusive"), - ("Exc", "Exclusive"), - ("Non", "None"), - ], - default="Exc", - max_length=3, - null=True, - ), - ), - ( - "book", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - to="general_ledger.book", - ), - ), - ( - "purchases_account", - models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.CASCADE, - related_name="purchases_contacts", - to="general_ledger.account", - ), - ), - ( - "sales_account", - models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.CASCADE, - related_name="sales_contacts", - to="general_ledger.account", - ), - ), - ], - options={ - "verbose_name_plural": "Contacts", - "db_table": "gl_contact", - "ordering": ["name"], - }, - ), - migrations.CreateModel( - name="Ledger", - fields=[ - ("slug", models.SlugField(blank=True, max_length=22, null=True)), - ( - "id", - models.UUIDField( - default=uuid.uuid4, - primary_key=True, - serialize=False, - unique=True, - ), - ), - ("created_at", models.DateTimeField(auto_now_add=True, null=True)), - ("updated_at", models.DateTimeField(auto_now=True)), - ("name", models.CharField(max_length=255)), - ("description", models.TextField(blank=True, null=True)), - ("is_posted", models.BooleanField(default=False)), - ("is_locked", models.BooleanField(default=False)), - ("is_system", models.BooleanField(default=False)), - ("is_hidden", models.BooleanField(default=False)), - ( - "book", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - to="general_ledger.book", - ), - ), - ( - "coa", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - to="general_ledger.chartofaccounts", - ), - ), - ], - options={ - "verbose_name": "Ledger", - "verbose_name_plural": "Ledgers", - "db_table": "gl_ledger", - "ordering": ["name"], - "unique_together": {("name", "book"), ("slug", "book")}, - }, - ), - migrations.CreateModel( - name="Invoice", - fields=[ - ("slug", models.SlugField(blank=True, max_length=22, null=True)), - ( - "id", - models.UUIDField( - default=uuid.uuid4, - primary_key=True, - serialize=False, - unique=True, - ), - ), - ("created_at", models.DateTimeField(auto_now_add=True, null=True)), - ("updated_at", models.DateTimeField(auto_now=True)), - ( - "sales_tax_inclusive", - models.CharField( - blank=True, - choices=[ - ("Inc", "Inclusive"), - ("Exc", "Exclusive"), - ("Non", "None"), - ], - default="Exc", - max_length=3, - null=True, - ), - ), - ( - "purchases_tax_inclusive", - models.CharField( - blank=True, - choices=[ - ("Inc", "Inclusive"), - ("Exc", "Exclusive"), - ("Non", "None"), - ], - default="Exc", - max_length=3, - null=True, - ), - ), - ("description", models.CharField(blank=True, max_length=255)), - ("invoice_number", models.CharField(max_length=20, unique=True)), - ("date", models.DateField(default=datetime.date.today)), - ("due_date", models.DateField(blank=True, null=True)), - ("is_active", models.BooleanField(default=True)), - ("is_locked", models.BooleanField(default=False)), - ("is_system", models.BooleanField(default=False)), - ( - "status", - models.CharField( - choices=[ - ("DR", "Draft"), - ("AA", "Awaiting Approval"), - ("AP", "Awaiting Payment"), - ("PD", "Paid"), - ], - default="DR", - max_length=2, - ), - ), - ( - "tax_inclusive", - models.CharField( - blank=True, - choices=[ - ("Inc", "Inclusive"), - ("Exc", "Exclusive"), - ("Non", "None"), - ], - default="Exc", - max_length=3, - null=True, - ), - ), - ( - "total_amount", - models.DecimalField(decimal_places=4, default=0, max_digits=20), - ), - ( - "accounts_payable", - models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.CASCADE, - related_name="+", - to="general_ledger.account", - ), - ), - ( - "accounts_receivable", - models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.CASCADE, - related_name="+", - to="general_ledger.account", - ), - ), - ( - "bank_account", - models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.CASCADE, - related_name="bank_accounts", - to="general_ledger.bank", - ), - ), - ( - "contact", - models.ForeignKey( - limit_choices_to={"is_customer": True}, - on_delete=django.db.models.deletion.CASCADE, - to="general_ledger.contact", - ), - ), - ( - "purchases_account", - models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.CASCADE, - related_name="+", - to="general_ledger.account", - ), - ), - ( - "sales_account", - models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.CASCADE, - related_name="+", - to="general_ledger.account", - ), - ), - ( - "ledger", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - to="general_ledger.ledger", - ), - ), - ], - options={ - "verbose_name": "Invoice", - "verbose_name_plural": "Invoices", - "db_table": "gl_invoice", - "ordering": ["-created_at"], - }, - ), - migrations.CreateModel( - name="Payment", - fields=[ - ( - "id", - models.UUIDField( - default=uuid.uuid4, - primary_key=True, - serialize=False, - unique=True, - ), - ), - ("created_at", models.DateTimeField(auto_now_add=True, null=True)), - ("updated_at", models.DateTimeField(auto_now=True)), - ("date", models.DateField()), - ( - "amount", - models.DecimalField(decimal_places=4, default=0.0, max_digits=16), - ), - ("is_posted", models.BooleanField(default=False)), - ("is_paid", models.BooleanField(default=False)), - ("is_locked", models.BooleanField(default=False)), - ("is_system", models.BooleanField(default=False)), - ( - "state", - xstate_machine.FSMField( - choices=[ - ("DR", "Draft"), - ("RE", "Recorded"), - ("PO", "Posted"), - ("PA", "Partial"), - ("CO", "Complete"), - ("CA", "Cancelled"), - ("VO", "Void"), - ], - default="DR", - max_length=50, - ), - ), - ( - "ledger", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - to="general_ledger.ledger", - ), - ), - ], - options={ - "verbose_name": "Payment", - "verbose_name_plural": "Payments", - "db_table": "gl_payment", - "ordering": ["-created_at"], - }, - ), - migrations.CreateModel( - name="PaymentItem", - fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ("amount", models.DecimalField(decimal_places=4, max_digits=16)), - ("from_object_id", models.UUIDField()), - ("to_object_id", models.UUIDField()), - ( - "from_account", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="+", - to="general_ledger.account", - ), - ), - ( - "from_content_type", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="payment_from", - to="contenttypes.contenttype", - ), - ), - ( - "payment", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="items", - to="general_ledger.payment", - ), - ), - ( - "to_account", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="+", - to="general_ledger.account", - ), - ), - ( - "to_content_type", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="payment_to", - to="contenttypes.contenttype", - ), - ), - ], - ), - migrations.CreateModel( - name="PurchaseInvoice", - fields=[ - ("slug", models.SlugField(blank=True, max_length=22, null=True)), - ( - "id", - models.UUIDField( - default=uuid.uuid4, - primary_key=True, - serialize=False, - unique=True, - ), - ), - ("created_at", models.DateTimeField(auto_now_add=True, null=True)), - ("updated_at", models.DateTimeField(auto_now=True)), - ( - "sales_tax_inclusive", - models.CharField( - blank=True, - choices=[ - ("Inc", "Inclusive"), - ("Exc", "Exclusive"), - ("Non", "None"), - ], - default="Exc", - max_length=3, - null=True, - ), - ), - ( - "purchases_tax_inclusive", - models.CharField( - blank=True, - choices=[ - ("Inc", "Inclusive"), - ("Exc", "Exclusive"), - ("Non", "None"), - ], - default="Exc", - max_length=3, - null=True, - ), - ), - ("description", models.CharField(blank=True, max_length=255)), - ("invoice_number", models.CharField(max_length=20, unique=True)), - ("date", models.DateField(default=datetime.date.today)), - ("due_date", models.DateField(blank=True, null=True)), - ("is_active", models.BooleanField(default=True)), - ("is_locked", models.BooleanField(default=False)), - ("is_system", models.BooleanField(default=False)), - ( - "tax_inclusive", - models.CharField( - blank=True, - choices=[ - ("Inc", "Inclusive"), - ("Exc", "Exclusive"), - ("Non", "None"), - ], - default="Exc", - max_length=3, - null=True, - ), - ), - ( - "amount", - models.DecimalField(decimal_places=4, default=0, max_digits=10), - ), - ( - "state", - xstate_machine.FSMField( - choices=[ - ("DR", "Draft"), - ("RE", "Recorded"), - ("PO", "Posted"), - ("PA", "Partial"), - ("CO", "Complete"), - ("CA", "Cancelled"), - ("VO", "Void"), - ], - default="DR", - max_length=50, - ), - ), - ( - "accounts_payable", - models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.CASCADE, - related_name="+", - to="general_ledger.account", - ), - ), - ( - "accounts_receivable", - models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.CASCADE, - related_name="+", - to="general_ledger.account", - ), - ), - ( - "contact", - models.ForeignKey( - limit_choices_to={"is_customer": True}, - on_delete=django.db.models.deletion.CASCADE, - to="general_ledger.contact", - ), - ), - ( - "ledger", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - to="general_ledger.ledger", - ), - ), - ( - "purchases_account", - models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.CASCADE, - related_name="+", - to="general_ledger.account", - ), - ), - ( - "sales_account", - models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.CASCADE, - related_name="+", - to="general_ledger.account", - ), - ), - ], - options={ - "verbose_name": "Bill", - "verbose_name_plural": "Bills", - "db_table": "gl_bill", - }, - ), - migrations.CreateModel( - name="TaxRate", - fields=[ - ("slug", models.SlugField(blank=True, max_length=22, null=True)), - ( - "id", - models.UUIDField( - default=uuid.uuid4, - primary_key=True, - serialize=False, - unique=True, - ), - ), - ("created_at", models.DateTimeField(auto_now_add=True, null=True)), - ("updated_at", models.DateTimeField(auto_now=True)), - ("description", models.TextField(blank=True)), - ("name", models.CharField(max_length=70)), - ( - "short_name", - models.CharField(max_length=6, verbose_name="Short Name"), - ), - ( - "rate", - models.DecimalField(decimal_places=4, default=0.0, max_digits=8), - ), - ("is_visible", models.BooleanField(default=True)), - ("is_active", models.BooleanField(default=True)), - ("is_default", models.BooleanField(default=False)), - ("effective_date", models.DateField(blank=True, null=True)), - ("end_date", models.DateField(blank=True, null=True)), - ( - "book", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - to="general_ledger.book", - ), - ), - ], - options={ - "verbose_name": "Tax Rate", - "verbose_name_plural": "Tax Rates", - "db_table": "gl_tax_rate", - }, - ), - migrations.CreateModel( - name="PurchaseInvoiceLine", - fields=[ - ( - "id", - models.UUIDField( - default=uuid.uuid4, - primary_key=True, - serialize=False, - unique=True, - ), - ), - ("description", models.TextField(blank=True)), - ("name", models.CharField(blank=True, max_length=100)), - ("quantity", models.PositiveIntegerField()), - ("unit_price", models.DecimalField(decimal_places=4, max_digits=16)), - ("order", models.PositiveIntegerField(default=0)), - ( - "account", - models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.CASCADE, - to="general_ledger.account", - ), - ), - ( - "invoice", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="lines", - to="general_ledger.purchaseinvoice", - ), - ), - ( - "vat_rate", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - to="general_ledger.taxrate", - ), - ), - ], - options={ - "verbose_name": "Bill Line Item", - "verbose_name_plural": "Bill Line Item", - "db_table": "gl_purchaseinvoice_line_item", - }, - ), - migrations.AddField( - model_name="purchaseinvoice", - name="purchases_tax_rate", - field=models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.CASCADE, - related_name="+", - to="general_ledger.taxrate", - ), - ), - migrations.AddField( - model_name="purchaseinvoice", - name="sales_tax_rate", - field=models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.CASCADE, - related_name="+", - to="general_ledger.taxrate", - ), - ), - migrations.CreateModel( - name="InvoiceLine", - fields=[ - ( - "id", - models.UUIDField( - default=uuid.uuid4, - primary_key=True, - serialize=False, - unique=True, - ), - ), - ("description", models.TextField(blank=True)), - ("name", models.CharField(blank=True, max_length=100)), - ("quantity", models.DecimalField(decimal_places=0, max_digits=10)), - ("unit_price", models.DecimalField(decimal_places=4, max_digits=16)), - ("order", models.PositiveIntegerField(default=0)), - ( - "account", - models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.CASCADE, - to="general_ledger.account", - ), - ), - ( - "invoice", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="invoice_lines", - to="general_ledger.invoice", - ), - ), - ( - "vat_rate", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - to="general_ledger.taxrate", - ), - ), - ], - options={ - "verbose_name": "Invoice Line Item", - "verbose_name_plural": "Invoice Line Items", - "db_table": "gl_line_item", - "ordering": ["order"], - }, - ), - migrations.AddField( - model_name="invoice", - name="purchases_tax_rate", - field=models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.CASCADE, - related_name="+", - to="general_ledger.taxrate", - ), - ), - migrations.AddField( - model_name="invoice", - name="sales_tax_rate", - field=models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.CASCADE, - related_name="+", - to="general_ledger.taxrate", - ), - ), - migrations.CreateModel( - name="HistoricalInvoice", - fields=[ - ("slug", models.SlugField(blank=True, max_length=22, null=True)), - ("id", models.UUIDField(db_index=True, default=uuid.uuid4)), - ( - "created_at", - models.DateTimeField(blank=True, editable=False, null=True), - ), - ("updated_at", models.DateTimeField(blank=True, editable=False)), - ( - "sales_tax_inclusive", - models.CharField( - blank=True, - choices=[ - ("Inc", "Inclusive"), - ("Exc", "Exclusive"), - ("Non", "None"), - ], - default="Exc", - max_length=3, - null=True, - ), - ), - ( - "purchases_tax_inclusive", - models.CharField( - blank=True, - choices=[ - ("Inc", "Inclusive"), - ("Exc", "Exclusive"), - ("Non", "None"), - ], - default="Exc", - max_length=3, - null=True, - ), - ), - ("description", models.CharField(blank=True, max_length=255)), - ("invoice_number", models.CharField(db_index=True, max_length=20)), - ("date", models.DateField(default=datetime.date.today)), - ("due_date", models.DateField(blank=True, null=True)), - ("is_active", models.BooleanField(default=True)), - ("is_locked", models.BooleanField(default=False)), - ("is_system", models.BooleanField(default=False)), - ( - "status", - models.CharField( - choices=[ - ("DR", "Draft"), - ("AA", "Awaiting Approval"), - ("AP", "Awaiting Payment"), - ("PD", "Paid"), - ], - default="DR", - max_length=2, - ), - ), - ( - "tax_inclusive", - models.CharField( - blank=True, - choices=[ - ("Inc", "Inclusive"), - ("Exc", "Exclusive"), - ("Non", "None"), - ], - default="Exc", - max_length=3, - null=True, - ), - ), - ( - "total_amount", - models.DecimalField(decimal_places=4, default=0, max_digits=20), - ), - ("history_id", models.AutoField(primary_key=True, serialize=False)), - ("history_date", models.DateTimeField(db_index=True)), - ("history_change_reason", models.CharField(max_length=100, null=True)), - ( - "history_type", - models.CharField( - choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")], - max_length=1, - ), - ), - ( - "accounts_payable", - models.ForeignKey( - blank=True, - db_constraint=False, - null=True, - on_delete=django.db.models.deletion.DO_NOTHING, - related_name="+", - to="general_ledger.account", - ), - ), - ( - "accounts_receivable", - models.ForeignKey( - blank=True, - db_constraint=False, - null=True, - on_delete=django.db.models.deletion.DO_NOTHING, - related_name="+", - to="general_ledger.account", - ), - ), - ( - "bank_account", - models.ForeignKey( - blank=True, - db_constraint=False, - null=True, - on_delete=django.db.models.deletion.DO_NOTHING, - related_name="+", - to="general_ledger.bank", - ), - ), - ( - "contact", - models.ForeignKey( - blank=True, - db_constraint=False, - limit_choices_to={"is_customer": True}, - null=True, - on_delete=django.db.models.deletion.DO_NOTHING, - related_name="+", - to="general_ledger.contact", - ), - ), - ( - "history_user", - models.ForeignKey( - null=True, - on_delete=django.db.models.deletion.SET_NULL, - related_name="+", - to=settings.AUTH_USER_MODEL, - ), - ), - ( - "purchases_account", - models.ForeignKey( - blank=True, - db_constraint=False, - null=True, - on_delete=django.db.models.deletion.DO_NOTHING, - related_name="+", - to="general_ledger.account", - ), - ), - ( - "sales_account", - models.ForeignKey( - blank=True, - db_constraint=False, - null=True, - on_delete=django.db.models.deletion.DO_NOTHING, - related_name="+", - to="general_ledger.account", - ), - ), - ( - "ledger", - models.ForeignKey( - blank=True, - db_constraint=False, - null=True, - on_delete=django.db.models.deletion.DO_NOTHING, - related_name="+", - to="general_ledger.ledger", - ), - ), - ( - "purchases_tax_rate", - models.ForeignKey( - blank=True, - db_constraint=False, - null=True, - on_delete=django.db.models.deletion.DO_NOTHING, - related_name="+", - to="general_ledger.taxrate", - ), - ), - ( - "sales_tax_rate", - models.ForeignKey( - blank=True, - db_constraint=False, - null=True, - on_delete=django.db.models.deletion.DO_NOTHING, - related_name="+", - to="general_ledger.taxrate", - ), - ), - ], - options={ - "verbose_name": "historical Invoice", - "verbose_name_plural": "historical Invoices", - "ordering": ("-history_date", "-history_id"), - "get_latest_by": ("history_date", "history_id"), - }, - bases=(simple_history.models.HistoricalChanges, models.Model), - ), - migrations.AddField( - model_name="contact", - name="purchases_tax_rate", - field=models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.CASCADE, - related_name="purchases_tax_contacts", - to="general_ledger.taxrate", - ), - ), - migrations.AddField( - model_name="contact", - name="sales_tax_rate", - field=models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.CASCADE, - related_name="sales_tax_contacts", - to="general_ledger.taxrate", - ), - ), - migrations.AddField( - model_name="book", - name="purchases_tax_rate", - field=models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.CASCADE, - related_name="+", - to="general_ledger.taxrate", - ), - ), - migrations.AddField( - model_name="book", - name="sales_tax_rate", - field=models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.CASCADE, - related_name="+", - to="general_ledger.taxrate", - ), - ), - migrations.AddField( - model_name="account", - name="tax_rate", - field=models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, to="general_ledger.taxrate" - ), - ), - migrations.CreateModel( - name="TaxType", - fields=[ - ("slug", models.SlugField(blank=True, max_length=22, null=True)), - ( - "id", - models.UUIDField( - default=uuid.uuid4, - primary_key=True, - serialize=False, - unique=True, - ), - ), - ("created_at", models.DateTimeField(auto_now_add=True, null=True)), - ("updated_at", models.DateTimeField(auto_now=True)), - ("name", models.CharField(max_length=50)), - ("is_active", models.BooleanField(default=True)), - ("is_visible", models.BooleanField(default=True)), - ("is_deprecated", models.BooleanField(default=False)), - ( - "book", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - to="general_ledger.book", - ), - ), - ], - options={ - "verbose_name": "Tax Type", - "verbose_name_plural": "Tax Type", - "db_table": "gl_tax_type", - }, - ), - migrations.AddField( - model_name="taxrate", - name="tax_type", - field=models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, to="general_ledger.taxtype" - ), - ), - migrations.CreateModel( - name="Transaction", - fields=[ - ( - "id", - models.UUIDField( - default=uuid.uuid4, - primary_key=True, - serialize=False, - unique=True, - ), - ), - ("description", models.CharField(max_length=200)), - ( - "post_date", - models.DateTimeField( - blank=True, null=True, verbose_name="post_date" - ), - ), - ( - "trans_date", - models.DateField(blank=True, null=True, verbose_name="trans_date"), - ), - ( - "trans_datetime", - models.DateTimeField( - blank=True, null=True, verbose_name="trans_date_time" - ), - ), - ("is_posted", models.BooleanField(default=False)), - ("is_locked", models.BooleanField(default=False)), - ( - "ledger", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - to="general_ledger.ledger", - ), - ), - ], - options={ - "verbose_name": "Transaction", - "verbose_name_plural": "Transactions", - "db_table": "gl_transaction", - "ordering": ["trans_date"], - }, - ), - migrations.CreateModel( - name="PaymentTransaction", - fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ( - "payment", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - to="general_ledger.payment", - ), - ), - ( - "transaction", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - to="general_ledger.transaction", - ), - ), - ], - ), - migrations.AddField( - model_name="payment", - name="transactions", - field=models.ManyToManyField( - related_name="payments", - through="general_ledger.PaymentTransaction", - to="general_ledger.transaction", - ), - ), - migrations.CreateModel( - name="InvoiceTransaction", - fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ( - "invoice", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - to="general_ledger.invoice", - ), - ), - ( - "transaction", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - to="general_ledger.transaction", - ), - ), - ], - ), - migrations.AddField( - model_name="invoice", - name="transactions", - field=models.ManyToManyField( - related_name="invoices", - through="general_ledger.InvoiceTransaction", - to="general_ledger.transaction", - ), - ), - migrations.CreateModel( - name="Entry", - fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ("created_at", models.DateTimeField(auto_now_add=True, null=True)), - ("updated_at", models.DateTimeField(auto_now=True)), - ("description", models.CharField(max_length=200)), - ( - "amount", - models.DecimalField( - decimal_places=4, - default=0.0, - help_text="Account of the transaction.", - max_digits=20, - validators=[django.core.validators.MinValueValidator(0)], - verbose_name="Amount", - ), - ), - ( - "tx_type", - models.CharField( - choices=[("Dr", "Debit"), ("Cr", "Credit")], - default="Dr", - max_length=10, - verbose_name="Tx Type", - ), - ), - ( - "account", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - to="general_ledger.account", - ), - ), - ( - "transaction", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - to="general_ledger.transaction", - ), - ), - ], - options={ - "verbose_name": "Entry", - "verbose_name_plural": "Transaction Entries", - "db_table": "gl_transaction_entry", - "ordering": ["transaction__trans_date"], - }, - ), - migrations.CreateModel( - name="UserBookAccess", - fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ("is_read_only", models.BooleanField(default=False)), - ( - "book", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - to="general_ledger.book", - ), - ), - ( - "user", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - to=settings.AUTH_USER_MODEL, - ), - ), - ], - ), - migrations.AddField( - model_name="book", - name="users", - field=models.ManyToManyField( - related_name="accessible_books", - through="general_ledger.UserBookAccess", - to=settings.AUTH_USER_MODEL, - ), - ), - migrations.AlterUniqueTogether( - name="bank", - unique_together={("book", "slug")}, - ), - migrations.AlterUniqueTogether( - name="accounttype", - unique_together={("slug", "book")}, - ), - migrations.AddConstraint( - model_name="contact", - constraint=models.UniqueConstraint( - fields=("name", "book"), name="name_book_uniq" - ), - ), - migrations.AddConstraint( - model_name="account", - constraint=models.UniqueConstraint( - fields=("name", "coa"), name="name_coa_uniq" - ), - ), - migrations.AddConstraint( - model_name="account", - constraint=models.UniqueConstraint( - fields=("slug", "coa"), name="slug_coa_uniq" - ), - ), - migrations.AlterUniqueTogether( - name="taxtype", - unique_together={("name", "book"), ("slug", "book")}, - ), - migrations.AddConstraint( - model_name="taxrate", - constraint=models.UniqueConstraint( - fields=("slug", "tax_type"), name="slug_tax_type_uniq" - ), - ), - migrations.AddConstraint( - model_name="taxrate", - constraint=models.UniqueConstraint( - fields=("slug", "book"), name="tax_rate_uniq_slug_book" - ), - ), - migrations.AddConstraint( - model_name="taxrate", - constraint=models.UniqueConstraint( - fields=("name", "book"), name="tax_rate_uniq_name_book" - ), - ), - migrations.AlterUniqueTogether( - name="paymenttransaction", - unique_together={("payment", "transaction")}, - ), - migrations.AlterUniqueTogether( - name="invoicetransaction", - unique_together={("invoice", "transaction")}, - ), - migrations.AlterUniqueTogether( - name="userbookaccess", - unique_together={("user", "book")}, - ), - migrations.AddConstraint( - model_name="book", - constraint=models.UniqueConstraint( - fields=("owner", "slug"), name="book_uniq_owner_slug" - ), - ), - migrations.AlterField( - model_name="historicalinvoice", - name="invoice_number", - field=models.CharField(max_length=20), - ), - migrations.AlterField( - model_name="invoice", - name="invoice_number", - field=models.CharField(max_length=20), - ), - migrations.AlterField( - model_name="purchaseinvoice", - name="invoice_number", - field=models.CharField(max_length=20), - ), - migrations.AddField( - model_name="bankstatementline", - name="ofx_fitid", - field=models.CharField(blank=True, max_length=255), - ), - migrations.AddField( - model_name="bankstatementline", - name="ofx_memo", - field=models.CharField(blank=True, max_length=255), - ), - migrations.AlterModelOptions( - name="bankstatementline", - options={ - "ordering": ["date", "index", "name"], - "verbose_name": "Bank Statement Line", - "verbose_name_plural": "Bank Statement Lines", - }, - ), - migrations.AddConstraint( - model_name="bankstatementline", - constraint=models.UniqueConstraint( - fields=("bank", "date", "index"), - name="bankstatementline_uniq_bank_date_index", - ), - ), - migrations.AlterModelOptions( - name="bankstatementline", - options={ - "ordering": ["date", "index"], - "verbose_name": "Bank Statement Line", - "verbose_name_plural": "Bank Statement Lines", - }, - ), - migrations.AddField( - model_name="bankbalance", - name="balance_type", - field=models.CharField(blank=True, default="", max_length=255), - ), - ] diff --git a/general_ledger/migrations/0002_alter_historicalinvoice_invoice_number_and_more.py b/general_ledger/migrations/0002_alter_historicalinvoice_invoice_number_and_more.py deleted file mode 100644 index 9a65ed6..0000000 --- a/general_ledger/migrations/0002_alter_historicalinvoice_invoice_number_and_more.py +++ /dev/null @@ -1,28 +0,0 @@ -# Generated by Django 5.1.1 on 2024-10-01 03:34 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("general_ledger", "0001_initial"), - ] - - operations = [ - migrations.AlterField( - model_name="historicalinvoice", - name="invoice_number", - field=models.CharField(max_length=20), - ), - migrations.AlterField( - model_name="invoice", - name="invoice_number", - field=models.CharField(max_length=20), - ), - migrations.AlterField( - model_name="purchaseinvoice", - name="invoice_number", - field=models.CharField(max_length=20), - ), - ] diff --git a/general_ledger/migrations/0003_bankstatementline_ofx_fitid_and_more.py b/general_ledger/migrations/0003_bankstatementline_ofx_fitid_and_more.py deleted file mode 100644 index bfe4c30..0000000 --- a/general_ledger/migrations/0003_bankstatementline_ofx_fitid_and_more.py +++ /dev/null @@ -1,23 +0,0 @@ -# Generated by Django 5.1.1 on 2024-10-01 21:00 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("general_ledger", "0002_alter_historicalinvoice_invoice_number_and_more"), - ] - - operations = [ - migrations.AddField( - model_name="bankstatementline", - name="ofx_fitid", - field=models.CharField(blank=True, max_length=255), - ), - migrations.AddField( - model_name="bankstatementline", - name="ofx_memo", - field=models.CharField(blank=True, max_length=255), - ), - ] diff --git a/general_ledger/migrations/0004_alter_bankstatementline_options.py b/general_ledger/migrations/0004_alter_bankstatementline_options.py deleted file mode 100644 index bb64b1a..0000000 --- a/general_ledger/migrations/0004_alter_bankstatementline_options.py +++ /dev/null @@ -1,21 +0,0 @@ -# Generated by Django 5.1.1 on 2024-10-02 00:01 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ("general_ledger", "0003_bankstatementline_ofx_fitid_and_more"), - ] - - operations = [ - migrations.AlterModelOptions( - name="bankstatementline", - options={ - "ordering": ["date", "index", "name"], - "verbose_name": "Bank Statement Line", - "verbose_name_plural": "Bank Statement Lines", - }, - ), - ] diff --git a/general_ledger/migrations/0005_bankstatementline_bankstatementline_uniq_bank_date_index.py b/general_ledger/migrations/0005_bankstatementline_bankstatementline_uniq_bank_date_index.py deleted file mode 100644 index 0c42437..0000000 --- a/general_ledger/migrations/0005_bankstatementline_bankstatementline_uniq_bank_date_index.py +++ /dev/null @@ -1,20 +0,0 @@ -# Generated by Django 5.1.1 on 2024-10-02 00:03 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("general_ledger", "0004_alter_bankstatementline_options"), - ] - - operations = [ - migrations.AddConstraint( - model_name="bankstatementline", - constraint=models.UniqueConstraint( - fields=("bank", "date", "index"), - name="bankstatementline_uniq_bank_date_index", - ), - ), - ] diff --git a/general_ledger/migrations/0006_alter_bankstatementline_options.py b/general_ledger/migrations/0006_alter_bankstatementline_options.py deleted file mode 100644 index 8591231..0000000 --- a/general_ledger/migrations/0006_alter_bankstatementline_options.py +++ /dev/null @@ -1,24 +0,0 @@ -# Generated by Django 5.1.1 on 2024-10-02 00:08 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ( - "general_ledger", - "0005_bankstatementline_bankstatementline_uniq_bank_date_index", - ), - ] - - operations = [ - migrations.AlterModelOptions( - name="bankstatementline", - options={ - "ordering": ["date", "index"], - "verbose_name": "Bank Statement Line", - "verbose_name_plural": "Bank Statement Lines", - }, - ), - ] diff --git a/general_ledger/migrations/0007_bankbalance_balance_type.py b/general_ledger/migrations/0007_bankbalance_balance_type.py deleted file mode 100644 index 0129e9e..0000000 --- a/general_ledger/migrations/0007_bankbalance_balance_type.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 5.1.1 on 2024-10-02 00:25 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("general_ledger", "0006_alter_bankstatementline_options"), - ] - - operations = [ - migrations.AddField( - model_name="bankbalance", - name="balance_type", - field=models.CharField(blank=True, default="", max_length=255), - ), - ] diff --git a/general_ledger/migrations/0008_alter_bankbalance_options_historicalpayment.py b/general_ledger/migrations/0008_alter_bankbalance_options_historicalpayment.py deleted file mode 100644 index 440eab2..0000000 --- a/general_ledger/migrations/0008_alter_bankbalance_options_historicalpayment.py +++ /dev/null @@ -1,100 +0,0 @@ -# Generated by Django 5.1.1 on 2024-10-04 04:55 - -import django.db.models.deletion -import simple_history.models -import uuid -import xstate_machine -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("general_ledger", "0001_squashed_0007_bankbalance_balance_type"), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.AlterModelOptions( - name="bankbalance", - options={ - "ordering": ["balance_date"], - "verbose_name": "Bank Balance", - "verbose_name_plural": "Bank Balances", - }, - ), - migrations.CreateModel( - name="HistoricalPayment", - fields=[ - ("id", models.UUIDField(db_index=True, default=uuid.uuid4)), - ( - "created_at", - models.DateTimeField(blank=True, editable=False, null=True), - ), - ("updated_at", models.DateTimeField(blank=True, editable=False)), - ("date", models.DateField()), - ( - "amount", - models.DecimalField(decimal_places=4, default=0.0, max_digits=16), - ), - ("is_posted", models.BooleanField(default=False)), - ("is_paid", models.BooleanField(default=False)), - ("is_locked", models.BooleanField(default=False)), - ("is_system", models.BooleanField(default=False)), - ( - "state", - xstate_machine.FSMField( - choices=[ - ("DR", "Draft"), - ("RE", "Recorded"), - ("PO", "Posted"), - ("PA", "Partial"), - ("CO", "Complete"), - ("CA", "Cancelled"), - ("VO", "Void"), - ], - default="DR", - max_length=50, - ), - ), - ("history_id", models.AutoField(primary_key=True, serialize=False)), - ("history_date", models.DateTimeField(db_index=True)), - ("history_change_reason", models.CharField(max_length=100, null=True)), - ( - "history_type", - models.CharField( - choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")], - max_length=1, - ), - ), - ( - "history_user", - models.ForeignKey( - null=True, - on_delete=django.db.models.deletion.SET_NULL, - related_name="+", - to=settings.AUTH_USER_MODEL, - ), - ), - ( - "ledger", - models.ForeignKey( - blank=True, - db_constraint=False, - null=True, - on_delete=django.db.models.deletion.DO_NOTHING, - related_name="+", - to="general_ledger.ledger", - ), - ), - ], - options={ - "verbose_name": "historical Payment", - "verbose_name_plural": "historical Payments", - "ordering": ("-history_date", "-history_id"), - "get_latest_by": ("history_date", "history_id"), - }, - bases=(simple_history.models.HistoricalChanges, models.Model), - ), - ] diff --git a/general_ledger/migrations/0009_fileupload_book.py b/general_ledger/migrations/0009_fileupload_book.py deleted file mode 100644 index a4bc192..0000000 --- a/general_ledger/migrations/0009_fileupload_book.py +++ /dev/null @@ -1,24 +0,0 @@ -# Generated by Django 5.1.1 on 2024-10-04 13:47 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("general_ledger", "0008_alter_bankbalance_options_historicalpayment"), - ] - - operations = [ - migrations.AddField( - model_name="fileupload", - name="book", - field=models.ForeignKey( - default="fd1bb212-4163-4dea-b3eb-fb2539d9d16c", - on_delete=django.db.models.deletion.CASCADE, - to="general_ledger.book", - ), - preserve_default=False, - ), - ] diff --git a/general_ledger/migrations/0010_alter_bank_unique_together_and_more.py b/general_ledger/migrations/0010_alter_bank_unique_together_and_more.py deleted file mode 100644 index d265494..0000000 --- a/general_ledger/migrations/0010_alter_bank_unique_together_and_more.py +++ /dev/null @@ -1,23 +0,0 @@ -# Generated by Django 5.1.1 on 2024-10-04 18:22 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("general_ledger", "0009_fileupload_book"), - ] - - operations = [ - migrations.AlterUniqueTogether( - name="bank", - unique_together=set(), - ), - migrations.AddConstraint( - model_name="bank", - constraint=models.UniqueConstraint( - fields=("book", "slug"), name="bank_account_uniq_bank_slug" - ), - ), - ] diff --git a/general_ledger/migrations/0011_bank_close_date_bank_open_date.py b/general_ledger/migrations/0011_bank_close_date_bank_open_date.py deleted file mode 100644 index bf26382..0000000 --- a/general_ledger/migrations/0011_bank_close_date_bank_open_date.py +++ /dev/null @@ -1,23 +0,0 @@ -# Generated by Django 5.1.1 on 2024-10-04 18:28 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("general_ledger", "0010_alter_bank_unique_together_and_more"), - ] - - operations = [ - migrations.AddField( - model_name="bank", - name="close_date", - field=models.DateField(blank=True, null=True), - ), - migrations.AddField( - model_name="bank", - name="open_date", - field=models.DateField(blank=True, null=True), - ), - ] diff --git a/general_ledger/migrations/0012_remove_bank_routing_number.py b/general_ledger/migrations/0012_remove_bank_routing_number.py deleted file mode 100644 index d659236..0000000 --- a/general_ledger/migrations/0012_remove_bank_routing_number.py +++ /dev/null @@ -1,17 +0,0 @@ -# Generated by Django 5.1.1 on 2024-10-04 18:30 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ("general_ledger", "0011_bank_close_date_bank_open_date"), - ] - - operations = [ - migrations.RemoveField( - model_name="bank", - name="routing_number", - ), - ] diff --git a/general_ledger/migrations/0013_bank_routing_number.py b/general_ledger/migrations/0013_bank_routing_number.py deleted file mode 100644 index 34237f1..0000000 --- a/general_ledger/migrations/0013_bank_routing_number.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 5.1.1 on 2024-10-04 18:30 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("general_ledger", "0012_remove_bank_routing_number"), - ] - - operations = [ - migrations.AddField( - model_name="bank", - name="routing_number", - field=models.CharField(blank=True, max_length=20, null=True), - ), - ] diff --git a/general_ledger/migrations/0014_bank_closing_balance_bank_opening_balance.py b/general_ledger/migrations/0014_bank_closing_balance_bank_opening_balance.py deleted file mode 100644 index 417caf6..0000000 --- a/general_ledger/migrations/0014_bank_closing_balance_bank_opening_balance.py +++ /dev/null @@ -1,36 +0,0 @@ -# Generated by Django 5.1.1 on 2024-10-04 18:37 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("general_ledger", "0013_bank_routing_number"), - ] - - operations = [ - migrations.AddField( - model_name="bank", - name="closing_balance", - field=models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.SET_NULL, - related_name="+", - to="general_ledger.bankbalance", - ), - ), - migrations.AddField( - model_name="bank", - name="opening_balance", - field=models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.SET_NULL, - related_name="+", - to="general_ledger.bankbalance", - ), - ), - ] diff --git a/general_ledger/migrations/0015_bankbalance_bankbalance_uniq_bank_date_balance_type.py b/general_ledger/migrations/0015_bankbalance_bankbalance_uniq_bank_date_balance_type.py deleted file mode 100644 index 927425c..0000000 --- a/general_ledger/migrations/0015_bankbalance_bankbalance_uniq_bank_date_balance_type.py +++ /dev/null @@ -1,20 +0,0 @@ -# Generated by Django 5.1.1 on 2024-10-04 20:32 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("general_ledger", "0014_bank_closing_balance_bank_opening_balance"), - ] - - operations = [ - migrations.AddConstraint( - model_name="bankbalance", - constraint=models.UniqueConstraint( - fields=("bank", "date", "balance_type"), - name="bankbalance_uniq_bank_date_balance_type", - ), - ), - ] diff --git a/general_ledger/migrations/0015_bankbalance_bankbalance_uniq_bank_date_balance_type_squashed_0016_remove_bankbalance_bankbalance_uniq_bank_date_balance_type_and_more.py b/general_ledger/migrations/0015_bankbalance_bankbalance_uniq_bank_date_balance_type_squashed_0016_remove_bankbalance_bankbalance_uniq_bank_date_balance_type_and_more.py deleted file mode 100644 index b43e272..0000000 --- a/general_ledger/migrations/0015_bankbalance_bankbalance_uniq_bank_date_balance_type_squashed_0016_remove_bankbalance_bankbalance_uniq_bank_date_balance_type_and_more.py +++ /dev/null @@ -1,28 +0,0 @@ -# Generated by Django 5.1.1 on 2024-10-04 20:35 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - replaces = [ - ("general_ledger", "0015_bankbalance_bankbalance_uniq_bank_date_balance_type"), - ( - "general_ledger", - "0016_remove_bankbalance_bankbalance_uniq_bank_date_balance_type_and_more", - ), - ] - - dependencies = [ - ("general_ledger", "0014_bank_closing_balance_bank_opening_balance"), - ] - - operations = [ - migrations.AddConstraint( - model_name="bankbalance", - constraint=models.UniqueConstraint( - fields=("bank", "balance_date", "balance_type"), - name="bankbalance_uniq_bank_date_balance_type", - ), - ), - ] diff --git a/general_ledger/migrations/0016_remove_bankbalance_bankbalance_uniq_bank_date_balance_type_and_more.py b/general_ledger/migrations/0016_remove_bankbalance_bankbalance_uniq_bank_date_balance_type_and_more.py deleted file mode 100644 index bfc03d6..0000000 --- a/general_ledger/migrations/0016_remove_bankbalance_bankbalance_uniq_bank_date_balance_type_and_more.py +++ /dev/null @@ -1,24 +0,0 @@ -# Generated by Django 5.1.1 on 2024-10-04 20:32 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("general_ledger", "0015_bankbalance_bankbalance_uniq_bank_date_balance_type"), - ] - - operations = [ - migrations.RemoveConstraint( - model_name="bankbalance", - name="bankbalance_uniq_bank_date_balance_type", - ), - migrations.AddConstraint( - model_name="bankbalance", - constraint=models.UniqueConstraint( - fields=("bank", "balance_date", "balance_type"), - name="bankbalance_uniq_bank_date_balance_type", - ), - ), - ] diff --git a/general_ledger/migrations/0017_alter_bankstatementline_date.py b/general_ledger/migrations/0017_alter_bankstatementline_date.py deleted file mode 100644 index 979f95f..0000000 --- a/general_ledger/migrations/0017_alter_bankstatementline_date.py +++ /dev/null @@ -1,21 +0,0 @@ -# Generated by Django 5.1.1 on 2024-10-04 21:22 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ( - "general_ledger", - "0015_bankbalance_bankbalance_uniq_bank_date_balance_type_squashed_0016_remove_bankbalance_bankbalance_uniq_bank_date_balance_type_and_more", - ), - ] - - operations = [ - migrations.AlterField( - model_name="bankstatementline", - name="date", - field=models.DateTimeField(), - ), - ] diff --git a/general_ledger/migrations/0018_bankstatementline_ofx_dtposted_and_more.py b/general_ledger/migrations/0018_bankstatementline_ofx_dtposted_and_more.py deleted file mode 100644 index 1cce84c..0000000 --- a/general_ledger/migrations/0018_bankstatementline_ofx_dtposted_and_more.py +++ /dev/null @@ -1,28 +0,0 @@ -# Generated by Django 5.1.1 on 2024-10-04 21:39 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("general_ledger", "0017_alter_bankstatementline_date"), - ] - - operations = [ - migrations.AddField( - model_name="bankstatementline", - name="ofx_dtposted", - field=models.CharField(blank=True, max_length=32, null=True), - ), - migrations.AddField( - model_name="bankstatementline", - name="ofx_name", - field=models.CharField(blank=True, max_length=255), - ), - migrations.AddField( - model_name="bankstatementline", - name="ofx_trntype", - field=models.CharField(blank=True, max_length=32, null=True), - ), - ] diff --git a/general_ledger/migrations/0019_bankstatementline_tz.py b/general_ledger/migrations/0019_bankstatementline_tz.py deleted file mode 100644 index 2dcf3fa..0000000 --- a/general_ledger/migrations/0019_bankstatementline_tz.py +++ /dev/null @@ -1,19 +0,0 @@ -# Generated by Django 5.1.1 on 2024-10-04 22:55 - -import timezone_field.fields -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ("general_ledger", "0018_bankstatementline_ofx_dtposted_and_more"), - ] - - operations = [ - migrations.AddField( - model_name="bankstatementline", - name="tz", - field=timezone_field.fields.TimeZoneField(default="Europe/London"), - ), - ] diff --git a/general_ledger/migrations/0020_bank_tz.py b/general_ledger/migrations/0020_bank_tz.py deleted file mode 100644 index be5ec73..0000000 --- a/general_ledger/migrations/0020_bank_tz.py +++ /dev/null @@ -1,19 +0,0 @@ -# Generated by Django 5.1.1 on 2024-10-05 11:43 - -import timezone_field.fields -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ("general_ledger", "0019_bankstatementline_tz"), - ] - - operations = [ - migrations.AddField( - model_name="bank", - name="tz", - field=timezone_field.fields.TimeZoneField(default="Europe/London"), - ), - ] diff --git a/general_ledger/migrations/0021_bankstatementline_datetime_and_more.py b/general_ledger/migrations/0021_bankstatementline_datetime_and_more.py deleted file mode 100644 index c14d31b..0000000 --- a/general_ledger/migrations/0021_bankstatementline_datetime_and_more.py +++ /dev/null @@ -1,25 +0,0 @@ -# Generated by Django 5.1.1 on 2024-10-05 12:41 - -import django.utils.timezone -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("general_ledger", "0020_bank_tz"), - ] - - operations = [ - migrations.AddField( - model_name="bankstatementline", - name="datetime", - field=models.DateTimeField(default=django.utils.timezone.now), - preserve_default=False, - ), - migrations.AlterField( - model_name="bankstatementline", - name="date", - field=models.DateField(), - ), - ] diff --git a/general_ledger/migrations/0022_bankbalance_balance_date_tz.py b/general_ledger/migrations/0022_bankbalance_balance_date_tz.py deleted file mode 100644 index 60a61fc..0000000 --- a/general_ledger/migrations/0022_bankbalance_balance_date_tz.py +++ /dev/null @@ -1,19 +0,0 @@ -# Generated by Django 5.1.1 on 2024-10-05 14:37 - -import timezone_field.fields -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ("general_ledger", "0021_bankstatementline_datetime_and_more"), - ] - - operations = [ - migrations.AddField( - model_name="bankbalance", - name="balance_date_tz", - field=timezone_field.fields.TimeZoneField(default="Europe/London"), - ), - ] diff --git a/general_ledger/migrations/0023_alter_fileupload_options_alter_fileupload_table.py b/general_ledger/migrations/0023_alter_fileupload_options_alter_fileupload_table.py deleted file mode 100644 index b6bd255..0000000 --- a/general_ledger/migrations/0023_alter_fileupload_options_alter_fileupload_table.py +++ /dev/null @@ -1,25 +0,0 @@ -# Generated by Django 5.1.1 on 2024-10-05 15:48 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ("general_ledger", "0022_bankbalance_balance_date_tz"), - ] - - operations = [ - migrations.AlterModelOptions( - name="fileupload", - options={ - "ordering": ["-uploaded_at"], - "verbose_name": "Uploaded file", - "verbose_name_plural": "Uploaded files", - }, - ), - migrations.AlterModelTable( - name="fileupload", - table="gl_file_upload", - ), - ] diff --git a/general_ledger/migrations/0024_alter_accounttype_category_alter_fileupload_file.py b/general_ledger/migrations/0024_alter_accounttype_category_alter_fileupload_file.py deleted file mode 100644 index a365f6b..0000000 --- a/general_ledger/migrations/0024_alter_accounttype_category_alter_fileupload_file.py +++ /dev/null @@ -1,42 +0,0 @@ -# Generated by Django 5.1.1 on 2024-10-10 10:18 - -import general_ledger.models.file_upload -import general_ledger.validators -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("general_ledger", "0023_alter_fileupload_options_alter_fileupload_table"), - ] - - operations = [ - migrations.AlterField( - model_name="accounttype", - name="category", - field=models.CharField( - choices=[ - ("A", "Asset"), - ("L", "Liability"), - ("E", "Equity"), - ("R", "Revenue"), - ("X", "Expense"), - ("NCA", "Non-Current Asset"), - ("CA", "Current Asset"), - ("NCL", "Non-Current Liability"), - ("CL", "Current Liability"), - ], - default="A", - max_length=20, - ), - ), - migrations.AlterField( - model_name="fileupload", - name="file", - field=models.FileField( - upload_to=general_ledger.models.file_upload.generate_unique_filename, - validators=[general_ledger.validators.validate_file_size], - ), - ), - ] diff --git a/general_ledger/migrations/0025_alter_accounttype_category.py b/general_ledger/migrations/0025_alter_accounttype_category.py deleted file mode 100644 index 63e6707..0000000 --- a/general_ledger/migrations/0025_alter_accounttype_category.py +++ /dev/null @@ -1,29 +0,0 @@ -# Generated by Django 5.1.1 on 2024-10-10 10:42 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("general_ledger", "0024_alter_accounttype_category_alter_fileupload_file"), - ] - - operations = [ - migrations.AlterField( - model_name="accounttype", - name="category", - field=models.CharField( - choices=[ - ("E", "Equity"), - ("R", "Revenue"), - ("X", "Expense"), - ("NCA", "Non-Current Asset"), - ("CA", "Current Asset"), - ("NCL", "Non-Current Liability"), - ("CL", "Current Liability"), - ], - max_length=20, - ), - ), - ] diff --git a/general_ledger/migrations/0026_accounttype_liquidity.py b/general_ledger/migrations/0026_accounttype_liquidity.py deleted file mode 100644 index 2f3e323..0000000 --- a/general_ledger/migrations/0026_accounttype_liquidity.py +++ /dev/null @@ -1,34 +0,0 @@ -# Generated by Django 5.1.1 on 2024-10-10 11:14 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("general_ledger", "0025_alter_accounttype_category"), - ] - - operations = [ - migrations.AddField( - model_name="accounttype", - name="liquidity", - field=models.IntegerField( - choices=[ - (100, "Cash or Cash Equivalent"), - (90, "Bank"), - (80, "Accounts Receivable"), - (75, "Accounts Payable"), - (70, "Inventory"), - (65, "Loans Due within 12 months"), - (60, "Other Current Assets"), - (50, "Motor Vehicles"), - (40, "Machinery and Equipment"), - (30, "Fixtures and Fittings"), - (20, "Land and Buildings"), - ], - default=50, - ), - preserve_default=False, - ), - ] diff --git a/general_ledger/migrations/0027_alter_accounttype_liquidity.py b/general_ledger/migrations/0027_alter_accounttype_liquidity.py deleted file mode 100644 index 9ca8038..0000000 --- a/general_ledger/migrations/0027_alter_accounttype_liquidity.py +++ /dev/null @@ -1,34 +0,0 @@ -# Generated by Django 5.1.1 on 2024-10-10 11:23 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("general_ledger", "0026_accounttype_liquidity"), - ] - - operations = [ - migrations.AlterField( - model_name="accounttype", - name="liquidity", - field=models.IntegerField( - choices=[ - (100, "Cash or Cash Equivalent"), - (90, "Bank"), - (80, "Accounts Receivable"), - (75, "Accounts Payable"), - (70, "Inventory"), - (65, "Loans Due within 12 months"), - (60, "Other Current Assets"), - (50, "Motor Vehicles"), - (40, "Machinery and Equipment"), - (30, "Fixtures and Fittings"), - (20, "Land and Buildings"), - (0, "None"), - ], - default=0, - ), - ), - ] diff --git a/general_ledger/migrations/0028_alter_account_options_alter_accounttype_options.py b/general_ledger/migrations/0028_alter_account_options_alter_accounttype_options.py deleted file mode 100644 index bc8d9b3..0000000 --- a/general_ledger/migrations/0028_alter_account_options_alter_accounttype_options.py +++ /dev/null @@ -1,28 +0,0 @@ -# Generated by Django 5.1.1 on 2024-10-10 18:37 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ("general_ledger", "0027_alter_accounttype_liquidity"), - ] - - operations = [ - migrations.AlterModelOptions( - name="account", - options={ - "ordering": ["type__category", "type__liquidity", "name"], - "verbose_name_plural": "accounts", - }, - ), - migrations.AlterModelOptions( - name="accounttype", - options={ - "ordering": ["category", "liquidity", "name"], - "verbose_name": "Account Type", - "verbose_name_plural": "Account Types", - }, - ), - ] diff --git a/general_ledger/models.py b/general_ledger/models.py new file mode 100644 index 0000000..d48a74f --- /dev/null +++ b/general_ledger/models.py @@ -0,0 +1 @@ +from .django.models import * diff --git a/general_ledger/render/__init__.py b/general_ledger/render/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/general_ledger/render/config_options.py b/general_ledger/render/config_options.py new file mode 100644 index 0000000..4a8cc7d --- /dev/null +++ b/general_ledger/render/config_options.py @@ -0,0 +1,23 @@ +import yaml + + +class RenderOptions: + def __init__(self, config=None, **overrides): + self.config = config or {} + self.overrides = overrides + + def get(self, key, default=None): + # Check for runtime overrides first + if key in self.overrides: + return self.overrides[key] + # Fall back to configuration file values + return self.config.get(key, default) + + +class RendererConfig: + def __init__(self, config_file="general_ledger/config.yml"): + with open(config_file, "r") as file: + self.config = yaml.safe_load(file) + + def get(self, renderer, key, default=None): + return self.config.get("renderer", {}).get(renderer, {}).get(key, default) diff --git a/general_ledger/render/config_statement_render.py b/general_ledger/render/config_statement_render.py new file mode 100644 index 0000000..d4e06b1 --- /dev/null +++ b/general_ledger/render/config_statement_render.py @@ -0,0 +1,23 @@ +from dataclasses import dataclass, field +from decimal import Decimal + + +@dataclass +class RenderConfig: + """Configuration for statement rendering""" + + hide_empty: bool = False + materiality_threshold: Decimal = Decimal("0.01") + show_hidden: bool = True + show_calculations: bool = False + currency_symbol: str = "£" + decimal_places: int = 0 + show_zeros: bool = False + styles: dict = field( + default_factory=lambda: { + "positive": "green", + "negative": "red", + "operation": "blue", + "hidden": "dim", + } + ) diff --git a/general_ledger/render/consoler.py b/general_ledger/render/consoler.py new file mode 100644 index 0000000..af8121c --- /dev/null +++ b/general_ledger/render/consoler.py @@ -0,0 +1,435 @@ +import datetime +import itertools +import numbers +from collections import namedtuple + +from colorama import Fore, Back, Style +from dateutil.relativedelta import relativedelta + +from general_ledger.django.models import AccountType, Ledger, Direction +from general_ledger.utils.utility_date_stuff import last_day_of, EntryObject +from general_ledger.utils.inspect import inspect + +""" +this was an early attempt to render the ledger to the console +it has mostly been replaced by the rich library rendering +but it is still used in various places to show simple objects +Status: deprecated +""" + + +def pr_account_list(ledger, accounts, title=None): + """ + Print a list of accounts for a ledger, with balances + add a bit of color to the output + :param ledger: + :param accounts: + :param title: + :return: + """ + out = "" + if title: + out += f"{Fore.GREEN}{Style.BRIGHT}{title}{Style.RESET_ALL}\n" + if accounts: + for account in accounts: + entry_set = account.entry_set.filter(transaction__ledger=ledger) + debit_balance = entry_set.debit_total() + credit_balance = entry_set.credit_total() + if entry_set.is_balanced(): + continue + category = AccountType.Category(account.type.category) + account_type_name = f"{account.type.name}({category})" + out += f"{Style.BRIGHT}{account.name:<20}{Style.RESET_ALL} {account_type_name:<25} {account.type.liquidity:>5} {debit_balance:>10.2f} {credit_balance:>10.2f} \n" + else: + out += f"{Back.YELLOW} No accounts found {Style.RESET_ALL}\n" + + return out + + +def pr_tx_list(transactions, title=None): + """ + Print a list of transactions, with balances + add a bit of color to the output + :param transactions: + :param title: + :return: + """ + out = "" + if title: + out += f"{Fore.GREEN}{Style.BRIGHT}{title}{Style.RESET_ALL}\n" + if transactions: + for tx in transactions: + # inspect(tx) + out += f"{Back.LIGHTYELLOW_EX}transaction{Style.RESET_ALL}: {Back.LIGHTCYAN_EX}{tx.trans_date}{Style.RESET_ALL} {tx.description} [{tx.is_posted}/{tx.can_post()}]\n" + out += pr_entry_set(tx.entry_set) + else: + out += f"{Back.YELLOW} No tx found to print {Style.RESET_ALL}\n" + + return out + + +def pr_entry_set(entry_set, title=None): + """ + Print a list of entries with amounts + add a bit of color to the output + :param entry_set: + :param title: + :return: + """ + out = "" + if title: + out += f"{Fore.GREEN}{Style.BRIGHT}{title}{Style.RESET_ALL}\n" + if entry_set: + interval_key_current = None + for entry in entry_set.all(): + interval_key = getattr(entry, "interval_key", None) + if interval_key != interval_key_current: + interval_key_current = interval_key + out += f"{Back.LIGHTMAGENTA_EX}interval{Style.RESET_ALL}: {Back.LIGHTWHITE_EX}{interval_key}{Style.RESET_ALL}\n" + + out += pr_entry(entry) + else: + out += f"{Back.YELLOW} No entries found to print {Style.RESET_ALL}\n" + + return out + + +def pr_entry(entry, title=None): + """ + Print an entry on a line + add a bit of color to the output + :param entry: + :param title: + :return: + """ + out = "" + if title: + out += f"{Fore.GREEN}{Style.BRIGHT}{title}{Style.RESET_ALL} - " + if entry: + out += f"{Back.LIGHTBLUE_EX}entry:{Style.RESET_ALL} {Back.YELLOW}{entry.account.name[:16]: <16} {Style.RESET_ALL}[{entry.tx_type}] {Fore.GREEN}{entry.debit_amount: >10.2f}{Style.RESET_ALL} {Fore.LIGHTMAGENTA_EX}{entry.credit_amount: >10.2f}{Style.RESET_ALL}" + + out += f" {entry.trans_date} " + + if hasattr(entry, "interval_key"): + out += f" {entry.interval_key} " + + out += "\n" + else: + out += f"{Back.YELLOW} No entry found to print {Style.RESET_ALL} \n" + + return out + + +def pr_account_balanced(account_balanced, title=None): + """ + Print a list of entries with amounts + add a bit of color to the output + :param account_balanced: + :param title: + :return: + """ + out = "" + if title: + out += f"{Fore.GREEN}{Style.BRIGHT}{title:^81}{Style.RESET_ALL}\n" + if not account_balanced: + out += f"{Back.YELLOW} No entries found to print {Style.RESET_ALL}\n" + return out + out += pr_account_balanced_header(title="") + interval_keys = account_balanced["meta"]["interval_keys"] + for idx, interval_key in enumerate(interval_keys): + interval = account_balanced[interval_key] + out += pr_account_balanced_interval(interval, title=interval_key, idx=idx) + + return out + + +def pr_account_balanced_header(title=None): + """ + Print a list of entries with amounts + add a bit of color to the output + :param interval: + :param title: + :return: + """ + output = "" + if title: + output += f"{title.center(81)}\n" + output += "-" * 79 + "\n" + + return output + + +def pr_account_balanced_interval( + interval, + title=None, + idx=None, +): + """ + Print a list of entries with amounts + add a bit of color to the output + :param interval: + :param title: + :return: + """ + out = "" + # if title: + # out += f"{Fore.GREEN}{Style.BRIGHT}{title}{Style.RESET_ALL}\n" + if not interval: + out += f"{Back.YELLOW} No entries found to print {Style.RESET_ALL}\n" + return out + + # out += f"{Back.LIGHTMAGENTA_EX}interval{Style.RESET_ALL}: {Back.LIGHTWHITE_EX}{interval['status']}{Style.RESET_ALL} {interval['balance_interval']}\n" + # out += pr_entry_set(interval["entries"]) + out += pr_account_balanced_interval_separator( + interval, + title=title, + idx=idx, + ) + entries = interval["entries"] + + debits = [] + if interval["debit_bd"]: + debits.append( + EntryObject( + trans_date=interval["interval_key_dt"], + amount=interval["debit_bd"], + narrative="Bal b/d", + ) + ) + debits += entries.debits() + if interval["debit_cd"]: + debits.append( + EntryObject( + trans_date=last_day_of( + interval["interval_key_dt"], interval["balance_interval"] + ), + amount=interval["debit_cd"], + narrative="Bal c/d", + ) + ) + + creditz = [] + if interval["credit_bd"]: + creditz.append( + EntryObject( + trans_date=interval["interval_key_dt"], + amount=interval["credit_bd"], + narrative="Bal b/d", + ) + ) + creditz += entries.credits() + if interval["credit_cd"]: + creditz.append( + EntryObject( + trans_date=last_day_of( + interval["interval_key_dt"], interval["balance_interval"] + ), + amount=interval["credit_cd"], + narrative="Bal c/d", + ) + ) + + zipped = list( + itertools.zip_longest( + debits, + creditz, + fillvalue=None, + ) + ) + + # inspect(zipped) + for debit, credit in zipped: + # inspect(credit) + out += pr_account_balanced_interval_row( + pr_account_balanced_interval_entry( + debit.trans_date if debit else "", + debit.narrative if debit else "", + debit.amount if debit else "", + ), + pr_account_balanced_interval_entry( + credit.trans_date if credit else "", + credit.narrative if credit else "", + credit.amount if credit else "", + ), + ) + + out += pr_account_balanced_interval_row( + pr_account_balanced_interval_entry( + "", + "", + "________", + ), + pr_account_balanced_interval_entry( + "", + "", + "--------", + ), + ) + out += pr_account_balanced_interval_row( + pr_account_balanced_interval_entry( + "", + "", + interval["total"], + ), + pr_account_balanced_interval_entry( + "", + "", + interval["total"], + ), + ) + out += pr_account_balanced_interval_row( + pr_account_balanced_interval_entry( + "", + "", + "========", + ), + pr_account_balanced_interval_entry( + "", + "", + "========", + ), + ) + + return out + + +def pr_account_balanced_interval_separator( + interval, + title=None, + idx=None, +): + """ + This is the first line in a new interval. ususlly a separator + print the year. could be something elese for month or wekk + :param interval: + :param title: + :return: + """ + out = "" + skip = False + dt = interval["interval_key_dt"] + if idx == 0: + left = dt.year + elif interval["balance_interval"] == "year": + left = dt.year + elif interval["balance_interval"] == "month": + if dt.month == 1: + left = dt.year + else: + left = "" + skip = True + elif interval["balance_interval"] == "week": + if dt.month == 1 and dt.strftime("%-W") == "1": + left = dt.year + else: + left = "" + skip = True + middle = "" + right = "£" + col_left = pr_account_balanced_interval_entry(left, middle, right) + col_right = pr_account_balanced_interval_entry(left, middle, right) + if not skip: + out += pr_account_balanced_interval_row(col_left, col_right) + return out + + +def pr_account_balanced_interval_row(col_left, col_right): + out = "" + out += f"{col_left}|{col_right}\n" + return out + + +def pr_account_balanced_interval_entry(left, middle, right): + out = "" + + if isinstance(left, datetime.date): + # left = left.strftime("%b %d").ljust(7) + left = left.strftime("%-m.%-d").ljust(7) + left = f"{Fore.LIGHTYELLOW_EX}{left}{Style.RESET_ALL}" + elif isinstance(left, str): + left = left.ljust(7) + left = f"{Fore.GREEN}{left}{Style.RESET_ALL}" + else: + # print(type(left)) + left = str(left).ljust(7) + left = f"{Fore.LIGHTMAGENTA_EX}{left}{Style.RESET_ALL}" + + if isinstance(right, numbers.Number): + right = f"{Fore.LIGHTBLUE_EX}{right: >10.2f}{Style.RESET_ALL}" + elif isinstance(right, str) and len(right) == 1: + right = f" {right} " + else: + right = f"{Fore.LIGHTGREEN_EX}{right: >10}{Style.RESET_ALL}" + right = f"{Style.BRIGHT}{right}{Style.RESET_ALL}" + + middle = f"{middle: <19}" + out = f" {left} {middle} {right} " + return out + + +class LedgerHelper2: + + def __init__(self, ledger: Ledger): + self.ledger = ledger + + def get_entry_summary(self, entryset, account): + + years = sorted(list(set([entry.trans_date.year for entry in entryset]))) + debits = entryset.filter(tx_type=Direction.DEBIT) + creditz = entryset.filter(tx_type=Direction.CREDIT) + + output = "" + for year in years: + debits1 = entryset.filter( + tx_type=Direction.DEBIT, + transaction__trans_date__year=year, + ) + creditz1 = entryset.filter( + tx_type=Direction.CREDIT, + transaction__trans_date__year=year, + ) + zipped = list(itertools.zip_longest(debits1, creditz1, fillvalue=None)) + if len(zipped) == 0: + continue + # self.logger.info(f"year: {year} account: {account}") + output += self.get_year_header_row(year, account, debits1, creditz1) + + # print(len(zipped)) + # print(f"type of zipped: {type(zipped)}") + for e in zipped: + # print(f"type of e: {type(e)}") + # print(f"type of e[0]: {type(e[0])}") + output += f"{self.get_entry_row(e[0])}|{self.get_entry_row(e[1])}\n" + + # output += self.get_totals_row(account) + + return output + + def get_year_header_row(self, year, account, debits1, creditz1) -> str: + output = "" + if len(debits1) and len(creditz1): + output += f" {Style.BRIGHT}{Fore.CYAN}{year: <31}{Style.RESET_ALL} {account.currency_symbol.center(6)} | {Style.BRIGHT}{Fore.CYAN}{year: <28}{Style.RESET_ALL} {account.currency_symbol.center(10): >10}\n" + elif len(debits1): + output += f" {Style.BRIGHT}{Fore.CYAN}{year: <31}{Style.RESET_ALL} {account.currency_symbol.center(6): >6} | {' '*39}\n" + elif len(creditz1): + output += f" {' '*38} | {Style.BRIGHT}{Fore.CYAN}{year: <28}{Style.RESET_ALL} {account.currency_symbol.center(10): >10}\n" + return output + + def get_entry_row(self, entry): + # print(type(entry)) + output = "" + if entry: + # print(self.get_counter_entry(entry)) + tmp = entry.get_counter_entry() + # print(f"tmp: '{tmp}'") + output += f" {entry.transaction.trans_date.strftime('%b %e'): <8}{tmp: <19} {entry.amount : >10.2f} " + else: + output += f" {' '*38} " + return output + + def get_totals_row(self, account): + output = "" + output += f" {'------'.rjust(38)} | {'-------'.rjust(38)}\n" + output += f" {'totals'.rjust(38)} | {'1234.00'.rjust(38)}\n" + output += f" {'======'.rjust(38)} | {'======='.rjust(38)}\n" + return output diff --git a/general_ledger/render/format_statement_j2.py b/general_ledger/render/format_statement_j2.py new file mode 100644 index 0000000..c43cddf --- /dev/null +++ b/general_ledger/render/format_statement_j2.py @@ -0,0 +1,42 @@ +from general_ledger.render.formats_abc import StatementFormat +from general_ledger.statements.meta import DetailLevel +from util.visitors.recursive_func import RecursiveFuncVisitor + + +class StatementFormatJ2(StatementFormat): + def render_statement(self, renderer, node): + print("Rendering statement with Jinja templates") + node.meta.expand = DetailLevel.EXPAND + node.set_expand(DetailLevel.EXPAND) + + result = "".join( + node.accept( + RecursiveFuncVisitor( + pre_func=self.pre_func, + post_func=self.post_func, + ) + ) + ) + # result.render() + renderer.renderable = result + return result + + def pre_func(self, node, level, *_, **__): + out = "" + out += f'
' + out += f'

{node.title}

' + out += f"
" + out += f'

{node.value}

{level}' + padding = 20 * level + out += f'
' + return out + + def post_func(self, node, *_, **__): + out = "" + out += f'
end of {node.name}
' + return out + + +# class NodeHtmlVisitor(RecursiveFuncVisitor): +# def visit(self, node): +# return node.accept diff --git a/general_ledger/render/format_statement_rich_table.py b/general_ledger/render/format_statement_rich_table.py new file mode 100644 index 0000000..8c7c894 --- /dev/null +++ b/general_ledger/render/format_statement_rich_table.py @@ -0,0 +1,170 @@ +import copy + +from general_ledger.render.formats_abc import StatementFormat +from general_ledger.statements.financial_statement import FinancialStatement +from general_ledger.statements.meta import DetailLevel +from general_ledger.utils.utility import visit_logger + +from loguru import logger + +logger = logger.opt(colors=True) + + +class StatementFormatRichTable(StatementFormat): + def render_statement(self, renderer, node): + print("Rendering statement with Rich Table") + node.meta.expand = DetailLevel.EXPAND + node.set_expand(DetailLevel.EXPAND) + + result = node.accept(NodeTableVisitor("Financial Statement Example 1")).render() + # result.render() + renderer.renderable = result + return result + + +class NodeTableVisitor: + + fs: FinancialStatement = None + + def __init__( + self, + name, + ): + self.name: str = name + self.fs = FinancialStatement(name) + self.current_level = 0 + + # @logger_wraps() + def visit(self, node, *_) -> FinancialStatement: + self.visit_node(node) + return self.fs + + def visit_node(self, node): + visit_logger( + logger, + f"Processing '{node.name}' {node.meta.expand} depth: {node.depth} ", + node, + self.current_level, + ) + self.visit_node_header(node) + self.visit_node_body(node) + self.visit_node_footer(node) + + def visit_node_header(self, node): + if node.is_leaf or node.meta.expand == DetailLevel.VALUE: + return + self.fs.add_value( + node.label, + "-", + col_idx=self.current_level, + node_type=FinancialStatement.Type.HEADER, + indent=self.current_level, + node=node, + extra=copy.deepcopy(self.fs.used_columns), + ) + + def visit_node_footer(self, node): + if node.is_leaf or node.meta.expand == DetailLevel.VALUE: + return + self.fs.add_value( + node.label, + "-", + col_idx=self.current_level, + node_type=FinancialStatement.Type.FOOTER, + indent=self.current_level, + node=node, + extra=f"value: {node.value} accum: {node.accumulated_value}", + ) + + def visit_leaf(self, node): + self.fs.add_value( + node.label, + node.value, + col_idx=self.current_level, + node_type=FinancialStatement.Type.LEAF, + indent=self.current_level, + node=node, + ) + return self.fs + + def visit_node_body(self, node): + + # choose available column + first = False if self.fs.counts[self.current_level] else True + count = self.fs.counts[self.current_level] + + visit_logger( + logger, + f"before columns: {self.fs.used_columns} {count} {first} xxx", + node, + self.current_level, + ) + + original_current_level = self.current_level + next_level = self.current_level + + if node.is_leaf or node.meta.expand == DetailLevel.VALUE: + self.visit_leaf(node) + return + + if not self.fs.used_columns[self.current_level]: + """if the level is unused we can use it for our children""" + self.fs.used_columns[self.current_level] = [node.name] + next_level = self.current_level + elif self.fs.used_columns[self.current_level] and first: + self.fs.used_columns[self.current_level].append(node.name) + next_level = self.current_level + else: + """if the level is used we need to move to the next level + and record that we used the current level""" + next_level = self.current_level + 1 + self.fs.used_columns[next_level].append(node.name) + + visit_logger( + logger, + f"after columns: {self.fs.used_columns} {count} {first}", + node, + self.current_level, + ) + self.current_level = next_level + for i, child in enumerate(node.values()): + is_first = i == 0 + is_last = i == len(node) - 1 + self.visit(child) + if is_last: + """if we are the end of list of children. can draw a line""" + self.fs.add_value( + "", + "---------", + col_idx=self.current_level, + node_type=FinancialStatement.Type.RUNNING, + indent=self.current_level, + node=child, + extra=[ + "", + f"value: {child.value} total: {node.sections.value}", + ], + ) + self.current_level = original_current_level + node_type = ( + FinancialStatement.Type.SUBTOTAL + if node.parent + else FinancialStatement.Type.TOTAL + ) + self.fs.add_value( + node.label, + # node.sections.value if node.has_children else node.value, + node.operation.calculate(node), + col_idx=self.current_level, + node_type=node_type, + indent=self.current_level, + node=node, + extra=[ + copy.deepcopy(self.fs.used_columns), + f"value: {node.value} accum: {node.accumulated_value}", + ], + ) + + if node.name in self.fs.used_columns[next_level]: + self.fs.used_columns[next_level].remove(node.name) + self.current_level = original_current_level diff --git a/general_ledger/render/format_statement_rich_table_reversed.py b/general_ledger/render/format_statement_rich_table_reversed.py new file mode 100644 index 0000000..958dfb9 --- /dev/null +++ b/general_ledger/render/format_statement_rich_table_reversed.py @@ -0,0 +1,73 @@ +from typing import Optional + +from rich.console import Console +from rich.text import Text +from rich.tree import Tree + +from general_ledger.render.config_statement_render import RenderConfig +from general_ledger.render.formats_abc import StatementFormat +from general_ledger.render.utility_rich import lists_to_grid_cols +from general_ledger.statements.meta import Operation +from general_ledger.statements.statement_node import StatementNode +from general_ledger.utils.inspect import inspect +from util.visitors.recursive_func import RecursiveFuncVisitor + +console = Console() + +""" +=== Full View === +Income Statement 0 +├── + Gross Profit 0 +│ ├── + Sales 38,500 +│ ├── - Returns Inward 0 +│ └── - Cost of goods sold: 0 +│ ├── + Opening Inventory 0 +│ ├── + Purchases 29,000.00 +│ └── - Closing Inventory -3,000 +└── - Net Profit 0.00 + ├── + Other Operating Income 0 + └── - Expenses 0 + ├── + General Expenses Account 600 + ├── + Lighting Expenses Account 1,500 + └── + Rent Account 2,400 +""" + + +class StatementTableFormatReverse(StatementFormat): + def __init__(self): + super().__init__() + self.config_key = "statement_table_reverse" + + def render_statement(self, renderer, node: StatementNode): + print("Rendering statement with Rich Table upside down") + + out = node.accept(self.ReverseVisitor(renderer=renderer, node=node)) + + opts = { + "show_calculation": renderer.options.get("show_calculation", False), + "show_hidden": renderer.options.get("show_hidden", False), + } + + items = [] + items.append(Text("something here")) + + result = lists_to_grid_cols(items) + renderer.renderable = result + return result + + class ReverseVisitor(RecursiveFuncVisitor): + def __init__(self, **kwargs): + super().__init__(pre_func=self._pre_func, post_func=self._post_func) + self.current_level = 0 + self.options = kwargs.get("options") + self.reverse = True + + def _pre_func(self, visitee, _): + indent = "->" * visitee.depth + return f"pre :{indent} {visitee.title:18.18} {visitee.name:18.18} {visitee.label}" + + def _post_func(self, visitee, _): + indent = "->" * visitee.depth + return f"post :{indent} {visitee.title:18.18} {visitee.name:18.18} {visitee.label}" + + diff --git a/general_ledger/render/format_statement_rich_tree.py b/general_ledger/render/format_statement_rich_tree.py new file mode 100644 index 0000000..3c80b54 --- /dev/null +++ b/general_ledger/render/format_statement_rich_tree.py @@ -0,0 +1,150 @@ +from typing import Optional + +from rich.console import Console +from rich.text import Text +from rich.tree import Tree + +from general_ledger.render.config_statement_render import RenderConfig +from general_ledger.render.formats_abc import StatementFormat +from general_ledger.statements.meta import Operation +from general_ledger.statements.statement_node import StatementNode +from util.visitors.recursive_func import RecursiveFuncVisitor + +console = Console() + +""" +=== Full View === +Income Statement 0 +├── + Gross Profit 0 +│ ├── + Sales 38,500 +│ ├── - Returns Inward 0 +│ └── - Cost of goods sold: 0 +│ ├── + Opening Inventory 0 +│ ├── + Purchases 29,000.00 +│ └── - Closing Inventory -3,000 +└── - Net Profit 0.00 + ├── + Other Operating Income 0 + └── - Expenses 0 + ├── + General Expenses Account 600 + ├── + Lighting Expenses Account 1,500 + └── + Rent Account 2,400 +""" + + +class StatementTreeFormat(StatementFormat): + def __init__(self): + super().__init__() + self.config_key = "statement_tree" + + def render_statement(self, renderer, node): + print("Rendering statement with Rich Tree") + + node.accept( + RecursiveFuncVisitor( + pre_func=lambda visitee, _: visitee.set_visibility( + renderer.options.get("detail_level") + ) + ) + ) + + opts = { + "show_calculation": renderer.options.get("show_calculation", False), + "show_hidden": renderer.options.get("show_hidden", False), + } + + result = render_statement_tree(node, **opts) + renderer.renderable = result + return result + + +def render_statement_tree( + node: StatementNode, + show_calculation: bool = False, + show_hidden: bool = False, + config: RenderConfig = RenderConfig(), +) -> Optional[Tree]: + """ + Render a statement node as a Rich Tree with optional calculation details. + Only renders visible nodes and optionally indicates hidden children. + """ + + node.ensure_expanded() + + # Check if node should be visible + if not node.should_be_visible(config): + return None + + # Skip if node isn't visible + if not node.is_visible: + return None + + # Process children first + visible_children = [] + has_hidden_children = False + has_empty_children = False + + for child in node.values(): + if child.is_visible: + child_tree = render_statement_tree( + child, + show_calculation=show_calculation, + show_hidden=show_hidden, + config=config, + ) + if child_tree is not None: + visible_children.append(child_tree) + elif config.hide_empty and child.is_empty_branch(config): + has_empty_children = True + else: + has_hidden_children = True + else: + # Check if this hidden child has visible descendants + has_hidden_children = has_hidden_children or any( + c.is_visible for c in child.values() + ) + # has_hidden_children = True + + # Build the label for this node + label_parts = [] + + # Operation + operation = ( + f"{node.meta.operation.value} " if node.meta.operation != Operation.NONE else "" + ) + label_parts.append(Text(operation, style="blue")) + + # Label + label = node.meta.label_override or node.label.replace("_", " ").title() + label_parts.append(Text(label)) + + # Value + if node.is_leaf or node.meta.show_subtotal: + value_str = f" {node.value:,.{config.decimal_places}f}" + style = "red" if node.value < 0 else "green" + label_parts.append(Text(value_str, style=style)) + + if show_calculation and node.value_strategy: + strategy_name = node.value_strategy.__class__.__name__ + label_parts.append(Text(f" ({strategy_name})", style="dim")) + + # Create tree + tree = Tree(Text.assemble(*label_parts)) + + # Add visible children + for child_tree in visible_children: + tree.add(child_tree) + + if show_hidden and has_hidden_children and not visible_children: + hidden_values = sum( + child.value + for child in node.values() + if not child.is_visible and any(c.is_visible for c in child.values()) + ) + tree.add( + Text( + f"(... hidden items totaling {hidden_values:,.{config.decimal_places}f})", + style="dim", + ) + ) + + return tree diff --git a/general_ledger/render/format_table_rich_t_account.py b/general_ledger/render/format_table_rich_t_account.py new file mode 100644 index 0000000..4c288ae --- /dev/null +++ b/general_ledger/render/format_table_rich_t_account.py @@ -0,0 +1,382 @@ +import itertools +from collections import defaultdict +from datetime import datetime + +from loguru import logger +from rich.box import Box + +from currency_symbols import CurrencySymbols +from rich.padding import Padding +from rich.style import Style +from rich.table import Table +from rich.text import Text + +from general_ledger.render.formats_abc import TableFormat +from general_ledger.render.renderables import TTotalEntry, TEmpty, TRow +from general_ledger.render.utility_rich import t_account_col_grid +from general_ledger.utils.inspect import inspect +from general_ledger.utils.utility_date_stuff import ( + last_day_of, + get_interval_key, + first_day_of_next, +) + +HEADER_BOTTOM_ONLY: Box = Box( + " ══ \n" # header top crossbar (called top) + " │ \n" # head (called head) + " ══ \n" # header lower crossbar (called head_row) + " │ \n" + " ─┼ \n" + " ─┼ \n" + " │ \n" + " ╵ \n" +) + + +class TAccountFormat(TableFormat): + """ + ╭──────────────────────────────────────────────────────────────────────────────╮ + │ K Tandy │ + │ ╷ │ + │ Debits │ Credits │ + │ ════════════════════════════════╪════════════════════════════════════════ │ + │ 2012 £ │ 2012 £ │ + │ 12.8.1 Sales 144.00 │ 12.8.22 Bank Account 144.00 │ + │ 12.8.19 Sales 300.00 │ 12.8.28 Bank Account 300.00 │ + │ ------- │ ------- │ + │ 444.00 │ 444.00 │ + │ ======= │ ======= │ + │ ╵ │ + │ this is the end, my beautiful friend │ + ╰──────────────────────────────────────────────────────────────────────────────╯ + """ + + def __init__(self): + super().__init__() + self.config_key = "t_account" + self.renderer = None + + def render_table(self, renderer, account_summary): + if not type(account_summary).__name__ == "AccountSummary": + raise ValueError( + f"account_summary must be an instance of AccountSummary not '{type(account_summary).__name__}'" + ) + logger.trace(f"processing account summary {account_summary.title}") + self.renderer = renderer + date_fmt = self.renderer.options.get("date_format", "%-m.%-d") + + table = Table( + expand=True, + box=HEADER_BOTTOM_ONLY, + show_header=False, + show_footer=False, + show_edge=True, + show_lines=False, + # caption="this is the end, my beautiful friend", + highlight=True, + pad_edge=False, + min_width=75, + # width=80, + title_style="bold green", + # collapse_padding=True, + # padding=(0,), + ) + table.title = account_summary.title + table.add_column( + "", + justify="center", + # style=random_style("col"), + style="magenta", + ratio=3, + ) + table.add_column( + "", + justify="center", + # style=random_style("col"), + style="magenta", + ratio=3, + ) + + grid_debits, grid_credits = t_account_col_grid() + + if hasattr(account_summary, "entries_grouped"): + self._process_intervals( + table, grid_debits, grid_credits, renderer, account_summary + ) + else: + inspect(account_summary, methods=True, dunder=True, all=True) + logger.warning( + "no entries_grouped in account_summary for '{}'", account_summary + ) + print(f"account_summary: {account_summary}") + print(f"account_summary: {account_summary!r}") + renderer.renderable = table + + def _process_intervals( + self, table, grid_debits, grid_credits, renderer, account_summary + ): + interval_keys = account_summary.entries_grouped["meta"]["interval_keys"] + first_interval = True + current_year = None + # inspect(account_summary) + + for idx, interval_key in enumerate(interval_keys): + + interval = account_summary.entries_grouped[interval_key] + if current_year is None or interval["interval_key_dt"].year != current_year: + current_year = interval["interval_key_dt"].year + self.print_year_header( + table, grid_debits, grid_credits, account_summary, current_year + ) + self.process_interval( + table, + grid_debits, + grid_credits, + account_summary, + interval, + first_interval, + len(interval_keys) == idx + 1, + ) + first_interval = False + # add all the accumulated stuff to the table + table.add_row(grid_debits, grid_credits) + + def process_interval( + self, + table, + grid_debits, + grid_credits, + account_summary, + interval, + first_interval, + final_interval, + ): + current_year = None + # inspect(interval) + + for idx, row in enumerate(self.process_entries(interval)): + if ( + current_year is None + or ( + (row.debit_entry.date.year != current_year) + and not isinstance(row.debit_entry, TTotalEntry) + ) + or ( + (row.credit_entry.date.year != current_year) + and not isinstance(row.credit_entry, TTotalEntry) + ) + ): + current_year = row.debit_entry.date.year + ( + self.print_year_header( + table, + grid_debits, + grid_credits, + account_summary, + current_year, + ) + if idx + else None + ) + debit_style = Style(bgcolor="default") + credit_style = Style(bgcolor="default") + + if interval[ + "status" + ] == account_summary.Status.DEBIT_BALANCE and self.renderer.options.get( + "highlight_debitors" + ): + debit_style += Style(bgcolor="light_steel_blue1") + if interval[ + "status" + ] == account_summary.Status.CREDIT_BALANCE and self.renderer.options.get( + "highlight_creditors" + ): + credit_style += Style(bgcolor="thistle1") + grid_debits.add_row(*row.debit_entry.as_cols(), style=debit_style) + grid_credits.add_row(*row.credit_entry.as_cols(), style=credit_style) + + # @TODO this very dumb. no do plz + if final_interval and account_summary.final_balance: + # append_final_balance + row = self.append_final_balance(account_summary, interval) + if row: + grid_debits.add_row(*row.debit_entry.as_cols()) + grid_credits.add_row(*row.credit_entry.as_cols()) + + def print_year_header( + self, + table, + grid_debits, + grid_credits, + account_summary, + year, + ): + grid_debits.add_row( + f"{year}", + " ", + Padding( + Text(CurrencySymbols.get_symbol(account_summary.currency)), + (0, 2, 0, 0), + ), + style="bold", + ) + grid_credits.add_row( + f"{year}", + " ", + Padding( + Text(CurrencySymbols.get_symbol(account_summary.currency)), + (0, 2, 0, 0), + ), + style="bold", + ) + + def process_entries( + self, + interval, + ): + debits_list = [] + credits_list = [] + entries = interval["entries"] + group_intervals = interval["group_intervals"] + + # find the last day of the interval + last_day = last_day_of( + interval["interval_key_dt"], + interval["balance_interval"], + ) + + grouped_debits = self.append_entries( + interval, + entries.debits(), + "debit_bd", + "debit_cd", + group_intervals, + last_day, + ) + grouped_credits = self.append_entries( + interval, + entries.credits(), + "credit_bd", + "credit_cd", + group_intervals, + last_day, + ) + + for key in sorted(set(grouped_debits.keys()).union(grouped_credits.keys())): + debits = grouped_debits[key] + creditz = grouped_credits[key] + max_len = max(len(debits), len(creditz)) + padded_debits = debits + [ + TEmpty(date=debits[-1].date if debits else creditz[-1].date) + ] * (max_len - len(debits)) + padded_credits = creditz + [ + TEmpty(date=creditz[-1].date if creditz else debits[-1].date) + ] * (max_len - len(creditz)) + debits_list.extend(padded_debits) + credits_list.extend(padded_credits) + + zipped = list( + itertools.zip_longest( + debits_list, + credits_list, + ) + ) + rows = [] + for debit, credit in zipped: + # inspect(credit) + rows.append(TRow(debit, credit)) + + rows.append( + TRow( + self.renderer.factory.ttotalentry( + last_day, interval["total"], one_line=len(rows) == 1 + ), + self.renderer.factory.ttotalentry( + last_day, interval["total"], one_line=len(rows) == 1 + ), + ) + ) + return rows + + def append_entries( + self, interval, entries, bd_key, cd_key, group_intervals, last_day + ): + """ + append the entries to the interval + :param interval: + :param entries: + :param bd_key: + :param cd_key: + :param group_intervals: + :param last_day: used to place the cd entry at the end + :return: + """ + grouped_entries = defaultdict(list) + if interval[bd_key]: + key = get_interval_key( + interval["interval_key_dt"], + group_intervals, + ) + grouped_entries[key].append( + self.renderer.factory.tbdentry( + date=interval["interval_key_dt"], + amount=interval[bd_key], + ) + ) + + for entry in entries: + key = get_interval_key( + entry.trans_date, + group_intervals, + ) + grouped_entries[key].append( + self.renderer.factory.tentry( + date=entry.trans_date, + narrative=entry.narrative, + amount=entry.amount, + ) + ) + if interval[cd_key]: + key = get_interval_key( + last_day, + group_intervals, + ) + grouped_entries[key].append( + self.renderer.factory.tcdentry( + date=last_day, + amount=interval[cd_key], + ) + ) + return grouped_entries + + def append_final_balance(self, account_summary, final_interval): + # this is pretty dumb. @TODO move this stuff into the account_summary + if account_summary.balance_interval: + # if we have an interval, we can calculate the first day + # of the suffix period + first_day = first_day_of_next( + final_interval["interval_key_dt"], + final_interval["balance_interval"], + ) + else: + # else the first day of the suffix is today + first_day = datetime.today().date() + + interval = account_summary.entries_grouped["suffix"] + if interval["debit_bd"]: + return TRow( + self.renderer.factory.tbdentry( + date=first_day, + amount=interval["debit_bd"], + ), + TEmpty(date=datetime.today().date()), + ) + elif interval["credit_bd"]: + return TRow( + TEmpty(date=datetime.today().date()), + self.renderer.factory.tbdentry( + date=first_day, + amount=interval["credit_bd"], + ), + ) diff --git a/general_ledger/render/format_table_rich_three_col.py b/general_ledger/render/format_table_rich_three_col.py new file mode 100644 index 0000000..c514f08 --- /dev/null +++ b/general_ledger/render/format_table_rich_three_col.py @@ -0,0 +1,113 @@ +from decimal import Decimal + +from decimal import Decimal + +from rich import box +from rich.table import Table + +from general_ledger.django.models import Direction +from general_ledger.render.formats_abc import TableFormat +from general_ledger.utils.utility_date_stuff import ( + last_day_of, +) + + +class ThreeColumnFormat(TableFormat): + """ + K Tandy + ------------------------------------------------------------------------------- + Debit Credit Balance + 2012 GBP GBP GBP + Aug 1 Sales | 144.00| | 144.00 + Aug 19 Sales | 300.00| | 444.00 + Aug 22 Bank Account | | 144.00| 300.00 + Aug 28 Bank Account | | 300.00| 0.00 + + """ + + def __init__(self): + super().__init__() + self.config_key = "three_column" + self.table = Table( + box=box.SIMPLE, + show_header=True, + show_footer=False, + show_edge=False, + show_lines=False, + # caption="this is the end, my beautiful friend", + caption="", + highlight=True, + pad_edge=False, + min_width=75, + ) + self.renderer = None + # self.grid_debits = Table.grid(expand=True) + # self.grid_credits = Table.grid(expand=True) + + def render_table(self, renderer, account_summary): + self.renderer = renderer + renderer.console.print("[bold]3-Column Format[/bold]") + self.table.title = account_summary.title + self.table.add_column("Date", justify="left", style="cyan") + self.table.add_column("Narrative", justify="left", style="magenta") + self.table.add_column("Debit", justify="center", style="blue") + self.table.add_column("Debit", justify="center", style="yellow") + self.table.add_column("Balance", justify="center", style="yellow") + self.table.add_column("Type", justify="center", style="yellow") + interval_keys = account_summary.entries_grouped["meta"]["interval_keys"] + first_interval = True + current_year = None + # inspect(account_summary) + + for idx, interval_key in enumerate(interval_keys): + + interval = account_summary.entries_grouped[interval_key] + self.process_interval( + account_summary, interval, first_interval, len(interval_keys) == idx + 1 + ) + first_interval = False + + renderer.renderable = self.table + + def process_interval( + self, account_summary, interval, first_interval, final_interval + ): + current_year = None + self.table.add_row(interval["interval_key_dt"].strftime("%Y")) + for idx, row in enumerate( + self.process_entries_3_col(account_summary, interval) + ): + self.table.add_row(*row.as_cols()) + + def process_entries_3_col(self, account_summary, interval): + """ + convert a summary into TEntry objects for rendering + :param account_summary: + :param interval: + :return: + """ + rows = [] + entries = interval["entries"] + group_intervals = interval["group_intervals"] + last_day = last_day_of( + interval["interval_key_dt"], + interval["balance_interval"], + ) + for entry in entries: + running_balance = entry.running_balance() + balance_type = ( + ("Dr" if entry.account.type.direction == Direction.DEBIT else "Cr") + if running_balance + else "" + ) + row = self.renderer.factory.threecolentry( + date=entry.trans_date, + narrative=entry.get_counter_entry(), + debit_amount=entry.amount if entry.is_debit else Decimal("0.00"), + credit_amount=entry.amount if entry.is_credit else Decimal("0.00"), + balance=running_balance, + balance_type=balance_type, + ) + rows.append(row) + + return rows diff --git a/general_ledger/render/format_table_rich_trial_balance.py b/general_ledger/render/format_table_rich_trial_balance.py new file mode 100644 index 0000000..4911ad7 --- /dev/null +++ b/general_ledger/render/format_table_rich_trial_balance.py @@ -0,0 +1,135 @@ +from decimal import Decimal + +from rich.box import Box +from rich.table import Table + +from general_ledger.builders.account_summary_builder import AccountSummary +from general_ledger.django.models import Direction +from general_ledger.render.formats_abc import TableFormat +from general_ledger.utils.utility_date_stuff import ( + last_day_of, +) + +HEADER_BOTTOM_ONLY: Box = Box( + " ══ \n" # header top crossbar (called top) + " \n" # head (called head) + " ══ \n" # header lower crossbar (called head_row) + " │ \n" + " ─┼ \n" + " ─┼ \n" + " │ \n" + " ╵ \n" +) + + +class TrialBalance(TableFormat): + + def __init__(self): + super().__init__() + self.config_key = "trial_balance" + self.table = Table( + expand=True, + box=HEADER_BOTTOM_ONLY, + show_header=True, + show_footer=False, + show_edge=True, + show_lines=False, + # caption="this is the end, my beautiful friend", + caption="", + highlight=True, + pad_edge=False, + # min_width=75, + # header_style="bold black on dark_sea_green1", + # row_styles=["yellow on green", "red on white"], + ) + self.renderer = None + # self.grid_debits = Table.grid(expand=True) + # self.grid_credits = Table.grid(expand=True) + + def render_table(self, renderer, account_set): + self.renderer = renderer + self.table.title = f"Trial balance as of {account_set.end_date}" + self.table.add_column("Account", justify="left", style="cyan") + self.table.add_column("Debit", justify="right", style="magenta") + self.table.add_column("Credit", justify="right", style="blue") + + for account_summary in account_set.summary_set: + suffix = account_summary.entries_grouped["suffix"] + # @TODO make account summary only return relevant accounts + if suffix["status"] == AccountSummary.Status.EMPTY: + print(f"Skipping {account_summary.title}") + continue + self.table.add_row( + account_summary.title, + renderer.factory.fmt(account_summary.debit_balance), + renderer.factory.fmt(account_summary.credit_balance), + ) + + trial_debit_balance = renderer.factory.fmt(account_set.trial_debit_balance) + trial_credit_balance = renderer.factory.fmt(account_set.trial_credit_balance) + debit_line_len = trial_debit_balance.cell_len + credit_line_len = trial_credit_balance.cell_len + self.table.add_row( + "", + ("-" * debit_line_len), + ("-" * credit_line_len), + ) + self.table.add_row( + "", + trial_debit_balance, + trial_credit_balance, + ) + self.table.add_row( + "", + ("=" * debit_line_len), + ("=" * credit_line_len), + ) + + first_interval = True + current_year = None + # inspect(account_summary) + + renderer.renderable = self.table + + def process_interval( + self, account_summary, interval, first_interval, final_interval + ): + current_year = None + self.table.add_row(interval["interval_key_dt"].strftime("%Y")) + for idx, row in enumerate( + self.process_entries_3_col(account_summary, interval) + ): + self.table.add_row(*row.as_cols()) + + def process_entries_3_col(self, account_summary, interval): + """ + convert a summary into TEntry objects for rendering + :param account_summary: + :param interval: + :return: + """ + rows = [] + entries = interval["entries"] + group_intervals = interval["group_intervals"] + last_day = last_day_of( + interval["interval_key_dt"], + interval["balance_interval"], + ) + for entry in entries: + running_balance = entry.running_balance() + balance_type = ( + ("Dr" if entry.account.type.direction == Direction.DEBIT else "Cr") + if running_balance + else "" + ) + row = self.renderer.factory.threecolentry( + date=entry.trans_date, + narrative=entry.get_counter_entry(), + debit_amount=entry.amount if entry.is_debit else Decimal("0.00"), + credit_amount=entry.amount if entry.is_credit else Decimal("0.00"), + balance=running_balance, + balance_type=balance_type, + ) + rows.append(row) + + return rows diff --git a/general_ledger/render/formats_abc.py b/general_ledger/render/formats_abc.py new file mode 100644 index 0000000..64369eb --- /dev/null +++ b/general_ledger/render/formats_abc.py @@ -0,0 +1,19 @@ +from abc import ABC, abstractmethod + + +class TableFormat(ABC): + def __init__(self): + self.config_key = None + + @abstractmethod + def render_table(self, renderer, account_summary): + pass + + +class StatementFormat(ABC): + def __init__(self): + self.config_key = None + + @abstractmethod + def render_statement(self, renderer, account_summary): + pass diff --git a/general_ledger/render/mixins/__init__.py b/general_ledger/render/mixins/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/general_ledger/render/mixins/__init__.py @@ -0,0 +1 @@ + diff --git a/general_ledger/render/mixins/renderable.py b/general_ledger/render/mixins/renderable.py new file mode 100644 index 0000000..ef3d351 --- /dev/null +++ b/general_ledger/render/mixins/renderable.py @@ -0,0 +1,36 @@ +from general_ledger.render.renderer_abc import Renderer + + +class RenderableMixin: + + renderer: Renderer = None + + def render(self): + if not self.renderer: + raise ValueError(f"No renderer set for '{self}'") + return self.renderer.render(self) + + def set_table_format(self, table_format, **options): + if hasattr(self.renderer, "set_table_format"): + self.renderer.set_table_format(table_format, **options) + else: + raise ValueError( + f"Renderer {self.renderer} does not support table format '{table_format}' set_table_format" + ) + return self + + def set_render_format(self, render_format, value, **options): + if hasattr(self.renderer, f"set_{render_format}_format"): + func = getattr(self.renderer, f"set_{render_format}_format") + func(value, **options) + else: + raise ValueError( + f"Renderer {self.renderer} does not support format '{render_format}' with value '{value}' set_{render_format}_format" + ) + return self + + def set_renderer(self, renderer: Renderer): + if not isinstance(renderer, Renderer): + raise ValueError("Renderer must be an instance of a Renderer") + self.renderer = renderer + return self diff --git a/general_ledger/render/renderables.py b/general_ledger/render/renderables.py new file mode 100644 index 0000000..fcc7801 --- /dev/null +++ b/general_ledger/render/renderables.py @@ -0,0 +1,215 @@ +from rich.console import Console +from rich.console import ConsoleOptions, RenderResult +from rich.panel import Panel +from rich.table import Table + +from general_ledger.render.utility_rich import fmt + + +def render_2_cols(col1, col2): + grid = Table.grid() + grid.add_column() + grid.add_column() + grid.add_row(col1, col2) + panel = Panel( + grid, + expand=True, + padding=0, + ) + return panel + + +class RendererContext: + def __init__(self, options): + self.options = options + + +class RendererFactory: + def __init__(self, context): + self.context = context + + def fmt(self, *args, **kwargs): + return fmt(*args, **kwargs, context=self.context) + + def tentry(self, *args, **kwargs): + return TEntry(*args, **kwargs, context=self.context) + + def trow(self, *args, **kwargs): + return TRow(*args, **kwargs, context=self.context) + + def tempty(self, *args, **kwargs): + return TEmpty(*args, **kwargs, context=self.context) + + def tcdentry(self, *args, **kwargs): + return TCDEntry(*args, **kwargs, context=self.context) + + def tbdentry(self, *args, **kwargs): + return TBDEntry(*args, **kwargs, context=self.context) + + def ttotalentry(self, *args, **kwargs): + return TTotalEntry(*args, **kwargs, context=self.context) + + def threecolentry(self, *args, **kwargs): + return ThreeColEntry(*args, **kwargs, context=self.context) + + +class ThreeColEntry: + """ + represents a single entry in a Three col account + """ + + def __init__( + self, + date, + narrative, + debit_amount, + credit_amount, + balance, + balance_type, + folio=None, + context=None, + ): + self.date = date + self.narrative = narrative + self.debit_amount = debit_amount + self.credit_amount = credit_amount + self.balance = balance + self.balance_type = balance_type + self.folio = folio + self.context = context if context else {} + + def __str__(self): + return f"{self.date} {self.narrative} {self.debit_amount} {self.credit_amount}" + + def __rich_console__( + self, console: Console, options: ConsoleOptions + ) -> RenderResult: + yield from self.as_cols() + + def as_cols(self): + date_fmt = self.context.get("date_format", "%-m.%-d") + yield f"[lightyellow]{self.date.strftime(date_fmt)}" if self.date else "" + yield f"[black]{self.narrative}" if self.narrative else "" + decimal_format = self.context.get("decimal_format", "10.2f") + yield ( + f"[blue]{self.debit_amount.__format__(decimal_format)}" + if self.debit_amount + else "" + ) + yield ( + f"[red]{self.credit_amount.__format__(decimal_format)}" + if self.credit_amount + else "" + ) + yield f"[red]{self.balance.__format__(decimal_format)}" + yield f"[red]{self.balance_type: >2}" + + +class TEntry: + """ + represents a single entry in a T-account + """ + + def __init__(self, date, narrative, amount, folio=None, context=None): + self.date = date + self.narrative = narrative + self.amount = amount + self.folio = folio + self.context = context if context else {} + + def __str__(self): + return f"{self.date} {self.narrative} {self.amount}" + + def __rich_console__( + self, console: Console, options: ConsoleOptions + ) -> RenderResult: + yield from self.as_cols() + + def as_cols(self): + date_fmt = self.context.get("date_format", "%-m.%-d") + yield f"[yellow]{self.date.strftime(date_fmt)}" if self.date else "" + yield f"[black]{self.narrative}" if self.narrative else "" + decimal_format = self.context.get("decimal_format", "10.2f") + yield (f"[blue]{self.amount.__format__(decimal_format)}" if self.amount else "") + + +class TBDEntry(TEntry): + """ + represents a single entry in a T-account for a b/d + """ + + def __init__(self, date, amount, folio=None, **kwargs): + self.narrative = "Bal b/d" + super().__init__(date, self.narrative, amount, folio, **kwargs) + + +class TCDEntry(TEntry): + """ + represents a single entry in a T-account for a c/d + """ + + def __init__(self, date, amount, **kwargs): + self.narrative = "Bal c/d" + super().__init__(date, self.narrative, amount, **kwargs) + + +class TEmpty(TEntry): + """ + represents an empty row in a T-account + """ + + def __init__(self, date=None, narrative=None, amount=None, **kwargs): + super().__init__(date, narrative, amount, **kwargs) + + def __rich__(self) -> str: + return "" + + def as_cols(self): + yield from [ + "", + "", + "", + ] + + +class TTotalEntry(TEntry): + """ + represents a total entry in a T-account + """ + + def __init__(self, date, amount, narrative=None, one_line=False, **kwargs): + super().__init__(date, narrative, amount, **kwargs) + self.one_line = one_line + + def __rich__(self) -> str: + return f"[bold cyan]{self.amount}" + + def as_cols(self): + decimal_format = self.context.get("decimal_format", "10.2f") + amount = self.amount.__format__(decimal_format) + total_len = len(amount.strip()) + 2 + yield from [ + f"", + "", + ( + f"[b green]{'='*total_len}[/b green]" + if self.one_line + else f"[b green]{'-'*total_len}[/b green]\n[blue]{amount}[/]\n[b green]{'='*total_len}[/b green]" + ), + ] + + +class TRow: + """ + represents a row in a T-account, pair of entries + """ + + def __init__(self, debit_entry, credit_entry, *args, **kwargs): + self.debit_entry = debit_entry + self.credit_entry = credit_entry + + def __str__(self): + return f"{self.debit_entry} {self.credit_entry}" + + def __repr__(self): + return f"[{self.debit_entry}, {self.credit_entry}]" diff --git a/general_ledger/render/renderer_abc.py b/general_ledger/render/renderer_abc.py new file mode 100644 index 0000000..987268f --- /dev/null +++ b/general_ledger/render/renderer_abc.py @@ -0,0 +1,36 @@ +from abc import ABC, abstractmethod + +from loguru import logger + + +class Renderer(ABC): + def __init__(self): + self.account_summary = None + self.return_renderable = False + self.print_console = True + self.options = None + + def render(self, renderable_object, **options): + logger.trace(f"Rendering with {self.__class__.__name__}") + self.account_summary = renderable_object + if self.options and hasattr(self.options, "overrides"): + # self.options = RenderOptions(RendererConfig().get(self.__class__.__name__)) + self.options.overrides = options if options else self.options.overrides + self.print_header() + self.print_intervals() + self.print_footer() + return self.do_render() + + def print_header(self): + pass + + @abstractmethod + def print_intervals(self): + pass + + def print_footer(self): + pass + + @abstractmethod + def do_render(self): + pass diff --git a/general_ledger/render/renderer_jinja2console.py b/general_ledger/render/renderer_jinja2console.py new file mode 100644 index 0000000..102fd1d --- /dev/null +++ b/general_ledger/render/renderer_jinja2console.py @@ -0,0 +1,34 @@ +from jinja2 import Template + +from general_ledger.render.renderer_abc import Renderer + + +class Jinja2ConsoleRenderer(Renderer): + def print_intervals(self): + pass + + def do_render(self): + pass + + def print_header(self): + template = Template("[HEADER] Account Summary Report\n") + print(template.render()) + + def print_opening_balance(self): + template = Template("Opening Balance: {{ opening_balance }}\n") + print(template.render(opening_balance=self.account_summary.opening_balance)) + + def print_entries(self): + for entry in self.account_summary.entries: + template = Template("Entry: {{ entry }}\n") + print(template.render(entry=entry)) + + def print_closing_balance(self): + template = Template( + "Closing Balance: {{ self.account_summary['suffix']['totals'] }}\n" + ) + print(template.render(closing_balance=self.account_summary.closing_balance)) + + def print_footer(self): + template = Template("[FOOTER] End of Report\n") + print(template.render()) diff --git a/general_ledger/render/renderer_jinja2web.py b/general_ledger/render/renderer_jinja2web.py new file mode 100644 index 0000000..3afc534 --- /dev/null +++ b/general_ledger/render/renderer_jinja2web.py @@ -0,0 +1,34 @@ +from jinja2 import Template + +from general_ledger.render.renderer_abc import Renderer + + +class Jinja2WebRenderer(Renderer): + def print_intervals(self): + pass + + def do_render(self): + pass + + def print_header(self): + template = Template("[HEADER] Account Summary Report\n") + print(template.render()) + + def print_opening_balance(self): + template = Template("Opening Balance: {{ opening_balance }}\n") + print(template.render(opening_balance=self.account_summary.opening_balance)) + + def print_entries(self): + for entry in self.account_summary.entries: + template = Template("Entry: {{ entry }}\n") + print(template.render(entry=entry)) + + def print_closing_balance(self): + template = Template( + "Closing Balance: {{ self.account_summary['suffix']['totals'] }}\n" + ) + print(template.render(closing_balance=self.account_summary.closing_balance)) + + def print_footer(self): + template = Template("[FOOTER] End of Report\n") + print(template.render()) diff --git a/general_ledger/render/renderer_rich.py b/general_ledger/render/renderer_rich.py new file mode 100644 index 0000000..b14291e --- /dev/null +++ b/general_ledger/render/renderer_rich.py @@ -0,0 +1,59 @@ +from loguru import logger +from rich.box import Box +from rich.console import Console +from rich.theme import Theme + +from general_ledger.render.config_options import RenderOptions, RendererConfig +from general_ledger.render.format_table_rich_t_account import TAccountFormat +from general_ledger.render.formats_abc import TableFormat +from general_ledger.render.renderables import ( + RendererFactory, +) +from general_ledger.render.renderer_abc import Renderer + +trial_balance_theme = Theme( + { + "trial.table.header": "dim cyan", + } +) + + +class RichConsoleRenderer(Renderer): + + def __init__(self, table_format=None, config=None, **options): + super().__init__() + self.return_renderable = True + self.print_console = False + self.console = Console(theme=trial_balance_theme) + # @TODO any way to load a default without importing the type? + self.table_format = table_format or TAccountFormat() + self.renderable = None # rich renderable object + self.config = config or RendererConfig() + self.options = RenderOptions( + config=self.config.get("rich", self.table_format.config_key), **options + ) + # self.context = RendererContext(RenderOptions(config=config, **options)) + self.factory = RendererFactory(self.options) + + def set_table_format(self, table_format, **options): + logger.trace("setting table format to {}", table_format) + if not isinstance(table_format, TableFormat): + raise ValueError("Table format must be an instance of TableFormat") + self.table_format = table_format + self.options.overrides.update(options) + self.options = RenderOptions( + config=self.config.get("rich", self.table_format.config_key), + **self.options.overrides, + ) + return self + + def print_intervals(self): + if not self.table_format: + raise ValueError("table_format not set") + self.table_format.render_table(self, self.account_summary) + + def do_render(self): + if self.print_console: + self.console.print(self.renderable) + if self.return_renderable: + return self.renderable diff --git a/general_ledger/render/renderer_statement.py b/general_ledger/render/renderer_statement.py new file mode 100644 index 0000000..d96e2b6 --- /dev/null +++ b/general_ledger/render/renderer_statement.py @@ -0,0 +1,57 @@ +from loguru import logger +from rich.console import Console + +from general_ledger.render.config_options import RenderOptions, RendererConfig +from general_ledger.render.format_statement_rich_table import StatementFormatRichTable +from general_ledger.render.formats_abc import StatementFormat +from general_ledger.render.renderer_abc import Renderer + +logger = logger.opt(colors=True) +console = Console() + + +class StatementRenderer(Renderer): + """Renders hierarchical statement nodes with running totals""" + + def __init__( + self, + statement_format=None, + config=None, + print_console=False, + return_renderable=True, + **options, + ): + super().__init__() + self.return_renderable = return_renderable + self.print_console = print_console + self.console = Console() + # self.context = StatementRenderingContext() + self.statement_format = statement_format or StatementFormatRichTable() + self.config = config or RendererConfig() + self.options = RenderOptions( + config=self.config.get("rich", self.statement_format.config_key), + **options, + ) + self.renderable = None # rich renderable object + + def set_statement_format(self, value, **options): + if not isinstance(value, StatementFormat): + raise ValueError("format must be an instance of StatementFormat") + self.statement_format = value + self.options.overrides.update(options) + + def print_intervals(self): + if not self.statement_format: + raise ValueError("statement_format not set") + return self.statement_format.render_statement(self, self.account_summary) + + def do_render(self): + if self.print_console: + logger.trace("printing console") + self.console.print(self.renderable) + if self.return_renderable: + logger.trace("returning renderable") + return self.renderable + + def set_render_options(self, options): + self.options = options diff --git a/general_ledger/render/utility_rich.py b/general_ledger/render/utility_rich.py new file mode 100644 index 0000000..6b3ebc4 --- /dev/null +++ b/general_ledger/render/utility_rich.py @@ -0,0 +1,185 @@ +import datetime +import random +from collections import defaultdict +from decimal import Decimal +from itertools import zip_longest +from uuid import UUID + +from rich import inspect +from rich.panel import Panel +from rich.style import Style +from rich.table import Table +from rich.text import Text + +from general_ledger.builders.mixins import StartEndBuilderMixin +from general_ledger.render.mixins.renderable import RenderableMixin +from general_ledger.utils.django import DjangoUtil +from general_ledger.utils.utility import is_iterable +from rich.console import Console + +row_styles = [ + Style( + bgcolor=f"rgb({random.randint(153, 255)}, {random.randint(153, 255)}, {random.randint(153, 255)})" + ) + for _ in range(20) # Create 10 random light colors +] + + +background_styles = [ + Style( + bgcolor=f"rgb({random.randint(200, 255)}, {random.randint(200, 255)}, {random.randint(200, 255)})" + ) + for _ in range(40) # Create 10 random light colors +] + + +def random_style(target="row"): + if target == "row": + return random.choice(row_styles) + elif target == "col": + return random.choice(background_styles) + else: + raise ValueError("target must be 'row' or 'col'") + + +def render_any_item(items): + if isinstance(items, list | tuple): + return [render_if_possible(item) for item in items] + return render_if_possible(items) + + +def render_if_possible(item): + if isinstance(item, RenderableMixin): + return item.render() + return item + + +# iterate over the +def lists_to_grid_cols( + *args, + expand=True, + random_styles=False, +): + table = Table.grid(expand=expand) + col_args = { + "ratio": 1, + } + for _ in args: + style = random.choice(background_styles) if random_styles else None + table.add_column(style=style, **col_args) + args = [arg if is_iterable(arg) else [arg] for arg in args] + grid = zip_longest(*args, fillvalue=None) + for row in grid: + table.add_row(*render_any_item(row)) + return table + + +def t_account_col_grid() -> tuple[Table, Table]: + """Create a grid with 3 columns for use in TAccountFormat""" + for grid in ( + grid1 := Table.grid(expand=True), + grid2 := Table.grid( + expand=True, + pad_edge=False, + ), + ): + grid.add_column( + justify="left", + min_width=6, + # style=random_style("col"), + ) + grid.add_column( + justify="left", + # style=random_style("col"), + ) + grid.add_column( + justify="right", + # style=random_style("col"), + ) + return grid1, grid2 + + +def model_table_generator(queryset, model): + table = Table(expand=False) + fields = DjangoUtil.get_fields(model) + for field in fields: + table.add_column(field) + count = 0 + for item in queryset: + if count > 20: + table.add_row("truncated", "...") + break + table.add_row( + *[fmt(getattr(item, field)) for field in fields], + ) + count += 1 + yield table + + +def fmt(item, style: str = "cyan", context=None) -> Text: + """this is a field formatter to allow passing stuff to rich Table row""" + context = context or {} + decimal_format = context.get("decimal_format", ">8,.0f") + date_format = context.get("date_format", "%b %e") + if isinstance(item, Decimal): + if item == 0: + return Text("", style=style) + elif item < 0: + style = "red" + elif item > 0: + style = "blue" + return Text(item.__format__(decimal_format), style=style) + elif isinstance(item, UUID): + return Text(str(item), style=style) + elif isinstance(item, datetime.date): + return Text(str(item), style="green") + elif isinstance(item, defaultdict): + return Text( + str({k: v for (k, v) in item.items()}).replace(" ", ""), style=style + ) + else: + return Text(str(item), style=style) + + +class ConsoleReportBuilder( + StartEndBuilderMixin, +): + def __init__(self, *args, **kwargs): + """ + Initialize the builder + """ + self.panel: bool = kwargs.pop("panel", False) + super().__init__(*args, **kwargs) + self.console = Console() + self.context = kwargs + self.columns: dict[str, list] = defaultdict(list) + + def add_column_item(self, column, item): + self.columns[column].append(item) + return self + + def add_column_items(self, column, items): + for item in items: + self.add_column_item(column, item) + return self + + def build(self): + grid = Table.grid( + expand=True, + ) + col_args = { + "ratio": 1, + } + for col, items in self.columns.items(): + grid.add_column(col, **col_args) + for row in zip_longest(*self.columns.values(), fillvalue=None): + # inspect(row) + grid.add_row( + *[ + item.render() if isinstance(item, RenderableMixin) else item + for item in row + ] + ) + if self.panel: + return Panel(grid) + return grid diff --git a/general_ledger/render/utility_rich_custom.py b/general_ledger/render/utility_rich_custom.py new file mode 100644 index 0000000..4fa924f --- /dev/null +++ b/general_ledger/render/utility_rich_custom.py @@ -0,0 +1,27 @@ +from uuid import UUID + +from rich.protocol import is_renderable +from rich.table import Table as RichTable + +from general_ledger.render.utility_rich import fmt + +""" +the idea here was have a custom rich table which could create renderable objects from django and GL fields types such as UUID and decimal. however it runs into the problem +that the imports are circular. so this needs some lazy loading logic to work properly. +""" + + +class Table(RichTable): + """A custom table class that converts UUIDs to strings""" + + def make_renderable(obj): + if isinstance(obj, UUID): + return str(obj) + if not is_renderable(obj): + return repr(obj) + return obj + + def add_row(self, *args, **kwargs): + # Convert all row items to renderable + renderable_args = [fmt(arg) for arg in args] + super().add_row(*renderable_args, **kwargs) diff --git a/general_ledger/resources/account.py b/general_ledger/resources/account.py index 5409a61..a992ef2 100644 --- a/general_ledger/resources/account.py +++ b/general_ledger/resources/account.py @@ -2,7 +2,7 @@ from import_export.admin import ImportExportModelAdmin from import_export.fields import Field -from general_ledger.models import Account, Book +from general_ledger.django.models import Account, Book class AccountResourceSimple(resources.ModelResource): diff --git a/general_ledger/resources/account_type.py b/general_ledger/resources/account_type.py index 6d04f3b..363ede8 100644 --- a/general_ledger/resources/account_type.py +++ b/general_ledger/resources/account_type.py @@ -2,7 +2,7 @@ from import_export.admin import ImportExportModelAdmin from import_export.fields import Field -from general_ledger.models import Account, Book, AccountType +from general_ledger.django.models import Account, Book, AccountType class AccountTypeResource(resources.ModelResource): diff --git a/general_ledger/resources/bank_statement_import.py b/general_ledger/resources/bank_statement_import.py index acf655d..fd764fc 100644 --- a/general_ledger/resources/bank_statement_import.py +++ b/general_ledger/resources/bank_statement_import.py @@ -2,7 +2,7 @@ from import_export import resources -from general_ledger.models import BankStatementLine +from general_ledger.django.models import BankStatementLine def check_unique_transaction(**kwargs): diff --git a/general_ledger/resources/book.py b/general_ledger/resources/book.py index b710722..154877d 100644 --- a/general_ledger/resources/book.py +++ b/general_ledger/resources/book.py @@ -3,7 +3,7 @@ from import_export import fields, resources from import_export.widgets import ForeignKeyWidget -from general_ledger.models import Book +from general_ledger.django.models import Book from django.contrib.auth import get_user_model diff --git a/general_ledger/resources/invoice.py b/general_ledger/resources/invoice.py index 910b217..a4d7394 100644 --- a/general_ledger/resources/invoice.py +++ b/general_ledger/resources/invoice.py @@ -1,6 +1,6 @@ from import_export import resources, fields -from general_ledger.models import Invoice +from general_ledger.django.models import Invoice class InvoiceResource(resources.ModelResource): diff --git a/general_ledger/resources/tax_rate.py b/general_ledger/resources/tax_rate.py index d494410..691bac1 100644 --- a/general_ledger/resources/tax_rate.py +++ b/general_ledger/resources/tax_rate.py @@ -3,7 +3,7 @@ from import_export import fields, resources from import_export.widgets import ForeignKeyWidget -from general_ledger.models import TaxType, TaxRate +from general_ledger.django.models import TaxType, TaxRate class TaxRateResource(resources.ModelResource): diff --git a/general_ledger/resources/tax_type.py b/general_ledger/resources/tax_type.py index 3afafa2..0bf9ad1 100644 --- a/general_ledger/resources/tax_type.py +++ b/general_ledger/resources/tax_type.py @@ -1,6 +1,6 @@ from import_export import resources -from general_ledger.models import TaxType +from general_ledger.django.models import TaxType class TaxTypeResource(resources.ModelResource): diff --git a/general_ledger/resources/transaction.py b/general_ledger/resources/transaction.py index 7c89b2f..a3a5c55 100644 --- a/general_ledger/resources/transaction.py +++ b/general_ledger/resources/transaction.py @@ -2,7 +2,7 @@ from import_export import resources, fields from import_export.forms import ImportForm, ConfirmImportForm -from general_ledger.models import Ledger, Transaction +from general_ledger.django.models import Ledger, Transaction import logging diff --git a/general_ledger/resources/xero_gl_import.py b/general_ledger/resources/xero_gl_import.py index 145b72a..94cf12b 100644 --- a/general_ledger/resources/xero_gl_import.py +++ b/general_ledger/resources/xero_gl_import.py @@ -1,7 +1,6 @@ from import_export import resources -from general_ledger.models import Transaction -from general_ledger.models.xero_gl_import import XeroGlImport +from general_ledger.django.models.xero_gl_import import XeroGlImport from import_export.fields import Field from import_export.widgets import DateWidget, DecimalWidget diff --git a/general_ledger/ruleset/README.md b/general_ledger/ruleset/README.md new file mode 100644 index 0000000..a8d651e --- /dev/null +++ b/general_ledger/ruleset/README.md @@ -0,0 +1,4 @@ +# title + +attempt to create a ruleset for general ledger import rules + diff --git a/general_ledger/ruleset/__init__.py b/general_ledger/ruleset/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/general_ledger/ruleset/interpretor.py b/general_ledger/ruleset/interpretor.py new file mode 100644 index 0000000..13f12df --- /dev/null +++ b/general_ledger/ruleset/interpretor.py @@ -0,0 +1,77 @@ +from general_ledger.ruleset.recursive_with_stack import StackNode, StackNodeVisitor + + +class UnaryOperator(StackNode): + def __init__(self, operand): + self.operand = operand + + +class BinaryOperator(StackNode): + def __init__(self, left, right): + self.left = left + self.right = right + + +class Add(BinaryOperator): + pass + + +class Sub(BinaryOperator): + pass + + +class Mul(BinaryOperator): + pass + + +class Div(BinaryOperator): + pass + + +class Negate(UnaryOperator): + pass + + +class Number(StackNode): + def __init__(self, value): + self.value = value + + +# A sample visitor class that evaluates expressions +class Evaluator(StackNodeVisitor): + def visit_Number(self, node): + return node.value + + def visit_Add(self, node): + return self.visit(node.left) + self.visit(node.right) + + def visit_Sub(self, node): + return self.visit(node.left) - self.visit(node.right) + + def visit_Mul(self, node): + return self.visit(node.left) * self.visit(node.right) + + def visit_Div(self, node): + return self.visit(node.left) / self.visit(node.right) + + def visit_Negate(self, node): + return -self.visit(node.operand) + + +class Evaluator2(StackNodeVisitor): + def visit_Add(self, node): + yield (yield Visit(node.left)) + (yield Visit(node.right)) + + def visit_Sub(self, node): + yield (yield Visit(node.left)) - (yield Visit(node.right)) + + +if __name__ == "__main__": + # 1 + 2*(3-4) / 5 + t1 = Sub(Number(3), Number(4)) + t2 = Mul(Number(2), t1) + t3 = Div(t2, Number(5)) + t4 = Add(Number(1), t3) + # Evaluate it + e = Evaluator() + print(e.visit(t4)) # Outputs 0.6 diff --git a/general_ledger/ruleset/recursive_with_stack.py b/general_ledger/ruleset/recursive_with_stack.py new file mode 100644 index 0000000..f828741 --- /dev/null +++ b/general_ledger/ruleset/recursive_with_stack.py @@ -0,0 +1,42 @@ +import types + + +class StackNode: + pass + + +import types + + +class Visit: + def __init__(self, node): + self.node = node + + +class StackNodeVisitor: + def visit(self, node): + stack = [node] + last_result = None + while stack: + try: + last = stack[-1] + if isinstance(last, types.GeneratorType): + stack.append(last.send(last_result)) + last_result = None + elif isinstance(last, StackNode): + stack.append(self._visit(stack.pop())) + else: + last_result = stack.pop() + except StopIteration: + stack.pop() + return last_result + + def _visit(self, node): + methname = "visit_" + type(node).__name__ + meth = getattr(self, methname, None) + if meth is None: + meth = self.generic_visit + return meth(node) + + def generic_visit(self, node): + raise RuntimeError("No {} method".format("visit_" + type(node).__name__)) diff --git a/general_ledger/scripts/reset.sh b/general_ledger/scripts/reset.sh index aa4132e..5c53867 100644 --- a/general_ledger/scripts/reset.sh +++ b/general_ledger/scripts/reset.sh @@ -19,6 +19,11 @@ python manage.py reset_gl | while IFS= read -r line; do echo "DROP TABLE $line" | python manage.py dbshell done +python manage.py reset_db --noinput + +rm -rf general_ledger/migrations/*.py +touch general_ledger/migrations/__init__.py + # Make new migrations # python manage.py makemigrations $APP_NAME diff --git a/general_ledger/scripts/reset2.sh b/general_ledger/scripts/restore.sh similarity index 74% rename from general_ledger/scripts/reset2.sh rename to general_ledger/scripts/restore.sh index 7954d17..43b8fee 100644 --- a/general_ledger/scripts/reset2.sh +++ b/general_ledger/scripts/restore.sh @@ -4,10 +4,7 @@ set -eu -o pipefail export DJANGO_SETTINGS_MODULE=dashboard.settings -python manage.py reset_db --noinput -rm -rf general_ledger/migrations/*.py -touch general_ledger/migrations/__init__.py python manage.py makemigrations diff --git a/general_ledger/scripts/rich_nested_tables.py b/general_ledger/scripts/rich_nested_tables.py new file mode 100644 index 0000000..ade2412 --- /dev/null +++ b/general_ledger/scripts/rich_nested_tables.py @@ -0,0 +1,92 @@ +import os +import sys +from decimal import Decimal +from rich import box + +sys.path.insert(0, os.getcwd()) + +import datetime +from rich.table import Table +from rich.style import Style +from rich.console import Console +import random + +from general_ledger.render.renderables import TEntry + +minA = 240 +maxA = 255 +minB = 110 +maxB = 140 + +row_styles = [ + Style(bgcolor=f"rgb({r}, {g}, {b})") + for r, g, b in [ + ( + ( + random.randint(minB, maxB), + random.randint(minA, maxA), + random.randint(minA, maxA), + ) + if i == 0 + else ( + ( + random.randint(minA, maxA), + random.randint(minB, maxB), + random.randint(minA, maxA), + ) + if i == 1 + else ( + random.randint(minA, maxA), + random.randint(minA, maxA), + random.randint(minB, maxB), + ) + ) + ) + for i in [random.choice([0, 1, 2]) for _ in range(20)] + ] +] + + +deb1 = TEntry( + date=datetime.datetime.now(), + narrative="some narrative", + amount=Decimal("543.45"), +) + + +def main(): + console = Console() + + table = Table( + title="Debit col", + expand=True, + show_header=False, + box=box.MINIMAL_DOUBLE_HEAD, + ) + + table.add_column("date col", justify="left") + table.add_column("narrative col", justify="left") + table.add_column("amount", justify="right") + + table.add_row( + *deb1.as_cols(), + style=random.choice(row_styles), + ) + # table.add_row("2022-01-01", "Sales") + table.add_section() + table.add_section() + table.add_section() + + table.add_row( + "some date", + "narrative", + "djfgro", + style=random.choice(row_styles), + ) + + console.print(table) + # inspect(table) + + +if __name__ == "__main__": + main() diff --git a/general_ledger/scripts/yield_example.py b/general_ledger/scripts/yield_example.py new file mode 100644 index 0000000..7399988 --- /dev/null +++ b/general_ledger/scripts/yield_example.py @@ -0,0 +1,12 @@ +import random + + +def random_item(*args): + tmp = args + idx = random.randint(0, len(tmp) - 1) + item = tmp.pop(idx) + yield item + + +for _ in range(10): + print(random_item("a", "b", "c", "d", "e")) diff --git a/general_ledger/serializers/account.py b/general_ledger/serializers/account.py index d9a7ce0..8333df4 100644 --- a/general_ledger/serializers/account.py +++ b/general_ledger/serializers/account.py @@ -2,7 +2,7 @@ from django.contrib.auth.models import User from rest_framework import routers, serializers, viewsets -from general_ledger.models import Contact, Account +from general_ledger.django.models import Contact, Account # Serializers define the API representation. diff --git a/general_ledger/serializers/bank_account.py b/general_ledger/serializers/bank_account.py index b7835b8..3366881 100644 --- a/general_ledger/serializers/bank_account.py +++ b/general_ledger/serializers/bank_account.py @@ -3,11 +3,11 @@ from rest_framework import routers, serializers, viewsets from timezone_field.rest_framework import TimeZoneSerializerField -from general_ledger.models import Contact, Bank +from general_ledger.django.models import Contact, Bank class BankAccountSerializer(serializers.HyperlinkedModelSerializer): - #tz = TimeZoneSerializerField() + # tz = TimeZoneSerializerField() class Meta: model = Bank diff --git a/general_ledger/serializers/bank_balance.py b/general_ledger/serializers/bank_balance.py index 25d90dc..a5c030b 100644 --- a/general_ledger/serializers/bank_balance.py +++ b/general_ledger/serializers/bank_balance.py @@ -2,7 +2,7 @@ from django.contrib.auth.models import User from rest_framework import routers, serializers, viewsets -from general_ledger.models import BankStatementLine, BankBalance +from general_ledger.django.models import BankStatementLine, BankBalance class BankBalanceSerializer(serializers.HyperlinkedModelSerializer): diff --git a/general_ledger/serializers/book.py b/general_ledger/serializers/book.py index 09c28bd..b87cc54 100644 --- a/general_ledger/serializers/book.py +++ b/general_ledger/serializers/book.py @@ -2,7 +2,7 @@ from django.contrib.auth.models import User from rest_framework import routers, serializers, viewsets -from general_ledger.models import Contact, Account, Book +from general_ledger.django.models import Contact, Account, Book # Serializers define the API representation. diff --git a/general_ledger/serializers/contact.py b/general_ledger/serializers/contact.py index 7e87cbf..0ff13ad 100644 --- a/general_ledger/serializers/contact.py +++ b/general_ledger/serializers/contact.py @@ -2,7 +2,7 @@ from django.contrib.auth.models import User from rest_framework import routers, serializers, viewsets -from general_ledger.models import Contact +from general_ledger.django.models import Contact # Serializers define the API representation. diff --git a/general_ledger/serializers/invoice.py b/general_ledger/serializers/invoice.py index 9261cb6..f2a93e4 100644 --- a/general_ledger/serializers/invoice.py +++ b/general_ledger/serializers/invoice.py @@ -2,7 +2,7 @@ from django.contrib.auth.models import User from rest_framework import routers, serializers, viewsets -from general_ledger.models import Contact, Invoice +from general_ledger.django.models import Contact, Invoice from general_ledger.serializers.invoice_line import InvoiceLineSerializer from general_ledger.serializers.transaction import TransactionSerializer diff --git a/general_ledger/serializers/invoice_line.py b/general_ledger/serializers/invoice_line.py index 2506029..65a189d 100644 --- a/general_ledger/serializers/invoice_line.py +++ b/general_ledger/serializers/invoice_line.py @@ -1,6 +1,6 @@ from rest_framework import serializers -from general_ledger.models import InvoiceLine +from general_ledger.django.models import InvoiceLine class InvoiceLineSerializer(serializers.ModelSerializer): diff --git a/general_ledger/serializers/ledger.py b/general_ledger/serializers/ledger.py index cf3e223..72c6051 100644 --- a/general_ledger/serializers/ledger.py +++ b/general_ledger/serializers/ledger.py @@ -2,7 +2,7 @@ from django.contrib.auth.models import User from rest_framework import routers, serializers, viewsets -from general_ledger.models import Contact, Invoice, Ledger +from general_ledger.django.models import Contact, Invoice, Ledger # Serializers define the API representation. diff --git a/general_ledger/serializers/payment.py b/general_ledger/serializers/payment.py index 3e2f874..a6a9b0b 100644 --- a/general_ledger/serializers/payment.py +++ b/general_ledger/serializers/payment.py @@ -2,7 +2,7 @@ from django.contrib.auth.models import User from rest_framework import routers, serializers, viewsets -from general_ledger.models import Contact, Payment +from general_ledger.django.models import Contact, Payment # Serializers define the API representation. diff --git a/general_ledger/serializers/transaction.py b/general_ledger/serializers/transaction.py index c0dd9b5..001403c 100644 --- a/general_ledger/serializers/transaction.py +++ b/general_ledger/serializers/transaction.py @@ -2,7 +2,7 @@ from django.contrib.auth.models import User from rest_framework import routers, serializers, viewsets -from general_ledger.models import Transaction +from general_ledger.django.models import Transaction from general_ledger.serializers.transaction_entry import TransactionEntrySerializer diff --git a/general_ledger/serializers/transaction_entry.py b/general_ledger/serializers/transaction_entry.py index e8e5e2a..ff8c942 100644 --- a/general_ledger/serializers/transaction_entry.py +++ b/general_ledger/serializers/transaction_entry.py @@ -1,6 +1,6 @@ from rest_framework import serializers -from general_ledger.models import Entry +from general_ledger.django.models import Entry class TransactionEntrySerializer(serializers.ModelSerializer): diff --git a/general_ledger/signals.py b/general_ledger/signals.py index 9cda8f9..cfb1b76 100644 --- a/general_ledger/signals.py +++ b/general_ledger/signals.py @@ -5,14 +5,14 @@ from xstate_machine import pre_transition from general_ledger.helpers.invoice import InvoiceHelper -from general_ledger.models import ( +from general_ledger.django.models import ( Invoice, InvoiceLine, PaymentItem, Payment, Transaction, ) -from general_ledger.models.invoice_transaction import InvoiceTransaction +from general_ledger.django.models.invoice_transaction import InvoiceTransaction # @TODO replace this with xstate machine diff --git a/general_ledger/statements/__init__.py b/general_ledger/statements/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/general_ledger/statements/account_summary.py b/general_ledger/statements/account_summary.py new file mode 100644 index 0000000..5a7e57d --- /dev/null +++ b/general_ledger/statements/account_summary.py @@ -0,0 +1,266 @@ +from collections import defaultdict +from dataclasses import dataclass +from decimal import Decimal +from enum import Enum + +import rich.repr +from loguru import logger +from rich.console import Console, ConsoleOptions, RenderResult +from rich.table import Table + +from general_ledger.django.models import Entry +from general_ledger.managers.transaction_entry import EntryQuerySet +from general_ledger.render.mixins.renderable import RenderableMixin +from general_ledger.render.renderer_rich import RichConsoleRenderer +from general_ledger.statements.mixins import StartEndMixin + +""" +ths original idea here was to keep this class sufficiently general that it +could be used to summarize any set of entries, not just those from an account +and ledger. However, it is now clear that this makes things more complicated +than they need to be. The class is now used to summarize entries from an account +and probably can be fixed to work with general entries. This will be done in a +future refactor. +""" + + +# @rich.repr.auto +class AccountSummary( + RenderableMixin, + StartEndMixin, +): + """ + represents an account view with summaries calculated + """ + + def __init__( + self, + entries, + balance_interval=None, + renderer=None, + title=None, + caption=None, + currency=None, + final_balance=False, + group_intervals=None, + **kwargs, + ): + super().__init__(**kwargs) + self.entries = entries + self.balance_interval = balance_interval + self.entries_before: EntryQuerySet = Entry.objects.none() + self.entries_after: EntryQuerySet = Entry.objects.none() + self.entries_between: EntryQuerySet = Entry.objects.none() + self.entries_grouped: defaultdict[str, dict] = defaultdict(dict) + """ this is a dictionary of entries grouped by interval """ + self.renderer = renderer + self.title = title + self.caption = caption + self.currency = currency + self.group_intervals = group_intervals or ["year"] + """ group intervals is a list of keyword by which the entries should be grouped. by default it is ['year'] """ + self.final_balance: bool = final_balance + """ this is a boolean flag as to whether the trailing balance should be + appended to the suffix interval """ + self.debit_balance = Decimal("0.00") + self.credit_balance = Decimal("0.00") + self.debit_total = Decimal("0.00") + """ the total of the debits in the range of the start to end""" + self.credit_total = Decimal("0.00") + """ the total of the credits in the range of the start to end""" + + self.balanced = False + """ cache the status of the balance """ + + self._group_by_intervals() + self.balance_off() + + @property + def is_empty(self): + return self.entries is None + + def _group_by_intervals(self): + entries = self.entries + + entries_before = entries.before(self.start_date, strict=True) + entries_between = entries.between(self.start_date, self.end_date, strict=False) + entries_after = entries.after(self.end_date, strict=True) + entries_grouped = entries_between.get_grouped_entries(self.balance_interval) + entries_grouped["prefix"]["entries"] = entries_before + entries_grouped["suffix"]["entries"] = entries_after + + self.entries_grouped = entries_grouped + self.entries_before = entries_before + self.entries_after = entries_after + self.entries_between = entries_between + + def balance_off(self): + """ + balance the entries by working through the intervals, starting with the prefix + and carrying the balances forward to the suffix + """ + if not self.entries_grouped: + return self + + self.process_interval( + self.entries_grouped["prefix"], + interval_key="prefix", + ) + credit_cd = self.entries_grouped["prefix"]["credit_cd"] + debit_cd = self.entries_grouped["prefix"]["debit_cd"] + for interval_key in self.entries_grouped["meta"]["interval_keys"]: + interval = self.entries_grouped[interval_key] + self.process_interval( + interval, + debit_bd=credit_cd, + credit_bd=debit_cd, + interval_key=interval_key, + ) + credit_cd = interval["credit_cd"] + debit_cd = interval["debit_cd"] + self.process_interval( + self.entries_grouped["suffix"], + debit_bd=credit_cd, + credit_bd=debit_cd, + interval_key="suffix", + ) + suffix = self.entries_grouped["suffix"] + self.debit_balance = ( + suffix["debit_total"] if suffix["debit_total"] else Decimal("0") + ) + self.credit_balance = ( + suffix["credit_total"] if suffix["credit_total"] else Decimal("0") + ) + self.debit_total = self.entries_between.debit_total() + self.credit_total = self.entries_between.credit_total() + self.balanced = True + return self + + def process_interval( + self, + item, + debit_bd=None, + credit_bd=None, + interval_key=None, + ): + """ + process the entries for a given interval + """ + logger.trace(f"processing interval: '{interval_key}'") + # inspect(entries.debit_total()) + # inspect(entries.credit_total()) + entries = item["entries"] + # inspect(entries) + entries.set_balances_bd( + debit_bd, + credit_bd, + ) + # inspect(entries) + item["status"] = self.get_status(entries) + # inspect(entries) + # inspect(item) + item["debit_bd"] = debit_bd + item["credit_bd"] = credit_bd + item["balance_interval"] = self.balance_interval + item["group_intervals"] = self.group_intervals + if item["status"] == self.Status.EMPTY: + item["debit_total"] = Decimal("0.00") + item["credit_total"] = Decimal("0.00") + item["total"] = Decimal("0.00") + item["debit_cd"] = None + item["credit_cd"] = None + elif item["status"] in [self.Status.CLOSE, self.Status.ONELINE_CLOSE]: + item["debit_total"] = entries.debit_total() + item["credit_total"] = entries.credit_total() + item["total"] = entries.debit_total() + item["debit_cd"] = None + item["credit_cd"] = None + elif item["status"] == self.Status.CREDIT_BALANCE: + item["debit_total"] = entries.debit_total() + item["credit_total"] = entries.credit_total() + item["total"] = entries.credit_total() + item["debit_cd"] = entries.credit_balance() + item["credit_cd"] = None + elif item["status"] == self.Status.DEBIT_BALANCE: + item["debit_total"] = entries.debit_total() + item["credit_total"] = entries.credit_total() + item["total"] = entries.debit_total() + item["debit_cd"] = None + item["credit_cd"] = entries.debit_balance() + else: + raise ValueError("unknown status") + + def get_status(self, entry_set): + if entry_set.is_empty(): + return self.Status.EMPTY + if entry_set.is_balanced(): + if ( + (entry_set.debits().count() <= 1 and entry_set.debit_bd == 0) + or (entry_set.debits().count() == 0 and entry_set.debit_bd) + ) and ( + (entry_set.credits().count() <= 1 and entry_set.credit_bd == 0) + or (entry_set.credits().count() == 0 and entry_set.credit_bd) + ): + return self.Status.ONELINE_CLOSE + return self.Status.CLOSE + if entry_set.is_credit_balance(): + return self.Status.CREDIT_BALANCE + return self.Status.DEBIT_BALANCE + + def __rich_repr__(self) -> rich.repr.Result: + yield self.title + # yield "caption", self.caption + yield "currency", self.currency, "GBP" + yield "group_intervals", self.group_intervals, ["year"] + yield "interval_keys", ( + self.entries_grouped["meta"]["interval_keys"] + if self.entries_grouped + else "not-set" + ) + yield "intervals", self.entries_grouped + + def __rich_console__( + self, console: Console, options: ConsoleOptions + ) -> RenderResult: + yield f"[b]Account Summary:[/b] #{self.title}" + my_table = Table("Attribute", "Value") + my_table.add_row("name", self.currency) + my_table.add_row("age", str(self.entries_grouped["meta"]["interval_keys"])) + yield my_table + + def render(self): + """custom rendering strategy for account summary""" + if not self.renderer: + self.renderer = RichConsoleRenderer() + return super().render() + + class Status(Enum): + """ + status of the entry_set in regard to what is required + to balance it. This is used as a clue to determine how to render + the entries in the console. + """ + + OPEN = 0 + EMPTY = 1 + """ + This interval has no entries, including any balance b/d + """ + CLOSE = 2 + """ + This interval is already balanced, needs to be closed off + with totals + """ + CREDIT_BALANCE = 3 + """ + This interval is not balanced on credit side, needs to be balanced + and totals added + """ + DEBIT_BALANCE = 4 + """ + This interval is not balanced on debit side, needs to be balanced and totals added + """ + ONELINE_CLOSE = 5 + """ + This interval has a single debit and a single credit, and is balanced. Indicates a one line close, i.e. no totals just underlines + """ diff --git a/general_ledger/statements/account_summary_set.py b/general_ledger/statements/account_summary_set.py new file mode 100644 index 0000000..23cc3f8 --- /dev/null +++ b/general_ledger/statements/account_summary_set.py @@ -0,0 +1,81 @@ +from enum import Enum +from dataclasses import dataclass +from enum import Enum +from typing import List + +from loguru import logger + +from general_ledger.builders.account_summary_builder import AccountSummary +from general_ledger.django.models.ledger import Ledger +from general_ledger.render.renderer_rich import RichConsoleRenderer +from general_ledger.render.utility_rich import lists_to_grid_cols +from general_ledger.statements.mixins import StartEndMixin +from general_ledger.render.mixins.renderable import RenderableMixin + + +@dataclass +class AccountSetSummary( + RenderableMixin, + StartEndMixin, +): + """ + this is the one that has all the stuff + """ + + ledger: Ledger = None + summary_set: List[AccountSummary] = None + caption = None + title = None + currency = None + renderer = None + + def __init__(self, **kwargs): + for key, value in kwargs.copy().items(): + if hasattr(self, key): + print(f"setting {key} to {value} in AccountSEtSummary") + setattr(self, key, kwargs.pop(key)) + super().__init__(**kwargs) + + def __str__(self): + return f"{self.caption} {self.title} count({len(self.summary_set)})" + + def __repr__(self): + return f"" + + def render(self): + """custom rendering strategy for account set summaries""" + if not self.renderer: + self.renderer = RichConsoleRenderer() + if not self.renderer.table_format: + raise ValueError("table_format not set") + if type(self.renderer.table_format).__name__ in [ + "TAccountFormat", + "ThreeColumnFormat", + ]: + return lists_to_grid_cols( + [ + self.renderer.render(account_summary) + for account_summary in self.summary_set + ] + ) + elif type(self.renderer.table_format).__name__ == "TrialBalance": + return super().render() + else: + raise ValueError( + f"Unsupported table format {self.renderer.table_format} for {self}" + ) + + @property + def trial_debit_balance(self): + return sum([x.debit_balance for x in self.summary_set]) + + @property + def trial_credit_balance(self): + return sum([x.credit_balance for x in self.summary_set]) + + class Status(Enum): + """ + status of the accounts set + """ + + UNKNOWN = 0 diff --git a/general_ledger/statements/closing_off_logic_attempt.py b/general_ledger/statements/closing_off_logic_attempt.py new file mode 100644 index 0000000..54a57f2 --- /dev/null +++ b/general_ledger/statements/closing_off_logic_attempt.py @@ -0,0 +1,382 @@ +from decimal import Decimal +from typing import Optional + +from general_ledger.builders import TransactionBuilder +from general_ledger.helpers.ledger_helper import LedgerHelper +from general_ledger.django.models import Direction, Ledger + + +class TradingAccountHelper: + def __init__( + self, + trading_account, + **kwargs, + ): + self.trading_account = trading_account + self.ledger = trading_account.ledger + self.account_set = self.ledger.coa.account_set + self.start_date = trading_account.start_date + self.end_date = trading_account.end_date + self.inventory_opening = self.trading_account.inventory_opening + self.inventory_closing = self.trading_account.inventory_closing + + self.trading_account = self.get_trading_account() + self.profit_and_loss = self.get_profit_and_loss_account() + self.drawings = self.ledger.coa.get_or_create("Drawings", "equity") + self.capital = self.ledger.coa.get_or_create("Capital", "equity") + self.close_off_sales() + self.close_off_purchases() + self.close_off_inventory() + self.close_off_trading() + self.close_off_expenses() + self.close_off_profit_and_loss() + self.close_off_drawings() + + def calculate_stuff(self): + # do some stuff + return 1 + + def get_trading_account(self): + trading = self.ledger.coa.get_or_create("Trading", "equity") + return trading + + def get_profit_and_loss_account(self): + trading = self.ledger.coa.get_or_create("Profit and Loss", "equity") + return trading + + def close_off_sales(self): + LedgerHelper.close_out_by_type_slug( + self.ledger, "sales", self.trading_account, self.end_date + ) + + def close_off_purchases(self): + LedgerHelper.close_out_by_type_slug( + self.ledger, "purchases", self.trading_account, self.end_date + ) + + def close_off_inventory(self): + entries = [] + entries.append( + ( + self.ledger.coa.get_or_create("Inventory"), + self.inventory_closing, + Direction.DEBIT, + ) + ) + entries.append( + ( + self.trading_account, + self.inventory_closing, + Direction.CREDIT, + ), + ) + LedgerHelper.post_transaction( + self.ledger, "close out inventory", self.end_date, entries + ) + + def close_off_trading(self): + entries = [] + amount = self.ledger.balance_for_account(self.trading_account, self.end_date) + entries.extend( + ( + ( + self.trading_account, + amount, + Direction.DEBIT, + ), + ( + self.profit_and_loss, + amount, + Direction.CREDIT, + ), + ) + ) + LedgerHelper.post_transaction( + self.ledger, "close out trading", self.end_date, entries + ) + + def close_off_expenses(self): + LedgerHelper.close_out_by_type_slug( + self.ledger, "overhead", self.profit_and_loss, self.end_date + ) + + # @TODO drawings are a contra equity account, so need to have + # some handling for contra accounts + def close_off_drawings(self): + + entries = [] + amount = -self.ledger.balance_for_account(self.drawings, self.end_date) + entries.extend( + ( + ( + self.capital, + amount, + Direction.DEBIT, + ), + ( + self.drawings, + amount, + Direction.CREDIT, + ), + ) + ) + LedgerHelper.post_transaction( + self.ledger, "close out trading", self.end_date, entries + ) + + def close_off_profit_and_loss(self): + entries = [] + amount = self.ledger.balance_for_account(self.profit_and_loss, self.end_date) + entries.extend( + ( + ( + self.profit_and_loss, + amount, + Direction.DEBIT, + ), + ( + self.capital, + amount, + Direction.CREDIT, + ), + ) + ) + LedgerHelper.post_transaction( + self.ledger, "close out trading", self.end_date, entries + ) + + def calculate_cogs(self): + # calculate COGS + self.inventory_opening = self.get_opening_inventory() + self.inventory_closing = self.get_closing_inventory() + self.purchases_closing = self.get_purchases() + self.cost_of_goods_sold = self.get_cost_of_goods_sold() + + def calculate_gross_profit(self): + # calculate gross profit + self.sales = self.get_sales() + self.gross_profit = self.get_gross_profit() + + # create the trading account + self.update_trading_account() + self.trading_balance = self.ledger.balance_by_slug( + "trading", + balance_date=self.end_date, + ) + self.profit_and_loss = self.ledger.balance_by_slug( + "profit-and-loss", + balance_date=self.end_date, + ) + + def get_opening_inventory(self): + # inventory_balance = self.ledger.inventory_balance() + inventory_balance = self.ledger.balance_by_type_slug( + "inventory", + balance_date=self.start_date, + balance_at_close=False, + ) + return inventory_balance + + def get_closing_inventory(self): + # inventory_balance = self.ledger.inventory_balance() + inventory_balance = self.ledger.balance_by_type_slug( + "inventory", + balance_date=self.end_date, + ) + return inventory_balance + + def get_purchases(self): + purchases_balance = self.ledger.balance_by_type_slug( + "direct-costs", + balance_date=self.end_date, + ) + return purchases_balance + + def get_cost_of_goods_sold(self): + cost_of_goods_sold = ( + self.inventory_opening + self.purchases_closing - self.inventory_closing + ) + return cost_of_goods_sold + + def get_sales(self): + sales_balance = self.ledger.balance_by_type_slug( + "sales", + balance_date=self.end_date, + ) + return sales_balance + + def get_gross_profit(self): + gross_profit = self.sales - self.cost_of_goods_sold + return gross_profit + + def get_total_revenue(self): + pass + + # @TODO this would need to close down all of the accounts + # of the relevant type in the ledger + def update_trading_account(self): + sales = self.ledger.coa.account_set.get( + name="Sales", + type__slug="sales", + ) + + purchases = self.ledger.coa.account_set.get( + name="Purchases", + type__slug="direct-costs", + ) + inventory = self.ledger.coa.account_set.get( + name="Inventory", + type__slug="inventory", + ) + closing_inventory = self.ledger.coa.account_set.get( + name="Closing Inventory", + type__slug="current-asset", + ) + trading = self.ledger.coa.account_set.get( + name="Trading", + type__slug="equity", + ) + profit_and_loss = self.ledger.coa.account_set.get( + name="Profit and Loss", + type__slug="equity", + ) + + lighting = self.ledger.coa.account_set.get( + name="Lighting Expenses", + type__slug="overhead", + ) + rent = self.ledger.coa.account_set.get( + name="Rent", + type__slug="overhead", + ) + general_expenses = self.ledger.coa.account_set.get( + name="General Expenses", + type__slug="overhead", + ) + + drawings = self.ledger.coa.account_set.get( + name="Drawings", + type__slug="equity", + ) + + capital = self.ledger.coa.account_set.get( + name="Capital", + type__slug="equity", + ) + tb = TransactionBuilder( + ledger=self.ledger, + description="Closing Balance", + ) + tb.set_trans_date("2012-12-31") + tb.add_entry(sales, self.sales, Direction.DEBIT) + tb.add_entry(trading, self.sales, Direction.CREDIT) + tb.build().post() + + tb = TransactionBuilder( + ledger=self.ledger, + description="Closing Balance", + ) + tb.set_trans_date("2012-12-31") + tb.add_entry(closing_inventory, self.inventory_closing, Direction.DEBIT) + tb.add_entry(trading, self.inventory_closing, Direction.CREDIT) + tb.build().post() + + tb = TransactionBuilder( + ledger=self.ledger, + description="Closing Balance", + ) + tb.set_trans_date("2012-12-31") + tb.add_entry(purchases, self.purchases_closing, Direction.CREDIT) + tb.add_entry(trading, self.purchases_closing, Direction.DEBIT) + tb.build().post() + + tb = TransactionBuilder( + ledger=self.ledger, + description="Closing Balance", + ) + tb.set_trans_date("2012-12-31") + rent_balance = self.ledger.balance_by_slug( + "rent", + balance_date=self.end_date, + ) + tb.add_entry( + rent, + rent_balance, + Direction.CREDIT, + ) + tb.add_entry(profit_and_loss, rent_balance, Direction.DEBIT) + tb.build().post() + + tb = TransactionBuilder( + ledger=self.ledger, + description="Closing Balance", + ) + tb.set_trans_date("2012-12-31") + lighting_balance = self.ledger.balance_by_slug( + "lighting-expenses", + balance_date=self.end_date, + ) + tb.add_entry( + lighting, + lighting_balance, + Direction.CREDIT, + ) + tb.add_entry(profit_and_loss, lighting_balance, Direction.DEBIT) + tb.build().post() + + tb = TransactionBuilder( + ledger=self.ledger, + description="Closing Balance", + ) + tb.set_trans_date("2012-12-31") + general_expenses_balance = self.ledger.balance_by_slug( + "general-expenses", + balance_date=self.end_date, + ) + tb.add_entry( + general_expenses, + general_expenses_balance, + Direction.CREDIT, + ) + tb.add_entry(profit_and_loss, general_expenses_balance, Direction.DEBIT) + tb.build().post() + + tb = TransactionBuilder( + ledger=self.ledger, + description="Closing Balance", + ) + tb.set_trans_date("2012-12-31") + tb.add_entry(profit_and_loss, self.gross_profit, Direction.CREDIT) + tb.add_entry(trading, self.gross_profit, Direction.DEBIT) + tb.build().post() + + tb = TransactionBuilder( + ledger=self.ledger, + description="Update Capital", + ) + tb.set_trans_date("2012-12-31") + drawings_balance = ( + self.ledger.balance_by_slug( + "drawings", + balance_date=self.end_date, + ) + * -1 + ) + + print(f"drawings_balance: {drawings_balance}") + tb.add_entry(capital, drawings_balance, Direction.DEBIT) + tb.add_entry(drawings, drawings_balance, Direction.CREDIT) + tb.build().post() + + tb = TransactionBuilder( + ledger=self.ledger, + description="Update Capital", + ) + tb.set_trans_date("2012-12-31") + net_profit_balance = self.ledger.balance_by_slug( + "profit-and-loss", + balance_date=self.end_date, + ) + tb.add_entry(capital, net_profit_balance, Direction.CREDIT) + tb.add_entry(profit_and_loss, net_profit_balance, Direction.DEBIT) + tb.build().post() diff --git a/general_ledger/statements/core_domain.py b/general_ledger/statements/core_domain.py new file mode 100644 index 0000000..c96d6c7 --- /dev/null +++ b/general_ledger/statements/core_domain.py @@ -0,0 +1,41 @@ +from abc import ABC, abstractmethod +from dataclasses import dataclass +from datetime import date +from decimal import Decimal +from typing import Protocol, List, Dict, Optional, Iterator +from enum import Enum + + +# Core Domain Models +@dataclass(frozen=True) +class AccountBalance: + """Immutable balance for an account at a point in time""" + + account_id: str + balance: Decimal + as_of_date: date + + +@dataclass(frozen=True) +class TransactionTotal: + """Immutable total for a set of transactions""" + + account_id: str + total: Decimal + start_date: date + end_date: date + + +@dataclass(frozen=True) +class AccountInfo: + """Immutable account information""" + + id: str + name: str + account_type: str + category: str + + +class AccountingMethod(Enum): + PERIODIC = "periodic" + PERPETUAL = "perpetual" diff --git a/general_ledger/statements/financial_statement.py b/general_ledger/statements/financial_statement.py new file mode 100644 index 0000000..a6b8a5d --- /dev/null +++ b/general_ledger/statements/financial_statement.py @@ -0,0 +1,228 @@ +from collections import defaultdict +from dataclasses import dataclass, field +from decimal import Decimal +from enum import Enum +from typing import Dict, Any +from typing import List + +from loguru import logger +from rich.console import Console +from rich.style import Style +from rich.table import Table, Column +from rich.text import Text + +from general_ledger.render.mixins.renderable import RenderableMixin +from general_ledger.statements.meta import Operation +from general_ledger.utils.utility import string_to_color +from general_ledger.render.utility_rich import fmt + +console = Console() + + +@dataclass +class FinancialStatement(RenderableMixin): + """this is just a big tubular dataclass that holds all the data for a financial statement + each dimension of the statement is a list of dicts, where each dict is a node in the statement + """ + + title: str + """The title of the statement""" + + data: List[Dict[str, Any]] = field(default_factory=list) + counts: defaultdict[int, int] = field(default_factory=lambda: defaultdict(int)) + used_columns: defaultdict[int, list] = field( + default_factory=lambda: defaultdict(list) + ) + show_op: bool = False + show_debug: bool = False + show_type: bool = True + + class Type(Enum): + NORMAL = "normal" + TOTAL = "total" + HEADER = "header" + LEAF = "leaf" + SUBTOTAL = "subtotal" + """a subtotal is the sum if its children""" + RUNNING = "running" + """running is subtotal plus prev sibling totals""" + BLANK = "blank" + EMPTY = "empty" + FOOTER = "footer" + SECTION = "section" + SUBSECTION = "subsection" + + def add_row( + self, + label, + row: List[Any], + indent: int = 0, + node_type: Type = "normal", + extra=None, + node=None, + ): + if extra is None: + extra = [] + """Add a row to the statement""" + if node_type not in [FinancialStatement.Type.FOOTER]: + self.data.append( + { + "label": label, + "cols": row, + "indent": indent, + "node_type": node_type, + "extra": extra, + "node": node, + } + ) + return self + + # @logger_wraps() + def add_value( + self, + label: str, + value: Decimal | str, + col_idx: int = 0, + indent: int = 0, + node_type: Type = Type.LEAF, + extra=None, + node=None, + ): + + row: List[Any] = [""] * (col_idx + 1) + row[col_idx] = value + + # if we are a running, and thw row two above it empty, move it + # up to the row above it + # if node_type == self.Type.SUBTOTAL: + # if ( + # self.data[-2]["cols"][col_idx] == "" + # and self.data[-1]["cols"][col_idx] == "" + # and self.data[-1]["node_type"] == self.Type.RUNNING + # ): + # # print(f"moving running: {label} {value} to col: {col_idx} {node_type}") + # self.data[-2]["cols"][col_idx] = value + # return self + # + # if node_type == self.Type.RUNNING: + # if ( + # self.data[-1]["cols"][col_idx] == "" + # and self.data[-1]["node_type"] == self.Type.RUNNING + # ): + # # print( + # # f"candiate running: {label} {value} to col: {col_idx} {node_type}" + # # ) + # self.data[-1]["cols"][col_idx] = value + # return self + + self.add_row( + label, + row, + indent=indent, + node_type=node_type, + extra=extra, + node=node, + ) + if node_type not in [FinancialStatement.Type.HEADER]: + # print(f"add value: {label} {value} to col: {col_idx} {node_type}") + self.counts[col_idx] += 1 + # self.used_columns[col_idx] = row + return self + + def render(self): + + if not self.data: + logger.error("No data to render {}", self.__class__.__name__) + return + + max_cols = max(len(item["cols"]) for item in self.data) + + table = Table( + expand=True, + ) + + if self.show_op: + table.add_column(header="op", vertical="bottom", justify="right") + + table.add_column( + "label", + vertical="bottom", + justify="left", + ) + + if self.show_type: + table.add_column( + "type", + vertical="bottom", + justify="right", + ) + + # inspect(self.data) + # print(f"max_cols: {max_cols}") + + for i in range(max_cols - 1, -1, -1): + table.add_column(vertical="bottom", header=str(i), justify="right") + + if self.show_debug: + table.add_column(vertical="bottom", header="extra", justify="center") + + for item in self.data: + extra = item["extra"] if type((item["extra"])) is list else [item["extra"]] + item["cols"] = [ + *item["cols"], + *["" for i in range(len(item["cols"]), max_cols)], + ] + item["cols"] = list(reversed(item["cols"])) + row = [] + if self.show_op: + row.append(item["node"].meta.operation.value) + row.append(self._fmt_label(item)) + + if self.show_type: + row.append(item["node_type"].value) + row.extend([self._fmt(item, col) for col in item["cols"]]) + if self.show_debug: + row.extend([fmt(x) for x in extra]) + table.add_row(*row) + return table + + def __repr__(self): + return f"<{self.__class__.__name__}(title='{self.title}', ({len(self.data)} rows) used_columns: {self.used_columns})>" + + def _fmt_label(self, item): + bgstyle = string_to_color(item["node"].label) + indent = " " * item["indent"] + + style = Style.parse("black") + bgstyle # Default style + + if item["node"].meta.operation == Operation.NONE: + label = f"{indent}{item['label']}" + elif item["node"].meta.operation == Operation.ADD: + label = f"{indent}{item['node'].meta.operation.value}{item['label']}" + elif item["node"].meta.operation == Operation.LESS: + label = f"[italic]LESS[/italic] {item['label']}" + else: + raise ValueError(f"Unknown operation: {item['node'].meta}") + + if item["node_type"] == self.Type.TOTAL: + style = Style.parse("bold black") + bgstyle + label = label + "\n" + elif item["node_type"] == self.Type.SUBTOTAL: + label = "" + label + "" + elif item["node_type"] in (self.Type.HEADER, self.Type.FOOTER): + style = Style.parse("dim") + bgstyle + # No special formatting for LEAF, so it uses the default + + return Text.from_markup(label, style=style) + + def _fmt(self, item, value): + if item["node_type"] == self.Type.TOTAL and value: + return fmt(value) + Text("\n======", style="bold") + elif item["node_type"] == self.Type.SUBTOTAL and value: + return Text("", style="green") + fmt(value) + return fmt(value) + + def __rich_repr__(self): + yield "Title", self.title + yield "Rows", len(self.data) + yield "Data", self.data diff --git a/general_ledger/statements/financial_statement_2.py b/general_ledger/statements/financial_statement_2.py new file mode 100644 index 0000000..3dd2454 --- /dev/null +++ b/general_ledger/statements/financial_statement_2.py @@ -0,0 +1,118 @@ +from dataclasses import dataclass, field +from enum import Enum, auto +from typing import Dict, List, Optional, Tuple, Any, Union +from decimal import Decimal + + +class DimensionType(Enum): + DIVISION = auto() + CATEGORY = auto() + YEAR = auto() + QUARTER = auto() + MONTH = auto() + REGION = auto() + PRODUCT = auto() + + +@dataclass +class DimensionValue: + """A value in a dimension hierarchy""" + + type: DimensionType + code: str + label: str + parent: Optional["DimensionValue"] = None + children: List["DimensionValue"] = field(default_factory=list) + + def __hash__(self): + return hash((self.type, self.code)) + + def get_path(self) -> Tuple[str, ...]: + """Get full path from root to this value""" + if self.parent: + return self.parent.get_path() + (self.code,) + return (self.code,) + + +@dataclass +class Column: + """Column or column group""" + + dimension: DimensionValue + children: List["Column"] = field(default_factory=list) + + @property + def is_leaf(self) -> bool: + """True if this column has no subcolumns""" + return len(self.children) == 0 + + def get_paths(self) -> List[Tuple]: + """Get all possible dimension paths""" + if not self.children: + return [self.dimension.get_path()] + + paths = [] + for subcol in self.children: + for subpath in subcol.get_paths(): + paths.append(self.dimension.get_path() + subpath) + return paths + + +@dataclass +class Row: + """Row with multi-dimensional values""" + + label: str + code: str + indent_level: int = 0 + is_total: bool = False + values: Dict[Tuple, Decimal] = field( + default_factory=dict + ) # (division, year) -> value + + def get_value(self, *dimensions: Union[str, Tuple[str, ...]]) -> Optional[Decimal]: + """Get value for dimension path, supporting partial matches""" + if isinstance(dimensions[0], tuple): + dimensions = dimensions[0] + return self.values.get(tuple(dimensions)) + + def get_values_for_dimension(self, dim_type: DimensionType) -> Dict[str, Decimal]: + """Get all values for a specific dimension type""" + results = {} + for dims, value in self.values.items(): + for dim in dims: + if dim.startswith(dim_type.name.lower()): + results[dim] = value + return results + + def set_value(self, value: Decimal, *dimensions): + """Set value for specific dimensions""" + self.values[tuple(dimensions)] = value + + +@dataclass +class Section: + """Group of related rows""" + + title: str + rows: List[Row] + + +@dataclass +class FinancialStatement: + """Financial statement with dimensional values""" + + title: str + columns: List[Column] + sections: List["Section"] + dimension_types: List[DimensionType] # Order of dimensionss + + def get_dimension_values(self, dim_type: DimensionType) -> List[str]: + """Get all values for a dimension type""" + values = set() + for col in self.columns: + for path in col.get_paths(): + dim_index = self.dimension_types.index(dim_type) + if len(path) > dim_index: + values.add(path[dim_index]) + return sorted(list(values)) diff --git a/general_ledger/statements/in_memory.py b/general_ledger/statements/in_memory.py new file mode 100644 index 0000000..2bae987 --- /dev/null +++ b/general_ledger/statements/in_memory.py @@ -0,0 +1,260 @@ +from dataclasses import dataclass, field +from datetime import date +from decimal import Decimal +from typing import List, Dict, Optional +from typing import Union + +from loguru import logger +from rich.console import Console, ConsoleOptions, RenderResult +from rich.table import Table + +import rich.repr +from general_ledger.statements.core_domain import ( + AccountInfo, + AccountBalance, + TransactionTotal, +) +from general_ledger.statements.providers import ( + BaseDataProvider, +) + + +# Example In-Memory Provider for Testing +@dataclass +class InMemoryAccount: + id: str + name: str + account_type: str + category: str + balances: Dict[date, Decimal] = field(default_factory=dict) + transactions: List[tuple[date, Decimal]] = field(default_factory=list) + + def __rich_repr__(self) -> rich.repr.Result: + yield None, self.name + yield "id", self.id + yield "account_type", self.account_type + + +class InMemoryProvider(BaseDataProvider): + """Simple in-memory implementation for testing""" + + def __init__( + self, + account_provider=None, + balance_provider=None, + transaction_provider=None, + ): + self.accounts: Dict[str, InMemoryAccount] = {} + super().__init__( + account_provider=account_provider if account_provider else self, + balance_provider=(balance_provider if balance_provider else self), + transaction_provider=( + transaction_provider if transaction_provider else self + ), + ) # Self implements all interfaces + + def add_sales(self, id: str) -> "InMemoryAccountBuilder": + """Quick sales account creation""" + builder = InMemoryAccountBuilder(id).sales() + self.accounts[id] = builder.account + return builder + + def add_account( + self, + account: InMemoryAccount, + ): + self.accounts[account.id] = account + + # AccountProvider implementation + def get_account(self, account_id: str) -> Optional[AccountInfo]: + if account := self.accounts.get(account_id): + return AccountInfo( + id=account.id, + name=account.name, + account_type=account.account_type, + category=account.category, + ) + return None + + def get_accounts_by_type(self, account_type: str) -> List[AccountInfo]: + return [ + AccountInfo( + id=str(account.id), + name=account.name, + account_type=account.account_type, + category=account.category, + ) + for account_id, account in self.accounts.items() + if account.account_type == account_type + ] + + def get_balance( + self, account_id: str, as_of_date: date, at_close: bool = True + ) -> AccountBalance: + account = self.accounts.get(account_id) + if not account: + return AccountBalance(account_id, Decimal("0.00"), as_of_date) + + balance = account.balances.get(as_of_date, Decimal("0.00")) + return AccountBalance(account_id, balance, as_of_date) + + def get_balances( + self, account_ids: list[str], as_of_date: date, at_close: bool = True + ) -> list[AccountBalance]: + return [ + self.get_balance(acc_id, as_of_date, at_close) for acc_id in account_ids + ] + + def get_transaction_total( + self, account_id: str, start_date: date, end_date: date + ) -> TransactionTotal: + account = self.accounts.get(account_id) + if not account: + logger.warning(f"got no account for id {account_id}") + return TransactionTotal(account_id, Decimal("0.00"), start_date, end_date) + + total = sum( + amount + for date_, amount in account.transactions + if start_date <= date_ <= end_date + ) + # logger.warning(f"returning total {total} for {account_id}") + return TransactionTotal(account_id, total, start_date, end_date) + + def get_transaction_totals( + self, + account_ids: List[str], + start_date: date, + end_date: date, + ) -> List[TransactionTotal]: + return [ + self.get_transaction_total(account_id, start_date, end_date) + for account_id in account_ids + ] + + def quick_account(self, spec: str) -> None: + """Parse account spec string: + "id:type:category|YYYYMMDD:amount|tx:YYYYMMDD:amount" + """ + parts = spec.split("|") + id, type_, category = parts[0].split(":") + + account = InMemoryAccount( + id=id, + name=f"{id}", + account_type=type_, + category=category, + balances={}, + transactions=[], + ) + + for part in parts[1:]: + if part.startswith("tx:"): + _, date_str, amount = part.split(":") + date_str = date_str.replace("-", "") + date_ = dt(int(date_str[:4]), int(date_str[4:6]), int(date_str[6:])) + account.transactions.append((date_, d(amount))) + else: + date_str, amount = part.split(":") + date_str = date_str.replace("-", "") + date_ = dt(int(date_str[:4]), int(date_str[4:6]), int(date_str[6:])) + account.balances[date_] = d(amount) + + self.accounts[id] = account + + def __rich_repr__(self) -> rich.repr.Result: + yield None, self.__class__.__name__ + yield "accounts", self.accounts, "xx" + + +class InMemoryProviderBuilder: + def __init__(self): + self.provider = InMemoryProvider() + + def with_sales_account( + self, + id: str, + opening: Decimal, + closing: Decimal, + transactions: List[tuple[date, Decimal]] = None, + ) -> "InMemoryProviderBuilder": + self.provider.add_account( + InMemoryAccount( + id=id, + name=f"Sales Account {id}", + account_type="sales", + category="revenue", + balances={self.start_date: opening, self.end_date: closing}, + transactions=transactions or [], + ) + ) + return self + + def with_date_range( + self, start_date: date, end_date: date + ) -> "InMemoryProviderBuilder": + self.start_date = start_date + self.end_date = end_date + return self + + def build(self) -> InMemoryProvider: + return self.provider + + +def d(value: Union[str, int, float]) -> Decimal: + """Quick Decimal creator""" + return Decimal(str(value)) + + +def dt(year: int, month: int, day: int) -> date: + """Quick date creator""" + return date(year, month, day) + + +class InMemoryAccountBuilder: + """Fluent builder for test accounts""" + + def __init__(self, account_id: str): + self.account = InMemoryAccount( + id=account_id, + name=f"{account_id} Account", + account_type="", + category="", + balances={}, + transactions=[], + ) + + def sales(self) -> "InMemoryAccountBuilder": + """Quick setup for sales account""" + self.account.account_type = "sales" + self.account.category = "revenue" + return self + + def with_type(self, account_type: str) -> "InMemoryAccountBuilder": + self.account.account_type = account_type + return self + + def with_category(self, category: str) -> "InMemoryAccountBuilder": + self.account.category = category + return self + + def with_name(self, name: str) -> "InMemoryAccountBuilder": + self.account.name = name + return self + + def bal( + self, year: int, month: int, day: int, amount: Union[str, int, float] + ) -> "InMemoryAccountBuilder": + """Add balance at date""" + self.account.balances[dt(year, month, day)] = d(amount) + return self + + def tx( + self, year: int, month: int, day: int, amount: Union[str, int, float] + ) -> "InMemoryAccountBuilder": + """Add transaction at date""" + self.account.transactions.append((dt(year, month, day), d(amount))) + return self + + def build(self) -> InMemoryAccount: + return self.account diff --git a/general_ledger/statements/ledger_account.py b/general_ledger/statements/ledger_account.py new file mode 100644 index 0000000..53678a4 --- /dev/null +++ b/general_ledger/statements/ledger_account.py @@ -0,0 +1,56 @@ +from dataclasses import dataclass, field +from django.db import models +from general_ledger.managers.transaction_entry import EntryQuerySet +from general_ledger.django.models import Direction, Entry +from general_ledger.django.models.ledger import Ledger +from general_ledger.django.models.account import Account +from general_ledger.utils.inspect import inspect + + +class LedgerAccount(models.Manager): + """ + instance of an account on a specific ledger + """ + + has_summary: bool = False + _entry_set: EntryQuerySet = None + # = field(default_factory=lambda: str(uuid.uuid4())) + + def get_queryset(self): + qs = Entry.objects.filter( + transaction__ledger=self.ledger, + account=self.account, + ) + return qs + + def __init__(self, ledger, account): + self.ledger: Ledger = ledger + self.account: Account = account + super().__init__() + + @property + def entry_set(self): + if self._entry_set is None: + self._entry_set = self.account.entry_set.filter( + transaction__ledger=self.ledger + ) + return self._entry_set + + @property + def account_name(self): + return self.account.name + + @property + def code(self): + return self.account.code + + @property + def currency(self): + return self.account.currency + + @property + def balance(self): + if self.account.direction == Direction.DEBIT: + return self.entry_set.debit_balance() + else: + return self.entry_set.credit_balance() diff --git a/general_ledger/statements/meta.py b/general_ledger/statements/meta.py new file mode 100644 index 0000000..e158436 --- /dev/null +++ b/general_ledger/statements/meta.py @@ -0,0 +1,57 @@ +from dataclasses import dataclass +from enum import Enum +from typing import Optional, Protocol + + +class DetailLevel(Enum): + VALUE = "value" + """node returns its own value only""" + VALUES = "node values" + """node returns the value of its nodes and its own total""" + SUMMARY = "summary" + """node returns its own value and totals from its children""" + DETAILED = "detailed" + FULL = "full" + CHILD = "child" + """examine child for expand value""" + EXPAND = "expand" + + +class Operation(Enum): + ADD = "+" + LESS = "-" + NONE = "" # For nodes that are just containers/groupings + + +class NodeOperation(Protocol): + @staticmethod + def calculate(node): ... + + +class AddOperation(NodeOperation): + @staticmethod + def calculate(node): + return node.value + + +class LessOperation(NodeOperation): + @staticmethod + def calculate(node): + return -node.value + + +class NoneOperation(NodeOperation): + @staticmethod + def calculate(node): + return node.value + + +@dataclass +class NodeMeta: + """Metadata about how a node participates in calculations and display""" + + operation: Operation = Operation.NONE + indent_level: int = 0 + show_subtotal: bool = False + label_override: Optional[str] = None + expand: DetailLevel = DetailLevel.DETAILED diff --git a/general_ledger/statements/mixins/__init__.py b/general_ledger/statements/mixins/__init__.py new file mode 100644 index 0000000..bb87d65 --- /dev/null +++ b/general_ledger/statements/mixins/__init__.py @@ -0,0 +1,3 @@ +from .tree import TreeMixin +from .start_end import StartEndMixin +from .validatable import ValidatableMixin diff --git a/general_ledger/statements/mixins/rich.py b/general_ledger/statements/mixins/rich.py new file mode 100644 index 0000000..63ea136 --- /dev/null +++ b/general_ledger/statements/mixins/rich.py @@ -0,0 +1,70 @@ +import importlib + +import rich.repr +from rich.console import Console +from rich.console import ConsoleOptions, RenderResult +from rich.table import Table + +from general_ledger.statements.meta import Operation + +statements = importlib.import_module("general_ledger.statements") +# StatementNode = module_node.StatementNode + +console = Console() + + +def __statement_node_rich_repr__( + node: "statements.node.StatementNode", +) -> rich.repr.Result: + yield node.name + yield "start_date", node.start_date, ( + node.parent.start_date if node.parent else None + ) + yield "end_date", node.end_date, (node.parent.end_date if node.parent else None) + yield "depth", node.depth, 0 + yield "provider", node.provider, (node.parent.provider if node.parent else "always") + # yield "path", node.get_path() + yield "strategy", node.value_strategy.__class__.__name__, ( + node.parent.value_strategy.__class__.__name__ if node.parent else "NoneType" + ) + yield "account_type", node.account_type, None + yield "account_id", node.account_id, None + # this triggers a full calc so can't use rich print in calcs methods unless this is removed, otherwise it will loop + yield "value", node.value, 0 + yield "sections", node.sections if node.sections.value else None, "NoneType" + yield "is_visible", node.is_visible, True + yield "_is_set_visible", node._is_set_visible, None + yield "expand", node.meta.expand + yield "children", node._children, {} + + +def __statement_node_rich_console__( + node, console: Console, options: ConsoleOptions +) -> RenderResult: + if node.parent is None: + my_table = Table( + "Account", "details", "totals", "egr", "jhigur", "fgrg", expand=True + ) + for child in node._children.values(): + get_rows(child, my_table) + my_table.add_row( + f"{node.name}", + f"{node.depth:<{node.depth}}", + "", + "", + f"{node.value}", + "", + ) + yield my_table + + +def get_rows(node, table: Table = None) -> None: + op = node.meta.operation.name + label = f"{op} {node.name}" + if node.meta.operation == Operation.LESS and node.has_children: + table.add_row(f"Less {label}", f"{node.depth:>{node.depth}}", "", "", "", "") + if node.parent and node.parent.meta.operation == Operation.LESS: + label = f" {op} {node.name}" + for child in node._children.values(): + get_rows(child, table) + table.add_row(label, f"{node.depth:>{node.depth}}", "", "", "", str(node.value)) diff --git a/general_ledger/statements/mixins/start_end.py b/general_ledger/statements/mixins/start_end.py new file mode 100644 index 0000000..52a7f89 --- /dev/null +++ b/general_ledger/statements/mixins/start_end.py @@ -0,0 +1,57 @@ +from datetime import date +from datetime import datetime + +from rich import inspect + + +class StartDateDescriptor: + def __set_name__(self, owner, name): + self.name = name + + def __get__(self, instance, owner): + return instance.__dict__.get(self.name) or 0 + + def __set__(self, instance, value): + if not isinstance(value, date): + raise ValueError("Start date must be a date object") + instance.__dict__[self.name] = value + + +class EndDateDescriptor: + def __set_name__(self, owner, name): + self.name = name + + def __get__(self, obj, owner): + if obj is None: + return self + return obj.__dict__.get(self.name, None) + + def __set__(self, obj, value): + if isinstance(value, str): + value = datetime.strptime(value, "%Y-%m-%d") + if not isinstance(value, date): + raise ValueError("End date must be a date object: (%s)" % value) + obj.__dict__[self.name] = value + + +class StartEndMixin: + """ + Mixin class for start and end dates. + start and end dates are required for most reports + """ + + strict_dates: bool = True + start_date: date = None + end_date = EndDateDescriptor() + + def __init__(self, *args, **kwargs): + """ + date parsing args + :param kwargs: + """ + self.strict_dates = kwargs.pop("strict_dates", self.strict_dates) + if "start_date" in kwargs: + self.start_date = kwargs.pop("start_date") + if "end_date" in kwargs: + self.end_date = kwargs.pop("end_date") + super().__init__(*args, **kwargs) diff --git a/general_ledger/statements/mixins/tree.py b/general_ledger/statements/mixins/tree.py new file mode 100644 index 0000000..0d3a5b3 --- /dev/null +++ b/general_ledger/statements/mixins/tree.py @@ -0,0 +1,165 @@ +from typing import Dict, Optional, TypeVar, Generic, Iterator +from collections import deque, OrderedDict + +from rich import inspect + +T = TypeVar("T", bound="TreeMixin") + + +class TreeMixin(Generic[T]): + """a tree that supports visiting""" + + def __init__( + self, + name: str, + **kwargs, + ): + self.name = name + self._children: OrderedDict[str, T] = OrderedDict() + self.parent: Optional[T] = None + self._depth = None + + def accept(self, visitor, /, *args, **kwargs): + return visitor.visit(self, *args, **kwargs) + + @property + def is_leaf(self) -> bool: + return len(self._children) == 0 + + @property + def has_children(self) -> bool: + return not self.is_leaf + + @property + def is_root(self) -> bool: + return self.parent is None + + @property + def root(self) -> T: + current_node = self + while current_node.parent is not None: + current_node = current_node.parent + return current_node + + def add_child(self, child: T) -> T: + if self.root.find(child.name, strict=False): + raise ValueError( + f"Node '{child.name}' already exists in tree. name must be unique." + ) + self._children[child.name] = child + child.parent = self + child._depth = None + return self + + def get_child(self, name: str, strict: bool = True) -> T | None: + """Get a child node by name""" + if name not in self._children and strict: + raise KeyError(f"Child node '{name}' does not exist") + elif name not in self._children: + return None + return self._children[name] + + @property + def depth(self) -> int: + if self._depth is not None: + return self._depth + count = 0 + current_node = self + while current_node.parent is not None: + count += 1 + current_node = current_node.parent + self._depth = count + return count + + def get_path(self) -> str: + """Get the path to this node""" + if self.parent: + return f"{self.parent.get_path()}/{self.name}" + return self.name + + def show_nest(self) -> str: + """Get the path to this node in dictionary format""" + if self.parent: + return f"{self.parent.show_nest()}['{self.name}']" + return f"['{self.name}']" + + def items(self, reverse=False): + return reversed(self._children.items()) if reverse else self._children.items() + + def keys(self, reverse=False): + return reversed(self._children.keys()) if reverse else self._children.keys() + + def values(self, reverse=False): + return reversed(self._children.values()) if reverse else self._children.values() + + def __len__(self): + return len(self._children) + + def __getitem__(self, item): + return self._children[item] + + def __setitem__(self, key, value): + self._children[key] = value + value.parent = self + + def __iter__(self): + return iter(self._children.keys()) + + def __delitem__(self, name: str) -> None: + if name in self._children: + child = self._children[name] + del self._children[name] + child.parent = None + else: + raise KeyError(f"Node '{self.name}' has no child named '{name}'") + + def __bool__(self) -> bool: + """A TreeMixin instance is always True unless it's None.""" + return True + + @staticmethod + def remove(node: T) -> None: + if node.parent: + del node.parent._children[node.name] + node.parent = None + else: + raise ValueError( + "Cannot remove the root node directly. " + "Use another approach like clearing the tree." + ) + + def find(self, name: str, strict: bool = True) -> Optional[T]: + """Finds a node by name in self or subtree.""" + if self.name == name: + return self + for child in self.values(): + found_node = child.find(name, strict=False) + if found_node: + return found_node + if strict: + raise LookupError(f"Node '{name}' not found in tree") + return None + + def pre_order_traversal(self, reverse=False) -> Iterator[T]: + yield self + for child in self.values(reverse): + yield from child.pre_order_traversal(reverse) + + def post_order_traversal(self, reverse=False) -> Iterator[T]: + for child in self.values(reverse): + yield from child.post_order_traversal(reverse) + yield self + + def level_order_traversal(self, reverse=False) -> Iterator[T]: + queue = deque([self]) + while queue: + node = queue.popleft() + yield node + queue.extend(node.values(reverse)) # Enqueue all children of the node + + def __rich_repr__(self): + yield self.name + yield "depth", self.depth + yield "is_leaf", self.is_leaf + yield "children", self._children.keys() + # yield from self.pre_order_traversal() diff --git a/general_ledger/statements/mixins/validatable.py b/general_ledger/statements/mixins/validatable.py new file mode 100644 index 0000000..9b2ee43 --- /dev/null +++ b/general_ledger/statements/mixins/validatable.py @@ -0,0 +1,15 @@ +from datetime import date + + +class ValidatableMixin: + """ + Mixin that sets a validation property + allow recursive validation + """ + + @property + def is_valid(self): + return self.validate() + + def validate(self): + raise NotImplementedError("validate method must be implemented in subclass") diff --git a/general_ledger/statements/nodes/__init__.py b/general_ledger/statements/nodes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/general_ledger/statements/nodes/assets.py b/general_ledger/statements/nodes/assets.py new file mode 100644 index 0000000..101cae5 --- /dev/null +++ b/general_ledger/statements/nodes/assets.py @@ -0,0 +1,66 @@ +from loguru import logger +from rich.console import Console + +from general_ledger.statements.meta import ( + Operation, + NodeMeta, + AddOperation, + LessOperation, +) +from general_ledger.statements.nodes.assets_current import CurrentAssets +from general_ledger.statements.nodes.liabilities import LiabilitiesNode +from general_ledger.statements.nodes.assets_non_current import NonCurrentAssets +from general_ledger.statements.statement_node import StatementNode +from general_ledger.statements.nodes.profit_and_loss import ProfitAndLossAccount +from general_ledger.statements.nodes.trading_account import TradingAccountNode +from general_ledger.statements.strategies import ( + TransactionTotalStrategy, + ClosingBalanceStrategy, +) + +console = Console() + + +class AssetsNode( + StatementNode, +): + + def __init__(self, **kwargs): + super().__init__( + name=kwargs.pop("name", "assets"), + title=kwargs.pop("title", "Assets"), + label=kwargs.pop("label", "Assets"), + **kwargs, + ) + + def _expand_for_calculation(self) -> None: + """Expand trading account into its components""" + if not self._children: + # Add main components + logger.trace("Expanding Assets Node") + self.add_child( + NonCurrentAssets( + provider=self.provider, + start_date=self.start_date, + end_date=self.end_date, + operation=AddOperation, + meta=NodeMeta( + operation=Operation.ADD, + ), + ) + ) + + self.add_child( + CurrentAssets( + provider=self.provider, + start_date=self.start_date, + end_date=self.end_date, + operation=AddOperation, + meta=NodeMeta( + operation=Operation.ADD, + ), + ) + ) + + for child in self.values(): + child.ensure_expanded() diff --git a/general_ledger/statements/nodes/assets_current.py b/general_ledger/statements/nodes/assets_current.py new file mode 100644 index 0000000..4fdc67c --- /dev/null +++ b/general_ledger/statements/nodes/assets_current.py @@ -0,0 +1,98 @@ +from loguru import logger +from rich.console import Console + +from general_ledger.statements.meta import ( + Operation, + NodeMeta, + AddOperation, + LessOperation, +) +from general_ledger.statements.nodes.liabilities import LiabilitiesNode +from general_ledger.statements.statement_node import StatementNode +from general_ledger.statements.nodes.profit_and_loss import ProfitAndLossAccount +from general_ledger.statements.nodes.trading_account import TradingAccountNode +from general_ledger.statements.strategies import ( + TransactionTotalStrategy, + ClosingBalanceStrategy, +) + +console = Console() + + +class CurrentAssets( + StatementNode, +): + + def __init__(self, **kwargs): + super().__init__( + name=kwargs.pop("name", "current_assets"), + title=kwargs.pop("title", "Current Assets"), + label=kwargs.pop("label", "Current Assets"), + **kwargs, + ) + + def _expand_for_calculation(self) -> None: + """Expand trading account into its components""" + if not self._children: + # Add main components + logger.trace("Expanding Non Current Assets account") + self.add_child( + StatementNode( + name="inventory", + label="Inventory", + provider=self.provider, + start_date=self.start_date, + end_date=self.end_date, + account_type="inventory", + operation=AddOperation, + meta=NodeMeta( + operation=Operation.ADD, + ), + value_strategy=ClosingBalanceStrategy(), + ) + ).add_child( + StatementNode( + name="Bank Account", + provider=self.provider, + start_date=self.start_date, + end_date=self.end_date, + account_type="bank", + operation=AddOperation, + meta=NodeMeta( + operation=Operation.ADD, + show_subtotal=True, + ), + value_strategy=TransactionTotalStrategy(), + ) + ).add_child( + StatementNode( + name="Cash", + provider=self.provider, + start_date=self.start_date, + end_date=self.end_date, + account_type="cash", + operation=AddOperation, + meta=NodeMeta( + operation=Operation.ADD, + show_subtotal=True, + ), + value_strategy=TransactionTotalStrategy(), + ) + ).add_child( + StatementNode( + name="Trade Receivables", + provider=self.provider, + start_date=self.start_date, + end_date=self.end_date, + account_type="accounts-receivable", + operation=AddOperation, + meta=NodeMeta( + operation=Operation.ADD, + show_subtotal=True, + ), + value_strategy=TransactionTotalStrategy(), + ) + ) + + for child in self.values(): + child.ensure_expanded() diff --git a/general_ledger/statements/nodes/assets_non_current.py b/general_ledger/statements/nodes/assets_non_current.py new file mode 100644 index 0000000..7363613 --- /dev/null +++ b/general_ledger/statements/nodes/assets_non_current.py @@ -0,0 +1,56 @@ +from loguru import logger +from rich.console import Console + +from general_ledger.statements.meta import ( + Operation, + NodeMeta, + AddOperation, + LessOperation, +) +from general_ledger.statements.nodes.liabilities import LiabilitiesNode +from general_ledger.statements.statement_node import StatementNode +from general_ledger.statements.nodes.profit_and_loss import ProfitAndLossAccount +from general_ledger.statements.nodes.trading_account import TradingAccountNode +from general_ledger.statements.strategies import ( + TransactionTotalStrategy, + ClosingBalanceStrategy, +) + +console = Console() + + +class NonCurrentAssets( + StatementNode, +): + + def __init__(self, **kwargs): + super().__init__( + name=kwargs.pop("name", "non_current_assets"), + title=kwargs.pop("title", "Non Current Assets"), + label=kwargs.pop("label", "Non Current Assets"), + **kwargs, + ) + + def _expand_for_calculation(self) -> None: + """Expand trading account into its components""" + if not self._children: + # Add main components + logger.trace("Expanding Non Current Assets account") + self.add_child( + StatementNode( + name="Fixtures and Fittings", + label="Fixtures", + provider=self.provider, + start_date=self.start_date, + end_date=self.end_date, + account_type="non-current-asset", + operation=AddOperation, + meta=NodeMeta( + operation=Operation.ADD, + ), + value_strategy=ClosingBalanceStrategy(), + ) + ) + + for child in self.values(): + child.ensure_expanded() diff --git a/general_ledger/statements/nodes/balance_sheet.py b/general_ledger/statements/nodes/balance_sheet.py new file mode 100644 index 0000000..bc76f50 --- /dev/null +++ b/general_ledger/statements/nodes/balance_sheet.py @@ -0,0 +1,66 @@ +from loguru import logger +from rich.console import Console + +from general_ledger.statements.meta import ( + Operation, + NodeMeta, + AddOperation, + LessOperation, +) +from general_ledger.statements.nodes.assets import AssetsNode +from general_ledger.statements.nodes.liabilities import LiabilitiesNode +from general_ledger.statements.statement_node import StatementNode +from general_ledger.statements.nodes.profit_and_loss import ProfitAndLossAccount +from general_ledger.statements.nodes.trading_account import TradingAccountNode +from general_ledger.statements.strategies import ( + TransactionTotalStrategy, + ClosingBalanceStrategy, +) + +console = Console() + + +class BalanceSheetNode( + StatementNode, +): + + def __init__(self, **kwargs): + super().__init__( + name=kwargs.pop("name", "balance_sheet"), + title=kwargs.pop("title", "Balance Sheet"), + label=kwargs.pop("label", "Balance sheet"), + **kwargs, + ) + + def _expand_for_calculation(self) -> None: + """Expand trading account into its components""" + if not self._children: + # Add main components + logger.trace("Expanding Balance Sheet account") + self.add_child( + AssetsNode( + provider=self.provider, + start_date=self.start_date, + end_date=self.end_date, + operation=AddOperation, + meta=NodeMeta( + operation=Operation.ADD, + show_subtotal=True, + ), + ) + ) + self.add_child( + LiabilitiesNode( + provider=self.provider, + start_date=self.start_date, + end_date=self.end_date, + operation=LessOperation, + meta=NodeMeta( + operation=Operation.LESS, + show_subtotal=True, + ), + ) + ) + + for child in self.values(): + child.ensure_expanded() diff --git a/general_ledger/statements/nodes/capital.py b/general_ledger/statements/nodes/capital.py new file mode 100644 index 0000000..4412a96 --- /dev/null +++ b/general_ledger/statements/nodes/capital.py @@ -0,0 +1,90 @@ +from loguru import logger + +from general_ledger.statements.meta import ( + Operation, + NodeMeta, + AddOperation, + LessOperation, +) +from general_ledger.statements.nodes.assets_current import CurrentAssets +from general_ledger.statements.nodes.assets_non_current import NonCurrentAssets +from general_ledger.statements.statement_node import StatementNode +from general_ledger.statements.strategies import ClosingBalanceStrategy + + +class CapitalNode( + StatementNode, +): + + def __init__(self, **kwargs): + super().__init__( + name=kwargs.pop("name", "Owner's Equity"), + title=kwargs.pop("title", "Owner's Equity"), + label=kwargs.pop("label", "total=="), + **kwargs, + ) + + def _expand_for_calculation(self) -> None: + """Expand trading account into its components""" + if not self._children: + # Add main components + logger.trace("Expanding Capital Node") + + self.add_child( + StatementNode( + name="Capital", + provider=self.provider, + start_date=self.start_date, + end_date=self.end_date, + operation=AddOperation, + meta=NodeMeta( + operation=Operation.ADD, + ), + ).add_child( + StatementNode( + name="Cash introduced", + provider=self.provider, + start_date=self.start_date, + end_date=self.end_date, + account_id="capital", + operation=AddOperation, + meta=NodeMeta( + operation=Operation.ADD, + ), + value_strategy=ClosingBalanceStrategy(), + ) + ) + # .add_child( + # StatementNode( + # name="profits", + # title="Add Net profit for the year", + # provider=self.provider, + # start_date=self.start_date, + # end_date=self.end_date, + # operation=AddOperation, + # account_id="capital1", + # meta=NodeMeta( + # operation=Operation.ADD, + # ), + # value_strategy=ClosingBalanceStrategy(), + # ) + # ) + ) + + self.add_child( + StatementNode( + name="Drawings", + provider=self.provider, + start_date=self.start_date, + end_date=self.end_date, + account_type="drawings", + operation=LessOperation, + meta=NodeMeta( + operation=Operation.LESS, + ), + value_strategy=ClosingBalanceStrategy(), + ) + ) + + for child in self.values(): + child.ensure_expanded() diff --git a/general_ledger/statements/nodes/cogs.py b/general_ledger/statements/nodes/cogs.py new file mode 100644 index 0000000..5bb070c --- /dev/null +++ b/general_ledger/statements/nodes/cogs.py @@ -0,0 +1,84 @@ +from loguru import logger + +from general_ledger.statements.meta import AddOperation, LessOperation +from general_ledger.statements.statement_node import ( + StatementNode, + NodeMeta, + Operation, +) +from general_ledger.statements.strategies import ( + OpeningBalanceStrategy, + TransactionTotalStrategy, + ClosingBalanceStrategy, +) + + +class CostOfGoodsSold(StatementNode): + def _expand_for_calculation(self) -> None: + if not self._children: + logger.trace("Expanding COGS account") + self.add_child( + StatementNode( + name="opening_inv", + provider=self.provider, + start_date=self.start_date, + end_date=self.end_date, + account_type="inventory", + value_strategy=OpeningBalanceStrategy(), + operation=AddOperation, + meta=NodeMeta( + operation=Operation.ADD, + show_subtotal=True, + ), + ) + ) + + self.add_child( + StatementNode( + name="purchases", + provider=self.provider, + start_date=self.start_date, + end_date=self.end_date, + account_type="purchases", + operation=AddOperation, + value_strategy=TransactionTotalStrategy(), + meta=NodeMeta( + operation=Operation.ADD, + show_subtotal=True, + ), + ) + ) + + self.add_child( + StatementNode( + name="Purchases Returns", + provider=self.provider, + start_date=self.start_date, + end_date=self.end_date, + account_type="purchases-returns", + operation=AddOperation, + value_strategy=TransactionTotalStrategy(), + meta=NodeMeta( + operation=Operation.ADD, + show_subtotal=True, + ), + ) + ) + + self.add_child( + StatementNode( + name="closing_inv", + provider=self.provider, + start_date=self.start_date, + end_date=self.end_date, + account_type="inventory", + operation=LessOperation, + value_strategy=ClosingBalanceStrategy(), + meta=NodeMeta( + operation=Operation.LESS, + show_subtotal=True, + ), + ) + ) + for child in self._children.values(): + child.ensure_expanded() diff --git a/general_ledger/statements/nodes/cost_of_sales.py b/general_ledger/statements/nodes/cost_of_sales.py new file mode 100644 index 0000000..9b475d5 --- /dev/null +++ b/general_ledger/statements/nodes/cost_of_sales.py @@ -0,0 +1,64 @@ +from general_ledger.statements.statement_node import StatementNode + + +from loguru import logger + +from general_ledger.statements.statement_node import ( + StatementNode, + DetailLevel, + NodeMeta, + Operation, +) +from general_ledger.statements.strategies import ( + OpeningBalanceStrategy, + TransactionTotalStrategy, + ClosingBalanceStrategy, +) + + +class CostOfSalesNode(StatementNode): + """Node representing aggregate cost of sales figures""" + + def _expand_for_calculation(self) -> None: + if not self._children: + logger.trace("Expanding COGS account") + self.add_child( + StatementNode( + name="opening_inventory", + provider=self.provider, + start_date=self.start_date, + end_date=self.end_date, + account_type="inventory", + value_strategy=OpeningBalanceStrategy(), + meta=NodeMeta( + operation=Operation.ADD, + indent_level=1, + ), + ) + ) + + self.add_child( + StatementNode( + name="purchases", + provider=self.provider, + start_date=self.start_date, + end_date=self.end_date, + account_type="purchases", + value_strategy=TransactionTotalStrategy(), + meta=NodeMeta(operation=Operation.ADD, indent_level=1), + ) + ) + + self.add_child( + StatementNode( + name="closing_inventory", + provider=self.provider, + start_date=self.start_date, + end_date=self.end_date, + account_type="inventory", + value_strategy=ClosingBalanceStrategy(), + meta=NodeMeta(operation=Operation.LESS, indent_level=1), + ) + ) + for child in self._children.values(): + child.ensure_expanded() diff --git a/general_ledger/statements/nodes/expenses.py b/general_ledger/statements/nodes/expenses.py new file mode 100644 index 0000000..634d976 --- /dev/null +++ b/general_ledger/statements/nodes/expenses.py @@ -0,0 +1,47 @@ +from general_ledger.statements.meta import ( + NodeMeta, + Operation, + LessOperation, + AddOperation, +) +from general_ledger.statements.statement_node import StatementNode +from general_ledger.statements.strategies import TransactionTotalStrategy + + +class ExpensesNode( + StatementNode, +): + + def __init__(self, **kwargs): + super().__init__( + operation=LessOperation, + name="expenses", + **kwargs, + ) + + def _expand_for_calculation(self) -> None: + """Expand trading account into its components""" + if not self._children: + expense_accounts = self.provider.get_accounts_by_type("overhead") + + # Sort by name for consistent display + expense_accounts.sort(key=lambda x: x.name) + + for account in expense_accounts: + self.add_child( + StatementNode( + name=account.name, + provider=self.provider, + start_date=self.start_date, + end_date=self.end_date, + operation=AddOperation, + meta=NodeMeta( + operation=Operation.ADD, + indent_level=1, + show_subtotal=True, + ), + account_type="overhead", + account_id=account.id, + value_strategy=TransactionTotalStrategy(), + ) + ) diff --git a/general_ledger/statements/nodes/income_statement.py b/general_ledger/statements/nodes/income_statement.py new file mode 100644 index 0000000..56b37b0 --- /dev/null +++ b/general_ledger/statements/nodes/income_statement.py @@ -0,0 +1,53 @@ +from loguru import logger +from rich.console import Console + +from general_ledger.statements.meta import Operation, NodeMeta +from general_ledger.statements.statement_node import StatementNode +from general_ledger.statements.nodes.profit_and_loss import ProfitAndLossAccount +from general_ledger.statements.nodes.trading_account import TradingAccountNode + +console = Console() + + +class IncomeStatementNode( + StatementNode, +): + def __init__(self, **kwargs): + super().__init__( + name=kwargs.pop("name", "income"), + title=kwargs.pop("title", "Income Statement"), + label=kwargs.pop("label", "Net Profit"), + **kwargs, + ) + + def _expand_for_calculation(self) -> None: + if not self._children: + # Add main components + logger.trace("Expanding Income Statement account") + self.add_child( + TradingAccountNode( + label="Gross Profit", + provider=self.provider, + start_date=self.start_date, + end_date=self.end_date, + meta=NodeMeta( + operation=Operation.ADD, + show_subtotal=True, + ), + ) + ) + self.add_child( + ProfitAndLossAccount( + label="Other Activities", + name="other-activities", + provider=self.provider, + start_date=self.start_date, + end_date=self.end_date, + meta=NodeMeta( + operation=Operation.ADD, + show_subtotal=True, + ), + ) + ) + for child in self._children.values(): + child.ensure_expanded() diff --git a/general_ledger/statements/nodes/income_statement_corp.py b/general_ledger/statements/nodes/income_statement_corp.py new file mode 100644 index 0000000..ea62085 --- /dev/null +++ b/general_ledger/statements/nodes/income_statement_corp.py @@ -0,0 +1,133 @@ +from loguru import logger +from rich.console import Console + +from general_ledger.statements.meta import ( + Operation, + NodeMeta, + AddOperation, + LessOperation, +) +from general_ledger.statements.statement_node import StatementNode +from general_ledger.statements.nodes.operating_profit import OperatingProfit +from general_ledger.statements.nodes.trading_account import TradingAccountNode +from general_ledger.statements.strategies import TransactionTotalStrategy + +console = Console() + + +class IncomeStatementCorpNode( + StatementNode, +): + + def __init__(self, **kwargs): + super().__init__( + name="Statement of Comprehensive Income", + label="Profit for the financial year", + **kwargs, + ) + + def _expand_for_calculation(self) -> None: + """Expand trading account into its components""" + if not self._children: + # Add main components + logger.trace("Expanding Income Statement account") + self.add_child( + TradingAccountNode( + label="Gross Profit", + provider=self.provider, + start_date=self.start_date, + end_date=self.end_date, + meta=NodeMeta( + operation=Operation.ADD, + show_subtotal=True, + ), + ) + ) + self.add_child( + OperatingProfit( + name="Operating Profit", + provider=self.provider, + start_date=self.start_date, + end_date=self.end_date, + meta=NodeMeta( + operation=Operation.ADD, + show_subtotal=True, + ), + ) + ) + self.add_child( + StatementNode( + name="Profit before Taxation", + provider=self.provider, + start_date=self.start_date, + end_date=self.end_date, + account_type="other-income", + operation=AddOperation, + meta=NodeMeta( + operation=Operation.ADD, + show_subtotal=True, + ), + value_strategy=TransactionTotalStrategy(), + ) + .add_child( + StatementNode( + name="Interest Receivable", + provider=self.provider, + start_date=self.start_date, + end_date=self.end_date, + account_type="interest-income", + operation=AddOperation, + meta=NodeMeta( + operation=Operation.ADD, + show_subtotal=True, + ), + value_strategy=TransactionTotalStrategy(), + ) + ) + .add_child( + StatementNode( + name="Disposals", + provider=self.provider, + start_date=self.start_date, + end_date=self.end_date, + account_type="disposals", + operation=AddOperation, + meta=NodeMeta( + operation=Operation.ADD, + show_subtotal=True, + ), + value_strategy=TransactionTotalStrategy(), + ) + ) + ) + self.add_child( + StatementNode( + name="Tax on Profit", + provider=self.provider, + start_date=self.start_date, + end_date=self.end_date, + account_id="taxstuff", + operation=LessOperation, + meta=NodeMeta( + operation=Operation.LESS, + show_subtotal=True, + ), + value_strategy=TransactionTotalStrategy(), + ).add_child( + StatementNode( + name="Corporation Tax", + provider=self.provider, + start_date=self.start_date, + end_date=self.end_date, + account_type="tax", + operation=AddOperation, + meta=NodeMeta( + operation=Operation.ADD, + show_subtotal=True, + ), + value_strategy=TransactionTotalStrategy(), + ) + ) + ) + for child in self._children.values(): + child.ensure_expanded() diff --git a/general_ledger/statements/nodes/liabilities.py b/general_ledger/statements/nodes/liabilities.py new file mode 100644 index 0000000..523ba02 --- /dev/null +++ b/general_ledger/statements/nodes/liabilities.py @@ -0,0 +1,64 @@ +from loguru import logger +from rich.console import Console + +from general_ledger.statements.meta import ( + Operation, + NodeMeta, + LessOperation, + AddOperation, +) +from general_ledger.statements.statement_node import StatementNode +from general_ledger.statements.strategies import ( + ClosingBalanceStrategy, + TransactionTotalStrategy, +) + +console = Console() + + +class LiabilitiesNode( + StatementNode, +): + + def __init__(self, **kwargs): + super().__init__( + name=kwargs.pop("name", "liabilities"), + title=kwargs.pop("title", "Liabilities"), + label=kwargs.pop("label", "Liabilities"), + **kwargs, + ) + + def _expand_for_calculation(self) -> None: + """Expand trading account into its components""" + if not self._children: + # Add main components + logger.trace("Expanding Income Statement account") + self.add_child( + StatementNode( + name="Current Liabilities", + provider=self.provider, + start_date=self.start_date, + end_date=self.end_date, + meta=NodeMeta( + operation=Operation.ADD, + show_subtotal=True, + ), + ).add_child( + StatementNode( + name="Trade Payables", + provider=self.provider, + start_date=self.start_date, + end_date=self.end_date, + account_type="accounts-payable", + operation=AddOperation, + meta=NodeMeta( + operation=Operation.ADD, + show_subtotal=True, + ), + value_strategy=TransactionTotalStrategy(), + ) + ) + ) + + for child in self._children.values(): + child.ensure_expanded() diff --git a/general_ledger/statements/nodes/operating_profit.py b/general_ledger/statements/nodes/operating_profit.py new file mode 100644 index 0000000..58dd672 --- /dev/null +++ b/general_ledger/statements/nodes/operating_profit.py @@ -0,0 +1,55 @@ +from loguru import logger + +from general_ledger.statements.meta import Operation, NodeMeta, AddOperation +from general_ledger.statements.statement_node import StatementNode +from general_ledger.statements.nodes.expenses import ExpensesNode +from general_ledger.statements.strategies import TransactionTotalStrategy + + +class OperatingProfit( + StatementNode, +): + + def __init__(self, **kwargs): + name = kwargs.pop("name", "Operating Profit") + super().__init__( + name=name, + operation=kwargs.pop("operation", AddOperation), + **kwargs, + ) + + def _expand_for_calculation(self) -> None: + """Expand trading account into its components""" + if not self._children: + # Add main components + logger.trace("Operating Profit") + self.add_child( + StatementNode( + name="Other Operating Income", + provider=self.provider, + start_date=self.start_date, + end_date=self.end_date, + account_type="other-income", + operation=AddOperation, + meta=NodeMeta( + operation=Operation.ADD, + show_subtotal=True, + ), + value_strategy=TransactionTotalStrategy(), + ) + ) + self.add_child( + ExpensesNode( + provider=self.provider, + start_date=self.start_date, + end_date=self.end_date, + meta=NodeMeta( + operation=Operation.LESS, + show_subtotal=True, + ), + value_strategy=TransactionTotalStrategy(), + ) + ) + + for child in self._children.values(): + child.ensure_expanded() diff --git a/general_ledger/statements/nodes/profit_and_loss.py b/general_ledger/statements/nodes/profit_and_loss.py new file mode 100644 index 0000000..4126e8f --- /dev/null +++ b/general_ledger/statements/nodes/profit_and_loss.py @@ -0,0 +1,56 @@ +from loguru import logger + +from general_ledger.statements.meta import Operation, NodeMeta, AddOperation +from general_ledger.statements.statement_node import StatementNode +from general_ledger.statements.nodes.expenses import ExpensesNode +from general_ledger.statements.strategies import TransactionTotalStrategy + + +class ProfitAndLossAccount( + StatementNode, +): + + def __init__(self, **kwargs): + super().__init__( + operation=kwargs.pop("operation", AddOperation), + name=kwargs.pop("name", "p_and_l"), + title=kwargs.pop("title", "Income Statement"), + label=kwargs.pop("label", "Net Profit"), + **kwargs, + ) + + def _expand_for_calculation(self) -> None: + """Expand trading account into its components""" + if not self._children: + # Add main components + logger.trace("Expanding P and L account") + self.add_child( + StatementNode( + name="other-income", + label="Other Operating Income", + provider=self.provider, + start_date=self.start_date, + end_date=self.end_date, + account_type="other-income", + operation=AddOperation, + meta=NodeMeta( + operation=Operation.ADD, + ), + value_strategy=TransactionTotalStrategy(), + ) + ) + self.add_child( + ExpensesNode( + provider=self.provider, + start_date=self.start_date, + end_date=self.end_date, + meta=NodeMeta( + operation=Operation.LESS, + show_subtotal=True, + ), + value_strategy=TransactionTotalStrategy(), + ) + ) + + for child in self._children.values(): + child.ensure_expanded() diff --git a/general_ledger/statements/nodes/sales.py b/general_ledger/statements/nodes/sales.py new file mode 100644 index 0000000..a50ccdf --- /dev/null +++ b/general_ledger/statements/nodes/sales.py @@ -0,0 +1,65 @@ +from decimal import Decimal + +from general_ledger.statements.meta import AddOperation +from general_ledger.statements.statement_node import StatementNode, DetailLevel + + +class SalesNode(StatementNode): + """Node representing sales figures""" + + def __init__(self, *args, **kwargs): + super().__init__( + name=kwargs.pop("name", "sales"), + title=kwargs.pop("title", "Sales"), + label=kwargs.pop("label", "Sales"), + operation=kwargs.pop("operation", AddOperation), + **kwargs, + ) + self.account_type = "sales" + + # def _expand_for_calculation(self) -> None: + # + # if not self._children: + # # Add regional sales nodes + # for region in ["UK", "EU", "US"]: + # self.add_child( + # RegionalSalesNode( + # name=f"{region}_sales", + # provider=self.provider, + # start_date=self.start_date, + # end_date=self.end_date, + # region=region, + # ) + # ) + # for child in self._children.values(): + # child.ensure_expanded() + + +class RegionalSalesNode(StatementNode): + """Node representing sales for a specific region""" + + def __init__(self, region: str, **kwargs): + self.operation = kwargs.pop("operation", AddOperation) + super().__init__(**kwargs) + self.region = region + + def expand(self, detail_level: DetailLevel) -> bool: + """Expand regional sales into product categories""" + if detail_level != DetailLevel.FULL: + return False + + # if not self._children: + # # Add product category nodes + # categories = self.provider.get_product_categories(self.region) + # for category in categories: + # self.add_child( + # ProductCategorySalesNode( + # name=f"{category}_sales", + # provider=self.provider, + # start_date=self.start_date, + # end_date=self.end_date, + # region=self.region, + # category=category, + # ) + # ) + return True diff --git a/general_ledger/statements/nodes/trading_account.py b/general_ledger/statements/nodes/trading_account.py new file mode 100644 index 0000000..a37456d --- /dev/null +++ b/general_ledger/statements/nodes/trading_account.py @@ -0,0 +1,85 @@ +from loguru import logger + +from general_ledger.statements.meta import ( + NodeMeta, + Operation, + AddOperation, + LessOperation, +) +from general_ledger.statements.statement_node import StatementNode +from general_ledger.statements.nodes.cogs import CostOfGoodsSold +from general_ledger.statements.nodes.sales import SalesNode +from general_ledger.statements.strategies import TransactionTotalStrategy + + +class TradingAccountNode( + StatementNode, +): + + def __init__(self, **kwargs): + name = kwargs.pop("name", "trading") + super().__init__( + name=name, + operation=kwargs.pop("operation", AddOperation), + **kwargs, + ) + + def _expand_for_calculation(self) -> None: + """Expand trading account into its components""" + if not self._children: + # Add main components + logger.trace("Expanding trading account") + self.add_child( + SalesNode( + provider=self.provider, + start_date=self.start_date, + end_date=self.end_date, + meta=NodeMeta( + operation=Operation.ADD, + show_subtotal=False, + ), + value_strategy=TransactionTotalStrategy(), + ) + ) + + self.add_child( + StatementNode( + name="returns_inward", + provider=self.provider, + start_date=self.start_date, + end_date=self.end_date, + operation=LessOperation, + account_type="returns_inward", + meta=NodeMeta(operation=Operation.LESS), + value_strategy=TransactionTotalStrategy(), + ) + ) + + self.add_child( + CostOfGoodsSold( + name="cogs", + provider=self.provider, + start_date=self.start_date, + end_date=self.end_date, + operation=LessOperation, + meta=NodeMeta( + operation=Operation.LESS, + label_override="Cost of goods sold:", + show_subtotal=True, + ), + ) + ) + for child in self._children.values(): + child.ensure_expanded() + + # def __rich_console__( + # self, console: Console, options: ConsoleOptions + # ) -> RenderResult: + # yield f"[b]Account Summary:[/b] #{self.name}" + # my_table = Table("Account", "details", "totals") + # my_table.add_row("name", str(self.provider)) + # for child in self._children.values(): + # my_table.add_row("name", str(child.name)) + # my_table.add_row("age", str(self.value_strategy)) + # + # yield my_table diff --git a/general_ledger/statements/provider_django.py b/general_ledger/statements/provider_django.py new file mode 100644 index 0000000..24440c2 --- /dev/null +++ b/general_ledger/statements/provider_django.py @@ -0,0 +1,81 @@ +from datetime import date +from datetime import date +from typing import List, Optional + +from general_ledger.django.models import Account +from general_ledger.statements.core_domain import ( + AccountInfo, + TransactionTotal, + AccountBalance, +) +from general_ledger.statements.ledger_account import LedgerAccount +from general_ledger.statements.providers import BaseDataProvider +from general_ledger.utils.inspect import inspect + + +# Django ORM Provider +class DjangoProvider(BaseDataProvider): + """Django ORM implementation""" + + def __init__(self, ledger): + self.ledger = ledger + super().__init__(self, self, self) + + def __repr__(self): + return f"{self.__class__.__name__}(ledger={self.ledger})" + + # AccountProvider implementation + def get_account(self, account_id: str) -> Optional[AccountInfo]: + qs = self.ledger.coa.account_set.get_fuzzy(account_id) + inspect(qs.query) + inspect(qs) + account = qs.first() + if account: + return AccountInfo( + id=str(account.id), + name=account.name, + account_type=account.type.slug, + category=account.type.category, + ) + else: + raise ValueError(f"Account not found: {account_id}") + + # @logger_wraps(entry=True, exit=True) + def get_accounts_by_type(self, account_type: str) -> List[AccountInfo]: + accounts = self.ledger.coa.account_set.filter(type__slug=account_type) + return [ + AccountInfo( + id=str(account.id), + name=account.name, + account_type=account.type.slug, + category=account.type.category, + ) + for account in accounts + ] + + # @logger_wraps(entry=True, exit=True) + def get_transaction_total( + self, account_id: str, start_date: date, end_date: date + ) -> TransactionTotal: + ledger_account = LedgerAccount( + ledger=self.ledger, + account=Account.objects.get(id=account_id), + ) + return TransactionTotal( + account_id, ledger_account.balance, start_date, end_date + ) + + # @logger_wraps(entry=True, exit=True) + def get_balance( + self, account_id: str, as_of_date: date, at_close: bool = True + ) -> AccountBalance: + ledger_account = LedgerAccount( + ledger=self.ledger, + account=Account.objects.get_fuzzy(account_id), + ) + if at_close: + balance = ledger_account.all().upto(as_of_date).balance() + else: + balance = ledger_account.all().before(as_of_date).balance() + + return AccountBalance(account_id, balance, as_of_date) diff --git a/general_ledger/statements/providers.py b/general_ledger/statements/providers.py new file mode 100644 index 0000000..00fabbc --- /dev/null +++ b/general_ledger/statements/providers.py @@ -0,0 +1,155 @@ +from decimal import Decimal +from typing import Protocol, Dict +from datetime import date +from typing import Optional, List +from typing import Protocol + +import rich.repr + +from general_ledger.statements.core_domain import ( + AccountInfo, + AccountBalance, + TransactionTotal, +) +from general_ledger.statements.meta import NodeMeta, Operation +from general_ledger.statements.strategies import TransactionTotalStrategy + + +# Data Access Interfaces +class AccountProvider(Protocol): + """Interface for accessing account information""" + + def get_account(self, account_id: str) -> Optional[AccountInfo]: ... + + def get_accounts_by_type(self, account_type: str) -> List[AccountInfo]: ... + + +class BalanceProvider(Protocol): + """Interface for accessing account balances""" + + def get_balance( + self, account_id: str, as_of_date: date, at_close: bool = True + ) -> AccountBalance: ... + + def get_balances( + self, account_ids: List[str], as_of_date: date, at_close: bool = True + ) -> List[AccountBalance]: ... + + +class TransactionProvider(Protocol): + """Interface for accessing transaction information""" + + def get_transaction_total( + self, account_id: str, start_date: date, end_date: date + ) -> TransactionTotal: ... + + def get_transaction_totals( + self, account_ids: List[str], start_date: date, end_date: date + ) -> List[TransactionTotal]: ... + + +class BaseDataProvider: + """Combines all provider interfaces with default implementations""" + + def __init__( + self, + account_provider: AccountProvider, + balance_provider: BalanceProvider, + transaction_provider: TransactionProvider, + ): + self.account_provider = account_provider + self.balance_provider = balance_provider + self.transaction_provider = transaction_provider + + def __rich_repr__(self) -> rich.repr.Result: + yield "account_provider", self.account_provider + yield "balance_provider", self.balance_provider + yield "transaction_provider", self.transaction_provider + + +class CachingProviderDecorator(BaseDataProvider): + def __init__( + self, + provider: BaseDataProvider, + account_provider: AccountProvider, + balance_provider: BalanceProvider, + transaction_provider: TransactionProvider, + ): + super().__init__(account_provider, balance_provider, transaction_provider) + self.provider = provider + self._cache = {} + + def get_balance( + self, account_id: str, as_of_date: date, at_close: bool = True + ) -> AccountBalance: + cache_key = (account_id, as_of_date, at_close) + if cache_key not in self._cache: + self._cache[cache_key] = self.provider.get_balance( + account_id, as_of_date, at_close + ) + return self._cache[cache_key] + + +class LoggingProviderDecorator(BaseDataProvider): + def __init__( + self, + provider: BaseDataProvider, + logger, + account_provider: AccountProvider, + balance_provider: BalanceProvider, + transaction_provider: TransactionProvider, + ): + super().__init__(account_provider, balance_provider, transaction_provider) + self.provider = provider + self.logger = logger + + def get_balance( + self, account_id: str, as_of_date: date, at_close: bool = True + ) -> AccountBalance: + self.logger.debug(f"Getting balance for {account_id} at {as_of_date}") + result = self.provider.get_balance(account_id, as_of_date, at_close) + self.logger.debug(f"Balance is {result.balance}") + return result + + +# Example usage with test provider +class TestProvider(BaseDataProvider): + def get_accounts_by_type(self, account_type: str) -> List[AccountInfo]: + if account_type == "overhead": + return [ + AccountInfo( + id="rent-exp", + name="Rent Expense", + account_type="overhead", + category="expense", + ), + AccountInfo( + id="util-exp", + name="Utilities Expense", + account_type="overhead", + category="expense", + ), + AccountInfo( + id="sal-exp", + name="Salaries and Wages", + account_type="overhead", + category="expense", + ), + ] + return [] + + def get_transaction_total( + self, account_id: str, start_date: date, end_date: date + ) -> TransactionTotal: + # Example transactions for specific accounts + totals = { + "rent-exp": Decimal("8000"), + "util-exp": Decimal("4000"), + "sal-exp": Decimal("8000"), + } + return TransactionTotal( + account_id=account_id, + total=totals.get(account_id, Decimal("0")), + start_date=start_date, + end_date=end_date, + ) diff --git a/general_ledger/statements/statement_node.py b/general_ledger/statements/statement_node.py new file mode 100644 index 0000000..255f5c0 --- /dev/null +++ b/general_ledger/statements/statement_node.py @@ -0,0 +1,197 @@ +from dataclasses import dataclass, field +from datetime import date +from decimal import Decimal +from typing import Optional +from typing import Type + +import rich.repr +from loguru import logger +from rich.console import Console +from rich.console import ConsoleOptions, RenderResult + +from general_ledger.render.config_statement_render import RenderConfig +from general_ledger.statements.meta import NodeMeta, NodeOperation, NoneOperation +from general_ledger.statements.meta import Operation, DetailLevel +from general_ledger.statements.mixins import TreeMixin, StartEndMixin +from general_ledger.statements.mixins.rich import ( + __statement_node_rich_repr__, + __statement_node_rich_console__, +) +from general_ledger.statements.providers import BaseDataProvider +from general_ledger.statements.statement_section import SectionTotal +from general_ledger.statements.strategies import ValueStrategy +from general_ledger.render.renderer_statement import StatementRenderer +from general_ledger.render.mixins.renderable import RenderableMixin +from general_ledger.utils.utility import raiseu, logger_wraps, logger_init + + +class StatementNode( + StartEndMixin, + TreeMixin["StatementNode"], + RenderableMixin, +): + """Hierarchical node in a financial statement""" + + # @logger_init() + def __init__( + self, + /, + name: str, + *, + provider: BaseDataProvider, + title: str = None, + meta: Optional[NodeMeta] = None, + operation: Type[NodeOperation] = None, + value_strategy: Optional[ValueStrategy] = None, + account_type: str = None, + account_id: str = None, + label=None, + **kwargs, + ): + super().__init__(name, **kwargs) + self.detail_level = None + self.label: str = label if label else self.name + self.title: str = title if title else self.name + self.provider = provider + self.meta = meta or NodeMeta() + self.account_type = account_type + self.account_id = account_id # Store account_id + self._value: Optional[Decimal] = None + self.value_strategy = value_strategy + self._is_expanded = False + self._is_visible = True + self._is_set_visible = None + # these are both attempts to track the running total in siblings + # which kind of indicate a conceptual error in the design + self.accumulated_value = Decimal("0") + self.sections: SectionTotal = SectionTotal() + self.operation = operation or NoneOperation + + @property + def value(self) -> Decimal: + """Get the value for this node""" + if self._value is None: + self._value = self.calculate() + return self._value + + def is_effectively_zero(self, config: RenderConfig) -> bool: + """Check if node's value is effectively zero""" + return abs(self.value) < config.materiality_threshold + + def is_empty_branch(self, config: RenderConfig) -> bool: + """ + Check if this node and all its descendants are empty + """ + if not self.is_effectively_zero(config): + return False + + return all(child.is_empty_branch(config) for child in self.values()) + + def prune_empty(self): + """Prune empty branches from this node""" + for child in list(self.values()): + if child.is_empty_branch(RenderConfig()): + del self[child.name] + for child in self.values(): + child.prune_empty() + return self + + def should_be_visible(self, config: RenderConfig) -> bool: + """Determine if node should be visible based on all criteria""" + if not self.is_visible: + return False + + if config.hide_empty and self.is_empty_branch(config): + return False + + return True + + @property + def is_visible(self) -> bool: + """True if this node is visible""" + return ( + self._is_set_visible + if self._is_set_visible is not None + else self._is_visible + ) + + def calculate(self) -> Decimal: + """Calculate value including operation sign""" + self.ensure_expanded() + + if self.has_children: + running_total = Decimal("0") + for i, child in enumerate(self.values()): + running_total += child.operation.calculate(child) + child.accumulated_value = running_total + self.sections.add_value( + child.name, + child.value, + child.meta.operation, + is_first=i == 0, + is_last=i == len(self) - 1, + ) + + result = running_total + elif self.value_strategy: + result = self.value_strategy.calculate(self) + else: + raise ValueError(f"Node is leaf but has no value strategy '{self!r}'") + + return result + + def ensure_expanded(self) -> "StatementNode": + """Ensures node is expanded for calculation purposes""" + if not self._is_expanded: + self._expand_for_calculation() + self._is_expanded = True + return self + + def _expand_for_calculation(self) -> None: + """Internal method to expand node for calculation""" + # Override in subclasses to add necessary child nodes + pass + + def set_expand(self, detail_level: DetailLevel) -> "StatementNode": + """Controls which nodes are expanded based on detail level""" + self.meta.expand = detail_level + for child in self.values(): + child.set_expand(detail_level) + return self + + def set_visibility(self, detail_level: DetailLevel) -> "StatementNode": + """Controls which nodes are visible based on detail level""" + if detail_level == DetailLevel.SUMMARY: + # Only show top-level nodes + self._is_visible = not self.parent + elif detail_level == DetailLevel.DETAILED: + # Show up to second level + self._is_visible = not self.parent or not self.parent.parent + else: # FULL + self._is_visible = True + + for child in self.values(): + child.set_visibility(detail_level) + return self + + def render(self): + if not self.renderer: + logger.trace("No renderer set, using default StatementRenderer") + self.renderer = StatementRenderer() + return super().render() + + def __str__(self) -> str: + return self.name + + def __repr__(self) -> str: + return f"{self.__class__.__name__}(name={self.name if hasattr(self, 'name') else 'None'})" + + def __rich_repr__(self) -> rich.repr.Result: + yield from __statement_node_rich_repr__(self) + + __rich_repr__.angular = True + + def __rich_console__( + self, console: Console, options: ConsoleOptions + ) -> RenderResult: + yield from __statement_node_rich_console__(self, console, options) diff --git a/general_ledger/statements/statement_section.py b/general_ledger/statements/statement_section.py new file mode 100644 index 0000000..7cde1ba --- /dev/null +++ b/general_ledger/statements/statement_section.py @@ -0,0 +1,53 @@ +from dataclasses import dataclass, field +from datetime import date +from decimal import Decimal +from typing import Optional +from typing import Type + +import rich.repr +from loguru import logger +from rich.console import Console +from rich.console import ConsoleOptions, RenderResult + +from general_ledger.render.config_statement_render import RenderConfig +from general_ledger.statements.meta import NodeMeta, NodeOperation, NoneOperation +from general_ledger.statements.meta import Operation, DetailLevel +from general_ledger.statements.mixins import TreeMixin +from general_ledger.statements.mixins.rich import ( + __statement_node_rich_repr__, + __statement_node_rich_console__, +) +from general_ledger.statements.providers import BaseDataProvider +from general_ledger.statements.strategies import ValueStrategy +from general_ledger.render.renderer_statement import StatementRenderer +from general_ledger.render.mixins.renderable import RenderableMixin + + +@dataclass +class SectionTotal: + """Represents the cumulative total for a section of the financial statement.""" + + value: Decimal = Decimal(0) + subtotals: dict[str, Decimal] = field(default_factory=dict) + first: str = None + last: str = None + + def add_value( + self, + name: str, + amount: Decimal, + operation: Operation, + is_first: bool = False, + is_last: bool = False, + ): + self.first = name if is_first else self.first + self.last = name if is_last else self.last + + """Add a value to this section's total""" + modifier = -1 if operation == Operation.LESS else 1 + self.value += amount * modifier + + if name: + self.subtotals[name] = self.value + else: + raise ValueError("SectionTotal.add_value: name must be provided") diff --git a/general_ledger/statements/strategies.py b/general_ledger/statements/strategies.py new file mode 100644 index 0000000..2f9fe64 --- /dev/null +++ b/general_ledger/statements/strategies.py @@ -0,0 +1,84 @@ +from abc import ABC, abstractmethod +from datetime import date +from decimal import Decimal +from typing import Protocol, List + +from loguru import logger + +from general_ledger.django.models import Account + + +class ValueStrategy(Protocol): + def calculate(self, node: "StatementNode") -> Decimal: ... + + +class OpeningBalanceStrategy: + def calculate(self, node: "StatementNode") -> Decimal: + accounts = node.provider.get_accounts_by_type(node.account_type) + result = Decimal( + sum( + node.provider.get_balance( + acc.id, node.start_date, at_close=False + ).balance + for acc in accounts + ) + ) + return result + + +class ClosingBalanceStrategy: + def calculate(self, node: "StatementNode") -> Decimal: + logger.trace(f"Calculating ClosingBalanceStrategy for {node!r}") + """Calculate closing balance total for a node based on account_id if available""" + if node.account_id: + # Calculate for specific account + logger.trace(f"calculating for specific name {node.account_id}") + return Decimal( + node.provider.get_balance( + node.account_id, + node.end_date, + at_close=True, + ).balance + ) + elif node.account_type: + accounts = node.provider.get_accounts_by_type(node.account_type) + return Decimal( + sum( + node.provider.get_balance( + acc.id, node.end_date, at_close=True + ).balance + for acc in accounts + ) + ) + raise ValueError( + f"No account_id or account_type provided for calculation - '{node}'" + ) + + +class TransactionTotalStrategy: + def calculate(self, node: "StatementNode") -> Decimal: + # inspect(node) + logger.trace(f"Calculating transaction total for {node!r}") + """Calculate transaction total for a node based on account_id if available""" + if node.account_id: + # Calculate for specific account + logger.trace(f"calculating for specific name {node.account_id}") + return Decimal( + node.provider.get_transaction_total( + node.account_id, node.start_date, node.end_date + ).total + ) + elif node.account_type: + # Fallback to type-based calculation + accounts = node.provider.get_accounts_by_type(node.account_type) + return Decimal( + sum( + node.provider.get_transaction_total( + acc.id, node.start_date, node.end_date + ).total + for acc in accounts + ) + ) + raise ValueError( + f"No account_id or account_type provided for calculation - '{node}'" + ) diff --git a/general_ledger/statements/visitors/__init__.py b/general_ledger/statements/visitors/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/general_ledger/static/gl/css/style.css b/general_ledger/static/gl/css/style.css index 4e399d6..688c714 100644 --- a/general_ledger/static/gl/css/style.css +++ b/general_ledger/static/gl/css/style.css @@ -135,7 +135,7 @@ footer .text-center { .invoice-status-container { display: flex; - align-items: right; + align-items: end; } .form-invoice-status { @@ -204,4 +204,99 @@ footer .text-center { .potential-match { background-color: #8ecfcf; /* Light red */ +} + +.node-title { + font-size: 1.5em; + font-weight: bold; +} + +.node-content { + font-size: 1.2em; +} + +.node-content .node-content { + font-size: 1em; +} + +.node-value.node-total { + font-size: 1.5em; + font-weight: bold; + color: #eeeeee; +} + +.node-value { + font-size: 1.5em; + font-weight: bold; + color: #eeeeee; +} + +.node-level-9 { + background-color: #f0f9ff; /* Light pastel blue */ +} + +.node-level-8 { + background-color: #e0f2fe; /* Light pastel cyan */ +} + +.node-level-7 { + background-color: #bae6fd; /* Soft pastel light cyan */ +} + +.node-level-6 { + background-color: #7dd3fc; /* Pastel sky blue */ +} + +.node-level-5 { + background-color: #38bdf8; /* Medium pastel blue */ +} + +.node-level-4 { + background-color: #0ea5e9; /* Rich pastel blue */ +} + +.node-level-3 { + background-color: #0284c7; /* Deep pastel blue */ +} + +.node-level-2 { + background-color: #0369a1; /* Dark pastel blue */ +} + +.node-level-1 { + background-color: #075985; /* Very dark pastel blue */ +} + +.node-level-0 { + background-color: #0c4a6e; /* Intense dark blue */ +} + +/* Add to your CSS file */ +.grid-stack-item-drag-handle { + cursor: move; + cursor: grab; +} + +.grid-stack-item-drag-handle:active { + cursor: grabbing; +} + +.grid-stack-item-drag-handle .bi-grip-horizontal { + opacity: 0.5; + transition: opacity 0.2s; +} + +.grid-stack-item-drag-handle:hover .bi-grip-horizontal { + opacity: 1; +} + +/* Optional: Highlight the draggable area on hover */ +.grid-stack-item-drag-handle:hover { + background-color: rgba(0, 0, 0, 0.03); +} + +/* Make sure content is not draggable */ +.card-body { + cursor: default; + user-select: text; } \ No newline at end of file diff --git a/general_ledger/static/gl/js/invoice_form.js b/general_ledger/static/gl/js/invoice_form.js index 758a34a..26b9545 100644 --- a/general_ledger/static/gl/js/invoice_form.js +++ b/general_ledger/static/gl/js/invoice_form.js @@ -1,9 +1,12 @@ +/* + * this is just here to push the code down a few lines + */ document.addEventListener('DOMContentLoaded', function () { - const addButton = document.getElementById('add-line'); + const addButton = document.getElementById('add-line'); - var popoverTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="popover"]')) - var popoverList = popoverTriggerList.map(function (popoverTriggerEl) { - return new bootstrap.Popover(popoverTriggerEl) - }) + var popoverTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="popover"]')) + var popoverList = popoverTriggerList.map(function (popoverTriggerEl) { + return new bootstrap.Popover(popoverTriggerEl) + }) -}); \ No newline at end of file +}); diff --git a/general_ledger/templates/account/login.html b/general_ledger/templates/account/login.html new file mode 100644 index 0000000..c86741d --- /dev/null +++ b/general_ledger/templates/account/login.html @@ -0,0 +1,29 @@ +{% extends "account/base.html" %} + +{% load i18n %} + + +{% block head_title %}{% trans "Sign In" %}{% endblock %} + +{% block content %} + +

{% trans "Sign In" %}

+ + + + +

{% blocktrans %}If you have not created an account yet, then please +sign up first.{% endblocktrans %}

+ + +
+ {% csrf_token %} + {{ form.as_p }} + {% if redirect_field_value %} + + {% endif %} + {% trans "Forgot Password?" %} + +
+ +{% endblock %} diff --git a/general_ledger/templates/account/logout.html b/general_ledger/templates/account/logout.html new file mode 100644 index 0000000..2549a90 --- /dev/null +++ b/general_ledger/templates/account/logout.html @@ -0,0 +1,21 @@ +{% extends "account/base.html" %} + +{% load i18n %} + +{% block head_title %}{% trans "Sign Out" %}{% endblock %} + +{% block content %} +

{% trans "Sign Out" %}

+ +

{% trans 'Are you sure you want to sign out?' %}

+ +
+ {% csrf_token %} + {% if redirect_field_value %} + + {% endif %} + +
+ + +{% endblock %} diff --git a/general_ledger/templates/account/signup.html b/general_ledger/templates/account/signup.html new file mode 100644 index 0000000..8b53b44 --- /dev/null +++ b/general_ledger/templates/account/signup.html @@ -0,0 +1,21 @@ +{% extends "account/base.html" %} + +{% load i18n %} + +{% block head_title %}{% trans "Signup" %}{% endblock %} + +{% block content %} +

{% trans "Sign Up" %}

+ +

{% blocktrans %}Already have an account? Then please sign in.{% endblocktrans %}

+ +
+ {% csrf_token %} + {{ form.as_p }} + {% if redirect_field_value %} + + {% endif %} + +
+ +{% endblock %} diff --git a/general_ledger/templates/gl/base.html.j2 b/general_ledger/templates/gl/base.html.j2 index 7facca7..35d7652 100644 --- a/general_ledger/templates/gl/base.html.j2 +++ b/general_ledger/templates/gl/base.html.j2 @@ -4,128 +4,118 @@ - - - + + + - + - {% block prehead %} + {% block prehead %} + {% endblock %} - {% endblock %} + {# Load CSS and JavaScript #} + - {# Load CSS and JavaScript #} - + + + + {% block title %}Base Title (should never see this){% endblock %} - - - - {% block title %}Base Title (should never see this){% endblock %} + + + + - - - - + + - + - - - - - -{# + {# #} - - -{# #} - - {% block extrahead %} - + + {# #} - {% endblock %} + {% block extrahead %} + {% endblock %} - +{% block navbar %} + {% include 'gl/partials/navbar.html.j2' %} +{% endblock %} - {% block navbar %} - - {% include 'gl/partials/navbar.html.j2' %} - - {% endblock %} - - {% block sub_navbar %} - - {{ render_subnav() }} - - {% endblock %} +{% block sub_navbar %} + {{ render_subnav() }} +{% endblock %} - {% block warnings %} -{% include 'gl/partials/messages/warnings.html.j2' %} - {% endblock %} +{% block warnings %} + {% include 'gl/partials/messages/warnings.html.j2' %} +{% endblock %} - {% block main %} +{% block main %} - -
-
-
-
-

Default content from base.html

+ +
+
+
+
+

Default content from base.html

+
+
-
-
- {% endblock %} +{% endblock %} - {% block footer %} +{% block footer %} - {% include 'gl/partials/footer.html.j2' %} + {% include 'gl/partials/footer.html.j2' %} - {% endblock %} +{% endblock %} - - + + - + - - + + - + - + - + {# #} - {% block scriptend %} +{% block scriptend %} - {% endblock %} +{% endblock %} diff --git a/general_ledger/templates/gl/home.html.j2 b/general_ledger/templates/gl/home.html.j2 index c5d925e..fd8303f 100644 --- a/general_ledger/templates/gl/home.html.j2 +++ b/general_ledger/templates/gl/home.html.j2 @@ -2,101 +2,104 @@ {% block title %}Stocks{% endblock %} - {% block sub_navbar %} -{% with section_title="Overview" -%} -{% include 'gl/partials/sub_navbar.html.j2' %} -{% endwith %} + {% with section_title="Overview" %} + {% include 'gl/partials/sub_navbar.html.j2' %} + {% endwith %} {% endblock %} -{% block main %} - +{% block prehead %} + + +{% endblock %} - -
-
-
-
- -
- -
-
-
-
Cash Flow
-
-
- - -
- -
- {# #} +{% block main %} + +
+
+
+
+ +
+ {% include 'gl/home/card-chart.html.j2' %} + {% include 'gl/home/card-invoice.html.j2' %} + {% include 'gl/home/card-bank.html.j2' %} + {% include 'gl/home/card-bills.html.j2' %} + {% include 'gl/home/card-payroll.html.j2' %} +
+
-
-
-
-
Card 2
-

This is some text inside Card 2.

-
-
- -
-
-
Card 3
-

This is some text inside Card 3.

-
-
-
-
-
Card 2
-

This is some text inside Card 2.

-
-
- -
-
-
Card 3
-

This is some text inside Card 3.

-
-
-
-
-
-
- {% endblock %} {% block scriptend %} - - - - - - + + + + + {% endblock %} diff --git a/general_ledger/templates/gl/home/card-bank.html.j2 b/general_ledger/templates/gl/home/card-bank.html.j2 new file mode 100644 index 0000000..d3e3339 --- /dev/null +++ b/general_ledger/templates/gl/home/card-bank.html.j2 @@ -0,0 +1,44 @@ +
+
+
+ +
+
+
gnreg reg8 er9g r +
+
{{ generate_lorem_text(1, "paragraphs", False) }} +
+
fjdafjeowijfiwef +
+
+
+
diff --git a/general_ledger/templates/gl/home/card-bills.html.j2 b/general_ledger/templates/gl/home/card-bills.html.j2 new file mode 100644 index 0000000..7bfc0be --- /dev/null +++ b/general_ledger/templates/gl/home/card-bills.html.j2 @@ -0,0 +1,20 @@ + +
+
+
+
Bills Card
+
+
+

This is some text inside Bills Card.

+ +
+
+
+ \ No newline at end of file diff --git a/general_ledger/templates/gl/home/card-chart.html.j2 b/general_ledger/templates/gl/home/card-chart.html.j2 new file mode 100644 index 0000000..771f929 --- /dev/null +++ b/general_ledger/templates/gl/home/card-chart.html.j2 @@ -0,0 +1,28 @@ + +
+
+
+
Cash Flow
+
+
+ + +
+ +
+ {# #} + +
+
diff --git a/general_ledger/templates/gl/home/card-invoice.html.j2 b/general_ledger/templates/gl/home/card-invoice.html.j2 new file mode 100644 index 0000000..7e8b3cb --- /dev/null +++ b/general_ledger/templates/gl/home/card-invoice.html.j2 @@ -0,0 +1,17 @@ +
+
+
+
Invoice Card
+
+
+

This is some text Invoice Card 2.

+
+
+
\ No newline at end of file diff --git a/general_ledger/templates/gl/home/card-payroll.html.j2 b/general_ledger/templates/gl/home/card-payroll.html.j2 new file mode 100644 index 0000000..0530c4b --- /dev/null +++ b/general_ledger/templates/gl/home/card-payroll.html.j2 @@ -0,0 +1,17 @@ +
+
+
+
Payroll Card
+
+
+

This is some text inside payroll Card.

+
+
+
diff --git a/general_ledger/templates/gl/partials/navbar.html.j2 b/general_ledger/templates/gl/partials/navbar.html.j2 index 28a8c96..db23377 100644 --- a/general_ledger/templates/gl/partials/navbar.html.j2 +++ b/general_ledger/templates/gl/partials/navbar.html.j2 @@ -58,7 +58,7 @@
  • Balance Sheet
  • -
  • Profit and Loss
  • +
  • Profit and Loss
  • diff --git a/general_ledger/templates/gl/payment/payment_form.html.j2 b/general_ledger/templates/gl/payment/payment_form.html.j2 index da15016..7067c91 100644 --- a/general_ledger/templates/gl/payment/payment_form.html.j2 +++ b/general_ledger/templates/gl/payment/payment_form.html.j2 @@ -8,7 +8,7 @@ {% block main %} -fyuck +This is the result {% endblock %} diff --git a/general_ledger/templates/gl/statements/income_statement.html.j2 b/general_ledger/templates/gl/statements/income_statement.html.j2 index fbedb72..245e7f1 100644 --- a/general_ledger/templates/gl/statements/income_statement.html.j2 +++ b/general_ledger/templates/gl/statements/income_statement.html.j2 @@ -1,19 +1,40 @@ {% extends 'gl/base.html.j2' %} -{% block title %}Stocks{% endblock %} +{% block title %}Income Statement{% endblock %} {% block main %} -

    Income Statement

    -
    -{% csrf_token %} -{% include 'gl/widgets/datepicker.html.j2' %} +
    + +
    +
    +
    +
    +
    + + {% csrf_token %} + {% include 'gl/widgets/datepicker.html.j2' %} - + + + +
    + +
    + {{ statement|safe }} + +
    +
    + +
    +
    +
    + +
    {% endblock %} {% block scriptend %} -{% endblock %} +{% endblock %} \ No newline at end of file diff --git a/general_ledger/templatetags/myfilters.py b/general_ledger/templatetags/myfilters.py index 3227057..552f685 100644 --- a/general_ledger/templatetags/myfilters.py +++ b/general_ledger/templatetags/myfilters.py @@ -7,15 +7,70 @@ from django.urls import reverse from django.utils.html import format_html -from general_ledger.models import Invoice +from general_ledger.django.models import Invoice from crispy_forms.utils import render_crispy_form from django_jinja import library +from django.template.defaulttags import lorem + +import random + +LOREM_TEXT = ( + "lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiusmod tempor " + "incididunt ut labore et dolore magna aliqua ut enim ad minim veniam quis nostrud " + "exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat" +).split() + +NIETZSCHE_TEXT = ( + "Ultimate virtues hope sea good. Horror revaluation zarathustra intentions will dead " + "inexpedient good christian. War pious derive faith hope burying christianity morality justice " + "noble. Free abstract decrepit superiority inexpedient. " + "Snare decrepit superiority horror transvaluation truth madness love. Dead holiest faith " + "philosophy fearful strong endless prejudice spirit truth endless strong free. Horror " + "God is dead. God remains dead. And we have killed him. How shall we comfort " + "decrepit eternal-return gains overcome victorious sexuality ubermensch of joy contradict " + "ascetic insofar hatred Inexpedient reason philosophy truth depths truth oneself morality " + "love derive love prejudice victorious disgust. Marvelous reason reason fearful against " + "sea Selfish justice depths moral of ultimate pinnacle revaluation good strong " + "Eternal-return value good intentions value society fearful deceptions derive " + "eternal-return ocean christian right morality. Self law faithful ocean transvaluation " + "Self virtues reason value faith merciful burying decrepit noble. Law fearful of " + "against christian burying deceptions truth love law pious inexpedient. Chaos " + "victorious pious hatred overcome. Mountains philosophy marvelous overcome inexpedient " + "strong aversion enlightenment prejudice battle christianity spirit burying. Truth " + "passion strong eternal-return passion hatred burying superiority." +).split() + + +def generate_lorem_text(count=1, method="words", randomize=False): + """ + Generates lorem ipsum text. + - count: Number of words or paragraphs + - method: 'words' or 'paragraphs' + - randomize: If True, shuffle the text for random output + """ + if method == "paragraphs": + lorem_paragraph = " ".join(NIETZSCHE_TEXT) + paragraphs = [lorem_paragraph for _ in range(count)] + if randomize: + random.shuffle(paragraphs) + return "\n\n".join(paragraphs) + else: + words = LOREM_TEXT * ((count // len(LOREM_TEXT)) + 1) + if randomize: + random.shuffle(words) + return " ".join(words[:count]) + + +# Register the function as a global in Jinja +library.global_function(generate_lorem_text) + @library.global_function def inject_today_date(): return date.today().strftime("%Y-%m-%d") + @library.global_function() def crispy(form, helper, context=None): return render_crispy_form( diff --git a/general_ledger/tests/__init__.py b/general_ledger/tests/__init__.py index e2536c2..42a31fc 100644 --- a/general_ledger/tests/__init__.py +++ b/general_ledger/tests/__init__.py @@ -1,5 +1,4 @@ from .base import GeneralLedgerBaseTest -from general_ledger.tests.models.test_transactions import TestTransactionCreatePost from general_ledger.tests.book.test_chap2 import TestBasicOperations from general_ledger.tests.book.test_chap3 import TestBasicOperations2 from general_ledger.tests.book.test_chap4 import TestChap4Woods diff --git a/general_ledger/tests/base.py b/general_ledger/tests/base.py index 9d11f0d..ca4efce 100644 --- a/general_ledger/tests/base.py +++ b/general_ledger/tests/base.py @@ -3,7 +3,7 @@ from django.test import TestCase -from general_ledger.models import Book, Ledger +from general_ledger.django.models import Book, Ledger import logging diff --git a/general_ledger/tests/book/chap_5_review_5_5_data.csv b/general_ledger/tests/book/chap_5_review_5_5_data.csv new file mode 100644 index 0000000..2500654 --- /dev/null +++ b/general_ledger/tests/book/chap_5_review_5_5_data.csv @@ -0,0 +1,15 @@ +name,type_slug,tax_rate_slug +J Bee,accounts-receivable,no-vat +T Day,accounts-receivable,no-vat +J Soul,accounts-receivable,no-vat +D Blue,accounts-payable,no-vat +F Rise,accounts-payable,no-vat +P Lee,accounts-payable,no-vat +L Hope,accounts-payable,no-vat +R James,accounts-payable,no-vat +Sales Returns,current-asset,no-vat +Purchases Returns,current-liability,no-vat +Sales,sales,20-vat-on-income +Purchases,direct-costs,20-vat-on-expenses +Cash,cash,no-vat +Bank,bank,no-vat diff --git a/general_ledger/tests/book/data_chap3.py b/general_ledger/tests/book/data_chap3.py new file mode 100644 index 0000000..e534b40 --- /dev/null +++ b/general_ledger/tests/book/data_chap3.py @@ -0,0 +1,64 @@ +import logging + +import pytest + +from general_ledger.factories import BookFactory, LedgerFactory +from general_ledger.helpers.ledger_helper import LedgerHelper +from general_ledger.django.models import ( + Transaction, + Account, + Entry, +) +from general_ledger.utils.data_loader import tx + + +def load_chapter_3_data(): + """ + 1. Started a household machines business putting £2 ,000 into a + bank account. + 2. Bought equipment on time from house supplies £12,000. + + + """ + book = BookFactory(name="B. Swift") + ledger = book.get_default_ledger() + coa = book.get_default_coa() + + # fmt: off + purchases, sales, cash, capital, sales_returns, purchases_returns, d_small, a_lyon, d_hughes, m_spencer = [ + coa.get_or_create(*args) + for k, (args) in { + "purchases": ["Purchases", "direct-costs"], + "sales": ["Sales", "sales", "20-vat-on-income"], + "cash": ["Cash", "cash", "no-vat"], + "capital": ["Capital", "equity", "no-vat"], + "sales_returns": ["Sales Returns", "current-asset", "20-vat-on-income"], + "purchases_returns": ["Purchases Returns", "current-liability", "no-vat"], + "d_small": ["D Small", "accounts-payable", "no-vat"], + "a_lyon": ["A Lyon & Son", "accounts-payable", "no-vat"], + "d_hughes": ["D Hughes", "accounts-receivable", "no-vat"], + "m_spencer": ["M Spencer", "accounts-receivable", "no-vat"], + }.items() + ] + # fmt: on + + + + # fmt: off + txs = [tx(ledger, dr,amt,dt,cr) for dr,amt,dt,cr in [ + (purchases, "220", "2020-05-1", d_small), + (purchases, "410", "2020-5-2", a_lyon), + (d_hughes, "60.00", "2020-5-5", sales), + (m_spencer, "45.00", "2020-5-6", sales), + (d_small, "15.00", "2020-5-10", purchases_returns), + (cash, "210.00", "2020-5-11", sales), + (purchases, "150.00", "2020-5-12", cash), + (sales_returns, "16.00", "2020-5-19", m_spencer), + (cash, "175", "2020-5-21", sales), + (d_small, "205", "2020-5-22", cash), + (cash, "60", "2020-5-30", d_hughes), + (purchases, "214", "2020-5-31", a_lyon) + ]] + # fmt: on + + return ledger diff --git a/general_ledger/tests/book/data_chap5.py b/general_ledger/tests/book/data_chap5.py new file mode 100644 index 0000000..ebaf41a --- /dev/null +++ b/general_ledger/tests/book/data_chap5.py @@ -0,0 +1,329 @@ +import pandas as pd +from rich.console import Console + +from general_ledger.builders.transaction import TransactionBuilder +from general_ledger.factories import BookFactory +from general_ledger.helpers.ledger_helper import LedgerHelper +from general_ledger.django.models.account import Account +from general_ledger.django.models.direction import Direction +from general_ledger.utils.utility import slugu +from general_ledger.utils.data_loader import tx + +# from rich import print + +console = Console() + + +def load_chapter_5_data(): + """ + accounts for debtors + """ + book = BookFactory() + ledger = book.get_default_ledger() + coa = book.get_default_coa() + + # fmt: off + bank, cash, purchases, purchases_returns, sales, k_tandy, c_lee, k_wood, \ + d_knight, b_walters, e_williams, k_patterson = [ + coa.get_or_create(*args) + for k, (args) in { + "bank": ["Bank Account", "bank"], + "cash": ["Cash", "cash", "no-vat"], + "purchases": ["Purchases", "direct-costs"], + "purchases_returns": ["Purchases Returns", "current-liability", "no-vat"], + "sales": ["Sales", "sales", "20-vat-on-income"], + "k_tandy": ["K Tandy", "accounts-receivable", "no-vat"], + "c_lee": ["C Lee", "accounts-receivable", "no-vat"], + "k_wood": ["K Wood", "accounts-receivable", "no-vat"], + "d_knight": ["D Knight", "accounts-receivable", "no-vat"], + "b_walters": ["B Walters", "accounts-receivable", "no-vat"], + "e_williams": ["E Williams", "accounts-payable", "no-vat"], + "k_patterson": ["K Patterson", "accounts-payable", "no-vat"], + }.items() + ] + # fmt: on + + # fmt: off + # debit acct, amount, date, credit account + txs = [tx(ledger, dr,amt,dt,cr) for dr,amt,dt,cr in [ + (k_tandy, "144", "2012-08-01", sales), + (d_knight, "158", "2012-08-01", sales), + (purchases, "248", "2012-08-02", e_williams), + (k_wood, "214", "2012-08-06", sales), + (purchases, "620", "2012-08-08", k_patterson), + (c_lee, "177", "2012-08-11", sales), + (k_patterson, "20", "2012-08-14", purchases_returns), + (d_knight, "206", "2012-08-15", sales), + (purchases, "200", "2012-08-15", k_patterson), + (b_walters, "51", "2012-08-18", sales), + (purchases, "116", "2012-08-18", e_williams), + (k_tandy, "300", "2012-08-19", sales), + (c_lee, "203", "2012-08-19", sales), + (e_williams, "100", "2012-08-21", bank), + (bank, "144", "2012-08-22", k_tandy), + (c_lee, "100", "2012-08-22", sales), + (bank, "158", "2012-08-28", d_knight), + (bank, "300", "2012-08-28", k_tandy), + (k_patterson, "600", "2012-08-28", bank), + (bank, "480", "2012-08-30", c_lee), + (bank, "214", "2012-08-30", k_wood), + (d_knight, "118", "2012-08-30", sales), + ]] + # fmt: on + return ledger + + +def load_5_review_5_1_data(): + """ + accounts and transaction for review question 5.1 + """ + book = BookFactory() + ledger = book.get_default_ledger() + coa = book.get_default_coa() + # fmt: off + bank, cash, purchases, purchases_returns, sales, sales_returns, b_flynn, f_start, \ + f_lane, t_fey = [ + coa.get_or_create(*args) + for k, (args) in { + "bank": ["Bank Account", "bank"], + "cash": ["Cash", "cash", "no-vat"], + "purchases": ["Purchases", "direct-costs"], + "purchases_returns": ["Purchases Returns", "current-liability", "no-vat"], + "sales": ["Sales", "sales"], + "sales_returns": ["Sales Returns", "current-asset", "no-vat"], + "b_flynn": ["B Flynn", "accounts-receivable", "no-vat"], + "f_start": ["F Start", "accounts-receivable", "no-vat"], + "f_lane": ["F Lane", "accounts-receivable", "no-vat"], + "t_fey": ["T Fey", "accounts-receivable", "no-vat"], + }.items() + ] + # fmt: on + + transactions_sales = [ + ( + b_flynn, + "810.00", + "2019-5-1", + ), + ( + f_lane, + "1100.00", + "2019-5-1", + ), + ( + t_fey, + "413.00", + "2019-5-1", + ), + ( + f_start, + "480.00", + "2019-5-4", + ), + ( + b_flynn, + "134.00", + "2019-5-4", + ), + ( + f_start, + "240.00", + "2019-5-31", + ), + ] + + transactions_returns_inward = [ + ( + b_flynn, + "124.00", + "2019-5-10", + ), + ( + t_fey, + "62.00", + "2019-5-10", + ), + ] + + transactions_payments_received = [ + ( + f_lane, + "1100.00", + "2019-5-18", + bank, + ), + ( + t_fey, + "351.00", + "2019-5-20", + bank, + ), + ( + b_flynn, + "440.00", + "2019-5-24", + cash, + ), + ] + + for entry in transactions_sales: + LedgerHelper.post_transaction( + ledger, + f"Sales on-time to {entry[0].name}", + entry[2], + [ + (entry[0], entry[1], Direction.DEBIT), + (sales, entry[1], Direction.CREDIT), + ], + ) + + for entry in transactions_returns_inward: + LedgerHelper.post_transaction( + ledger, + f"Returns inward from {entry[0].name}", + entry[2], + [ + (entry[0], entry[1], Direction.CREDIT), + (sales_returns, entry[1], Direction.DEBIT), + ], + ) + + for entry in transactions_payments_received: + LedgerHelper.post_transaction( + ledger, + f"Payments received from {entry[0].name}", + entry[2], + [ + (entry[0], entry[1], Direction.CREDIT), + (entry[3], entry[1], Direction.DEBIT), + ], + ) + + return ledger + + +def load_5_review_5_2_data(): + """ + accounts and transaction for review question 5.2 + """ + book = BookFactory() + ledger = book.get_default_ledger() + coa = book.get_default_coa() + bank = Account.objects.get(name="Bank Account", coa=coa) + cash = Account.objects.get(name="Cash", coa=coa) + purchases, _ = coa.account_set.get_or_create( + name="Purchases", + type__slug="direct-costs", + ) + purchases_returns = coa.get_or_create( + "Purchases Returns", "current-liability", "no-vat" + ) + sales = coa.get_or_create("Sales", "sales") + sales_returns = coa.get_or_create("Sales Returns", "current-asset", "no-vat") + j_wilson, _ = coa.account_set.get_or_create( + name="J Wilson", + type=book.accounttype_set.get(slug="accounts-payable"), + tax_rate=book.taxrate_set.get(slug="no-vat"), + ) + p_todd, _ = coa.account_set.get_or_create( + name="P Todd", + type=book.accounttype_set.get(slug="accounts-payable"), + tax_rate=book.taxrate_set.get(slug="no-vat"), + ) + j_fry, _ = coa.account_set.get_or_create( + name="J Fry", + type=book.accounttype_set.get(slug="accounts-payable"), + tax_rate=book.taxrate_set.get(slug="no-vat"), + ) + p_rake, _ = coa.account_set.get_or_create( + name="P Rake", + type=book.accounttype_set.get(slug="accounts-payable"), + tax_rate=book.taxrate_set.get(slug="no-vat"), + ) + + transactions = [ + (purchases, "240.00", "2019-6-1", j_wilson), + (purchases, "390.00", "2019-6-1", p_todd), + (purchases, "1620.00", "2019-6-1", j_fry), + (purchases, "470.00", "2019-6-3", p_todd), + (purchases, "290.00", "2019-6-3", p_rake), + (purchases, "210.00", "2019-6-15", j_wilson), + (j_fry, "140.00", "2019-6-10", purchases_returns), + (j_wilson, "65.00", "2019-6-10", purchases_returns), + (p_todd, "39.00", "2019-6-30", purchases_returns), + (p_rake, "290.00", "2019-6-19", cash), + (j_wilson, "300.00", "2019-6-28", cash), + ] + + for entry in transactions: + LedgerHelper.post_transaction( + ledger, + f"review transactions {entry[0].name}", + entry[2], + [ + (entry[0], entry[1], Direction.DEBIT), + (entry[3], entry[1], Direction.CREDIT), + ], + ) + + return ledger + + +def load_5_review_5_5_data(): + """ + accounts and transaction for review question 5.2 + """ + book = BookFactory() + ledger = book.get_default_ledger() + coa = book.get_default_coa() + df = pd.read_csv("general_ledger/tests/book/chap_5_review_5_5_data.csv") + # inspect(df) + accts = {} + for index, row in df.iterrows(): + # print(row["name"], row["type_slug"], row["tax_rate_slug"]) + # print(Account.objects.filter(name=row["name"], coa=coa)) + accts[slugu(row["name"])] = coa.get_or_create( + row["name"], row["type_slug"], row["tax_rate_slug"] + ) + + # inspect(accts) + + transactions = [ + ( + accts["j_bee"], + "1040.00", + "2019-9-1", + accts["sales"], + ), + (accts["t_day"], "1260.00", "2019-9-1", accts["sales"]), + (accts["j_soul"], "480", "2019-9-1", accts["sales"]), + (accts["purchases"], "780", "2019-9-2", accts["d_blue"]), + (accts["purchases"], "1020", "2019-9-2", accts["f_rise"]), + (accts["purchases"], "560", "2019-9-2", accts["p_lee"]), + (accts["t_day"], "340", "2019-9-8", accts["sales"]), + (accts["l_hope"], "480", "2019-9-8", accts["sales"]), + (accts["purchases"], "92", "2019-9-10", accts["f_rise"]), + (accts["purchases"], "870", "2019-9-10", accts["r_james"]), + (accts["sales_returns"], "25", "2019-9-12", accts["j_soul"]), + (accts["sales_returns"], "190", "2019-9-12", accts["t_day"]), + (accts["purchases_returns"], "12", "2019-9-17", accts["f_rise"]), + (accts["purchases_returns"], "84", "2019-9-17", accts["r_james"]), + (accts["d_blue"], "780", "2019-9-20", accts["bank"]), + (accts["bank"], "900", "2019-9-24", accts["j_bee"]), + (accts["r_james"], "766", "2019-9-26", accts["bank"]), + (accts["bank"], "80", "2019-9-28", accts["j_bee"]), + (accts["bank"], "480", "2019-9-30", accts["l_hope"]), + ] + + for entry in transactions: + LedgerHelper.post_transaction( + ledger, + f"review transactions {entry[0].name}", + entry[2], + [ + (entry[0], entry[1], Direction.DEBIT), + (entry[3], entry[1], Direction.CREDIT), + ], + ) + + return ledger diff --git a/general_ledger/tests/book/data_chap6.py b/general_ledger/tests/book/data_chap6.py new file mode 100644 index 0000000..bcba8e1 --- /dev/null +++ b/general_ledger/tests/book/data_chap6.py @@ -0,0 +1,170 @@ +from general_ledger.factories import BookFactory +from general_ledger.tests.book.test_chap3 import load_chapter_3_data +from general_ledger.utils.data_loader import tx + + +def load_chapter_6_data(): + + ledger = load_chapter_3_data() + return ledger + + +def load_chapter_6_review_6_1_data(): + book = BookFactory(name="Darron") + ledger = book.get_default_ledger() + coa = book.get_default_coa() + + # fmt: off + purchases, purchases_returns, sales, sales_returns, capital, bank, cash, drawings, loan, machinery, advertising, m_ball, n_chadwick,j_vaughan, electricity = [ + coa.get_or_create(*args) + for k, (args) in { + "purchases": ["Purchases", "direct-costs"], + "purchases_returns": ["Purchases Returns", "current-liability", "no-vat"], + "sales": ["Sales", "sales", "20-vat-on-income"], + "sales_returns": ["Sales Returns", "current-asset", "20-vat-on-income"], + "capital": ["Capital", "equity", "no-vat"], + "bank_account": ["Bank Account", "bank"], + "cash": ["Cash", "cash", "no-vat"], + "drawings": ["Drawings", "equity", "no-vat"], + "loan_account": ["Loan Account", "non-current-liability"], + "machinery": ["Machinery", "non-current-asset", "no-vat"], + "advertising": ["Advertising", "expense", "20-vat-on-expenses"], + "m_ball": ["M Ball", "accounts-payable", "no-vat"], + "n_chadwick": ["N Chadwick", "accounts-receivable", "no-vat"], + "j_vaughan": ["j Vaughan", "accounts-receivable", "no-vat"], + "electricity": ["Electricity", "expense", "20-vat-on-expenses"], + }.items() + ] + # fmt: on + + + # fmt: off + # debit acct, amount, date, credit account + txs = [tx(ledger, dr, amt, dt, cr) for dr, amt, dt, cr in [ + (bank, "800", "2020-05-1", capital), + (bank, "2000", "2020-5-3", loan), + (machinery, "2500", "2020-5-5", bank), + (advertising, "75", "2020-5-7", bank), + (purchases, "200", "2020-5-9", bank), + (purchases, "700", "2020-5-11", m_ball), + (bank, "380", "2020-5-13", sales), + (n_chadwick, "470", "2020-5-15", sales), + (j_vaughan, "550", "2020-5-17", sales), + (drawings, "110", "2020-5-19", bank), + (sales_returns, "60", "2020-5-21", j_vaughan), + (m_ball, "300", "2020-5-23", bank), + (bank, "170", "2020-5-25", n_chadwick), + (electricity, "145", "2020-5-31", bank), + ]] + # fmt: on + + return ledger + + +def load_chapter_6_review_6_2_data(): + book = BookFactory(name="Nicola Burt") + ledger = book.get_default_ledger() + coa = book.get_default_coa() + + # fmt: off + purchases, purchases_returns, sales, capital, bank, cash, drawings, loan, machinery, computer_equipment, d_bellini, j_adams, tvc_ltd, g_plover, wages = [ + coa.get_or_create(*args) + for k, (args) in { + "purchases": ["Purchases", "direct-costs"], + "purchases_returns": ["Purchases Returns", "current-liability", "no-vat"], + "sales": ["Sales", "sales"], + "capital": ["Capital", "equity", "no-vat"], + "bank": ["Bank Account", "bank"], + "cash": ["Cash", "cash", "no-vat"], + "drawings": ["Drawings", "equity", "no-vat"], + "loan": ["Loan Account", "non-current-liability"], + "machinery": ["Machinery", "non-current-asset", "no-vat"], + "computer_equipment": ["Computer Equipment", "non-current-asset", "no-vat"], + "d_bellini": ["D Bellini", "accounts-payable", "no-vat"], + "j_adams": ["J Adams", "accounts-receivable", "no-vat"], + "tvc_ltd": ["TVC Ltd", "accounts-payable", "no-vat"], + "g_plover": ["G Plover", "non-current-liability", "no-vat"], + "wages": ["Wages", "expense", "no-vat"], + }.items() + ] + # fmt: on + + # fmt: off + # debit acct, amount, date, credit account + txs = [tx(ledger, dr,amt,dt,cr) for dr,amt,dt,cr in [ + (cash, "3850", "2023-08-01", capital), + (bank, "3500", "2023-08-02", cash), + (purchases, "414", "2023-08-04", d_bellini), + (machinery, "2500", "2023-08-05", bank), + (purchases, "323", "2023-08-07", cash), + (j_adams, "595", "2023-08-10", sales), + (drawings, "98", "2023-08-11", purchases), + (d_bellini, "70", "2023-08-12", purchases_returns), + (cash, "328", "2023-08-19", sales), + (computer_equipment, "1450", "2023-08-22", tvc_ltd), + (bank, "2000", "2023-08-24", loan), + (d_bellini, "180", "2023-08-29", bank), + (wages, "530", "2023-08-30", bank), + (tvc_ltd, "1450", "2023-08-31", bank), + ]] + # fmt: on + + return ledger + + +def load_chapter_6_review_6_5_data(): + + book = BookFactory(name="M Donnelly") + ledger = book.get_default_ledger() + coa = book.get_default_coa() + + # fmt: off + ( + purchases, purchases_returns, sales, sales_returns, capital, bank, cash, drawings, machinery, insurance, p_thomas, m_wilkinson, e_grant, e_williams, m_donnelly, wages, + ) = [ + coa.get_or_create(*args) + for k, (args) in { + "purchases": ["Purchases", "direct-costs"], + "purchases_returns": ["Purchases Returns", "current-liability", "no-vat"], + "sales": ["Sales", "sales"], + "sales_returns": ["Sales Returns", "current-asset", "20-vat-on-income"], + "capital": ["Capital", "equity", "no-vat"], + "bank": ["Bank Account", "bank"], + "cash": ["Cash", "cash", "no-vat"], + "drawings": ["Drawings", "equity", "no-vat"], + "machinery": ["Machinery", "non-current-asset", "no-vat"], + "insurance": ["Insurance", "overhead", "no-vat"], + "p_thomas": ["P Thomas", "accounts-payable", "no-vat"], + "m_wilkinson": ["M Wilkinson", "accounts-payable", "no-vat"], + "e_grant": ["E Grant", "accounts-receivable", "no-vat"], + "e_williams": ["E Williams", "accounts-receivable", "no-vat"], + "m_donnelly": ["M Donnelly", "accounts-payable", "no-vat"], + "wages": ["Wages", "expense", "no-vat"], + }.items() + ] + # fmt: on + + # fmt: off + # debit acct, amount, date, credit account + txs = [ + tx(ledger, dr, amt, dt, cr) + for dr, amt, dt, cr in [ + (cash, "500", "2023-04-01", capital), + (bank, "3000", "2023-04-01", capital), + (purchases, "475", "2023-04-05", p_thomas), + (machinery, "1450", "2023-04-06", bank), + (insurance, "120", "2023-04-07", bank), + (purchases, "255", "2023-04-09", m_wilkinson), + (e_grant, "700", "2023-04-12", sales), + (cash, "300", "2023-04-15", sales), + (p_thomas, "475", "2023-04-20", bank), + (m_wilkinson, "50", "2023-04-22", purchases_returns), + (e_williams, "325", "2023-04-24", sales), + (wages, "45", "2023-04-25", bank), + (sales_returns, "80", "2023-04-27", e_grant), + (drawings, "80", "2023-04-30", cash), + ] + ] + # fmt: on + + return ledger diff --git a/general_ledger/tests/book/data_chap7.py b/general_ledger/tests/book/data_chap7.py new file mode 100644 index 0000000..c12de6a --- /dev/null +++ b/general_ledger/tests/book/data_chap7.py @@ -0,0 +1,267 @@ +from general_ledger.factories import BookFactory +from general_ledger.utils.data_loader import tx + + +def load_chapter_7_exhibit_7_1(): + book = BookFactory(name="B Swift") + ledger = book.get_default_ledger() + coa = book.get_default_coa() + + # fmt: off + sales, purchases, rent, lighting, general_expenses, fixtures, trade_receivables, trade_payables, bank, cash, drawings, capital, opening_balances, inventory = [ + coa.get_or_create(*args) + for k, (args) in { + "sales": ["Sales", "sales"], + "purchases": ["Purchases", "purchases", "no-vat"], + "rent": ["Rent", "overhead", "no-vat"], + "lighting": ["Lighting Expenses", "overhead", "no-vat"], + "general_expenses": ["General Expenses", "overhead", "no-vat"], + "fixtures": ["Fixtures and Fittings", "non-current-asset", "20-vat-on-expenses"], + "trade_receivables": ["Trade Receivables", "accounts-receivable", "no-vat"], + "trade_payables": ["Trade Payables", "accounts-payable", "no-vat"], + "bank": ["Bank Account", "bank"], + "cash": ["Cash", "cash", "no-vat"], + "drawings": ["Drawings", "equity", "no-vat"], + "capital": ["Capital", "equity", "no-vat"], + "opening_balances": ["Opening Balances", "opening-balances", "no-vat"], + "inventory": ["Inventory", "inventory", "no-vat"], + }.items() + ] + # fmt: on + + # fmt: off + # debit acct, amount, date, credit account + txs = [tx(ledger, dr, amt, dt, cr) for dr, amt,dt, cr in [ + (opening_balances, "38500", "2019-01-01", sales), + (general_expenses, "600", "2019-01-01", opening_balances), + (drawings, "7000", "2019-01-01", opening_balances), + (fixtures, "5000", "2019-01-01", opening_balances), + (trade_receivables, "6800", "2019-01-01", opening_balances), + (opening_balances, "9100", "2019-01-01", trade_payables), + (bank, "15100", "2019-01-01", opening_balances), + (cash, "200", "2019-01-01", opening_balances), + (opening_balances, "20000", "2019-01-01", capital), + (lighting, "1500", "2019-01-01", opening_balances), + (purchases, "29000", "2019-01-01", opening_balances), + (rent, "2400", "2019-01-01", opening_balances), + # (inventory, "3000", "2019-01-01", opening_balances), + ]] + # fmt: on + + return ledger + + +def load_chapter_7_activity_7_4(): + book = BookFactory() + ledger = book.get_default_ledger() + coa = book.get_default_coa() + + +def load_chapter_7_exhibit_7_3(): + book = BookFactory() + ledger = book.get_default_ledger() + coa = book.get_default_coa() + + # fmt: off + fixtures, trade_receivables, trade_payables, bank, cash, drawings, capital, opening_balances, inventory = [ + coa.get_or_create(*args) + for k, (args) in { + "fixtures": ["Fixtures and Fittings", "non-current-asset", "20-vat-on-expenses"], + "trade_receivables": ["Trade Receivables", "accounts-receivable", "no-vat"], + "trade_payables": ["Trade Payables", "accounts-payable", "no-vat"], + "bank": ["Bank Account", "bank"], + "cash": ["Cash", "cash", "no-vat"], + "drawings": ["Drawings", "equity", "no-vat"], + "capital": ["Capital", "equity", "no-vat"], + "opening_balances": ["Opening Balances", "opening-balances", "no-vat"], + "inventory": ["Inventory", "inventory", "no-vat"], + }.items() + ] + # fmt: on + + # fmt: off + # debit acct, amount, date, credit account + txs = [tx(ledger, dr, amt, dt, cr) for dr, amt, dt,cr in [ + (fixtures, "5000", "2019-01-01", opening_balances), + (trade_receivables, "6800", "2019-01-01", opening_balances), + (opening_balances, "9100", "2019-01-01", trade_payables), + (bank, "15100", "2019-01-01", opening_balances), + (cash, "200", "2019-01-01", opening_balances), + (opening_balances, "21000", "2019-01-01", capital), + (inventory, "3000", "2019-01-01", opening_balances), + ]] + # fmt: on + + return ledger + + +def load_chapter_7_review_7_1(): + + book = BookFactory() + ledger = book.get_default_ledger() + coa = book.get_default_coa() + + # fmt: off + purchases, sales, salaries, motor_expenses, rent, insurance, general_expenses, premises, \ + motor_vehicles, trade_receivables, trade_payables, bank, cash, drawings, capital, opening_balances, inventory = [ + coa.get_or_create(*args) + for k, (args) in { + "purchases": ["Purchases", "purchases"], + "sales": ["Sales", "sales"], + "salaries": ["Salaries", "overhead", "no-vat"], + "motor_expenses": ["Motor Expenses", "overhead", "no-vat"], + "rent": ["Rent", "overhead", "no-vat"], + "insurance": ["Insurance", "overhead", "no-vat"], + "general_expenses": ["General Expenses", "overhead", "no-vat"], + "premises": ["Premises", "non-current-asset", "no-vat"], + "motor_vehicles": ["Motor Vehicles", "non-current-asset", "no-vat"], + "trade_receivables": ["Trade Receivables", "accounts-receivable", "no-vat"], + "trade_payables": ["Trade Payables", "accounts-payable", "no-vat"], + "bank": ["Bank Account", "bank"], + "cash": ["Cash", "cash", "no-vat"], + "drawings": ["Drawings", "equity", "no-vat"], + "capital": ["Capital", "equity", "no-vat"], + "opening_balances": ["Opening Balances", "opening-balances", "no-vat"], + "inventory": ["Inventory", "inventory", "no-vat"], + }.items() + ] + # fmt: on + + # fmt: off + # debit acct, amount, date, credit account + txs = [tx(ledger, dr,amt,dt,cr) for dr,amt,dt,cr in [ + (purchases, "60400", "2023-10-31", opening_balances), + (salaries, "29300", "2023-10-31", opening_balances), + (motor_expenses, "1200", "2023-10-31", opening_balances), + (rent, "950", "2023-10-31", opening_balances), + (insurance, "150", "2023-10-31", opening_balances), + (general_expenses, "85", "2023-10-31", opening_balances), + (premises, "47800", "2023-10-31", opening_balances), + (motor_vehicles, "8600", "2023-10-31", opening_balances), + (trade_receivables, "13400", "2023-10-31", opening_balances), + (bank, "8200", "2023-10-31", opening_balances), + (cash, "300", "2023-10-31", opening_balances), + (drawings, "4200", "2023-10-31", opening_balances), + (opening_balances, "100250", "2023-10-31", sales), + (opening_balances, "8800", "2023-10-31", trade_payables), + (opening_balances, "65535", "2023-10-31", capital), + (inventory, "15600", "2023-10-31", opening_balances), + ]] + # fmt: on + + return ledger + + +def load_chapter_7_review_7_2(): + + book = BookFactory() + ledger = book.get_default_ledger() + coa = book.get_default_coa() + + # fmt: off + purchases, sales, rent, lighting_heating, salaries_wages, insurance, buildings, \ + fixtures, trade_receivables, sundry_expenses, trade_payables, bank, drawings, \ + vans, motor_expenses, capital, opening_balances, inventory = [ + coa.get_or_create(*args) + for k, (args) in { + "purchases": ["Purchases", "purchases"], + "sales": ["Sales", "sales"], + "rent": ["Rent", "overhead", "no-vat"], + "lighting_heating": ["Lighting and Heating", "overhead", "no-vat"], + "salaries_wages": ["Salaries and Wages", "overhead", "no-vat"], + "insurance": ["Insurance", "overhead", "no-vat"], + "buildings": ["Buildings", "non-current-asset", "no-vat"], + "fixtures": ["Fixtures", "non-current-asset", "no-vat"], + "trade_receivables": ["Trade Receivables", "accounts-receivable", "no-vat"], + "sundry_expenses": ["Sundry Expenses", "overhead", "no-vat"], + "trade_payables": ["Trade Payables", "accounts-payable", "no-vat"], + "bank": ["Bank Account", "bank"], + "drawings": ["Drawings", "equity", "no-vat"], + "vans": ["Vans", "non-current-asset", "no-vat"], + "motor_expenses": ["Motor Running Expenses", "overhead", "no-vat"], + "capital": ["Capital", "equity", "no-vat"], + "opening_balances": ["Opening Balances", "equity", "no-vat"], + "inventory": ["Inventory", "inventory", "no-vat"], + }.items() + ] + # fmt: on + + # fmt: off + # debit acct, amount, date, credit account + txs = [tx(ledger, dr,amt,dt,cr) for dr,amt,dt,cr in [ + (purchases, "154000", "2024-06-30", opening_balances), + (rent, "3800", "2024-06-30", opening_balances), + (lighting_heating, "700", "2024-06-30", opening_balances), + (salaries_wages, "52000", "2024-06-30", opening_balances), + (insurance, "3000", "2024-06-30", opening_balances), + (buildings, "84800", "2024-06-30", opening_balances), + (fixtures, "2000", "2024-06-30", opening_balances), + (trade_receivables, "31200", "2024-06-30", opening_balances), + (sundry_expenses, "300", "2024-06-30", opening_balances), + (bank, "15000", "2024-06-30", opening_balances), + (drawings, "28600", "2024-06-30", opening_balances), + (vans, "16000", "2024-06-30", opening_balances), + (motor_expenses, "4600", "2024-06-30", opening_balances), + (opening_balances, "266000", "2024-06-30", sales), + (opening_balances, "16000", "2024-06-30", trade_payables), + (opening_balances, "114000", "2024-06-30", capital), + (inventory, "18000", "2024-06-30", opening_balances), + ]] + # fmt: on + + return ledger + + +def load_chapter_7_review_7_5(): + + book = BookFactory() + ledger = book.get_default_ledger() + coa = book.get_default_coa() + # fmt: off + bank, capital, loan, van_hire, it_equipment, purchases, \ + j_collins, e_barrett, m_pembridge, drawings, wages, inventory, \ + purchases_returns, sales, opening_balances = [ + coa.get_or_create(*args) + for k, (args) in { + "bank": ["Bank Account", "bank"], + "capital": ["Capital", "equity", "no-vat"], + "loan": ["LloydWest Bank Loan", "non-current-liability", "no-vat"], + "van_hire": ["Van Hire", "overhead", "no-vat"], + "it_equipment": ["IT Equipment", "non-current-asset", "no-vat"], + "purchases": ["Purchases", "purchases"], + "j_collins": ["J Collins", "accounts-payable", "no-vat"], + "e_barrett": ["E Barrett", "accounts-receivable", "no-vat"], + "m_pembridge": ["M Pembridge", "accounts-payable", "no-vat"], + "drawings": ["Drawings", "equity", "no-vat"], + "wages": ["Wages", "overhead", "no-vat"], + "inventory": ["Inventory", "inventory", "no-vat"], + "purchases_returns": ["Purchases Returns", "purchases-returns", "no-vat"], + "sales": ["Sales", "sales"], + "opening_balances": ["Opening Balances", "equity", "no-vat"], + }.items() + ] + # fmt: on + + # fmt: off + # debit acct, amount, date, credit account + txs = [tx(ledger, dr,amt,dt,cr) for dr,amt,dt,cr in [ + (bank, "750", "2023-09-01", capital), + (bank, "3000", "2023-09-03", loan), + (van_hire, "320", "2023-09-05", bank), + (it_equipment, "2200", "2023-09-07", bank), + (purchases, "760", "2023-09-09", bank), + (purchases, "570", "2023-09-11", j_collins), + (bank, "930", "2023-09-13", sales), + (j_collins, "120", "2023-09-15", purchases_returns), + (purchases, "890", "2023-09-17", m_pembridge), + (e_barrett, "1770", "2023-09-19", sales), + (j_collins, "450", "2023-09-21", bank), + (bank, "590", "2023-09-23", e_barrett), + (drawings, "280", "2023-09-25", bank), + (wages, "410", "2023-09-27", bank), + # applied in the calculation of the trading account + # (inventory, "570", "2023-09-30", opening_balances), + ]] + # fmt: on + + return ledger diff --git a/general_ledger/tests/book/data_chap8.py b/general_ledger/tests/book/data_chap8.py new file mode 100644 index 0000000..5ece908 --- /dev/null +++ b/general_ledger/tests/book/data_chap8.py @@ -0,0 +1,6 @@ +from general_ledger.tests.book.data_chap7 import load_chapter_7_exhibit_7_3 + + +def load_chapter_8_exhibit_8_1(): + + return load_chapter_7_exhibit_7_3() diff --git a/general_ledger/tests/book/test_chap2.py b/general_ledger/tests/book/test_chap2.py index 11d201c..b6b9b9d 100644 --- a/general_ledger/tests/book/test_chap2.py +++ b/general_ledger/tests/book/test_chap2.py @@ -1,21 +1,19 @@ import logging -from general_ledger import constants +from general_ledger.builders.transaction import TransactionBuilder +from general_ledger.builders.account_summary_builder import AccountSummaryBuilder from general_ledger.factories import BookFactory -from general_ledger.helpers import LedgerHelper -from general_ledger.models import ( +from general_ledger.helpers.ledger_helper import LedgerHelper +from general_ledger.django.models import ( Transaction, Account, Ledger, Direction, - AccountType, - TaxRate, ) from general_ledger.tests import GeneralLedgerBaseTest - -from general_ledger.builders import TransactionBuilder -from general_ledger.utils.account_balanced import AccountBalancer -from general_ledger.utils.consoler import pr_account_balanced +from general_ledger.render.consoler import pr_account_balanced +from general_ledger.utils.data_loader import tx as post_tx +from django.utils import timezone # Create your tests here. @@ -59,21 +57,27 @@ def test_something_else1(self): print(ledger) lh = LedgerHelper(ledger) - tx = lh.build_transaction( - description="Test Transaction", - entries=[ - { - "account": "102", - "amount": 100, - "tx_type": constants.TxType.CREDIT, - }, - { - "account": "103", - "amount": 100, - "tx_type": constants.TxType.DEBIT, - }, - ], + # tx(ledger, + + account_to_credit = Account.objects.get( + code="102", + coa=ledger.coa, + ) + + account_to_debit = Account.objects.get( + code="103", + coa=ledger.coa, ) + + tx = post_tx( + ledger, + account_to_debit, + 100, + timezone.now(), + account_to_credit, + "Test Transaction", + ) + print(tx) self.assertIsInstance(tx, Transaction) self.assertEqual(tx.description, "Test Transaction") @@ -160,6 +164,7 @@ def test_worked_example_1(self): tb4.add_entry(cash, 1_250, Direction.CREDIT) tx4 = tb4.build() tx4.post() + self.assertEqual(LedgerHelper.get_account_balance(cash), 4_250) self.assertIsInstance(tx1, Transaction) @@ -176,11 +181,13 @@ def test_worked_example_1(self): # self.assertTrue(False) lh = LedgerHelper(ledger) self.logger.info(lh.get_account_summary()) - - cash_balanced = AccountBalancer( - account=cash, - ledger=ledger, + cash_balanced = ( + AccountSummaryBuilder(strict_dates=False) + .with_account(cash) + .with_ledger(ledger) + .build() ) + cash_balanced.balance_off() # inspect(test) - print(pr_account_balanced(cash_balanced.grouped_entries)) + print(pr_account_balanced(cash_balanced.entries_grouped)) diff --git a/general_ledger/tests/book/test_chap3.py b/general_ledger/tests/book/test_chap3.py index 07fba16..d9db57e 100644 --- a/general_ledger/tests/book/test_chap3.py +++ b/general_ledger/tests/book/test_chap3.py @@ -1,172 +1,16 @@ import logging + import pytest -from general_ledger import constants + from general_ledger.factories import BookFactory, LedgerFactory -from general_ledger.helpers import LedgerHelper -from general_ledger.models import ( +from general_ledger.helpers.ledger_helper import LedgerHelper +from general_ledger.django.models import ( Transaction, Account, - Ledger, - Direction, - AccountType, - TaxRate, Entry, ) -from general_ledger.tests import GeneralLedgerBaseTest - -from general_ledger.builders import TransactionBuilder - - -def load_chapter_3_data(): - """ - 1. Started a household machines business putting £2 ,000 into a - bank account. - 2. Bought equipment on time from house supplies £12,000. - - - """ - book = BookFactory(name="B. Swift") - ledger = book.get_default_ledger() - coa = book.get_default_coa() - # cash = Account.objects.get(name="Cash", ledger=ledger) - # capital = Account.objects.get(name="Capital", ledger=ledger) - purchases, _ = coa.account_set.get_or_create( - name="Purchases", - type__slug="direct-costs", - ) - - sales_returns, _ = coa.account_set.get_or_create( - name="Sales Returns", - type=book.accounttype_set.get(slug="current-asset"), - tax_rate=book.taxrate_set.get(slug="20-vat-on-income"), - ) - sales, _ = coa.account_set.get_or_create( - name="Sales", - type=book.accounttype_set.get(slug="sales"), - tax_rate=book.taxrate_set.get(slug="20-vat-on-income"), - ) - - purchases_returns, _ = coa.account_set.get_or_create( - name="Purchases Returns", - type=book.accounttype_set.get(slug="current-liability"), - tax_rate=book.taxrate_set.get(slug="no-vat"), - ) - - cash = Account.objects.get(name="Cash", coa=coa) - capital = Account.objects.get(name="Capital", coa=coa) - - d_small, _ = coa.account_set.get_or_create( - name="D Small", - type=book.accounttype_set.get(slug="current-liability"), - tax_rate=book.taxrate_set.get(slug="no-vat"), - ) - - a_lyon, _ = coa.account_set.get_or_create( - name="A Lyon & Son", - type=book.accounttype_set.get(slug="current-liability"), - tax_rate=book.taxrate_set.get(slug="no-vat"), - ) - - d_hughes, _ = coa.account_set.get_or_create( - name="D Hughes", - type=book.accounttype_set.get(slug="current-asset"), - tax_rate=book.taxrate_set.get(slug="no-vat"), - ) - - m_spencer, _ = coa.account_set.get_or_create( - name="M Spencer", - type=book.accounttype_set.get(slug="current-asset"), - tax_rate=book.taxrate_set.get(slug="no-vat"), - ) - - tb1 = TransactionBuilder(ledger=ledger, description="Test Transaction - 1") - tb1.set_trans_date("2013-05-1") - tb1.add_entry(purchases, 220, Direction.DEBIT) - tb1.add_entry(d_small, 220, Direction.CREDIT) - tx1 = tb1.build() - assert tx1.can_post() - tx1.post() - - tb2 = TransactionBuilder(ledger=ledger, description="Test Transaction - 2") - tb2.set_trans_date("2013-05-2") - tb2.add_entry(purchases, 410, Direction.DEBIT) - tb2.add_entry(a_lyon, 410, Direction.CREDIT) - tx2 = tb2.build() - tx2.post() - - tb3 = TransactionBuilder(ledger=ledger, description="Test Transaction - 3") - tb3.set_trans_date("2013-05-5") - tb3.add_entry(sales, 60, Direction.CREDIT) - tb3.add_entry(d_hughes, 60, Direction.DEBIT) - tx3 = tb3.build() - tx3.post() - # self.assertEqual(LedgerHelper.get_account_balance(cash), 5_500) - - tb4 = TransactionBuilder(ledger=ledger, description="Test Transaction - 4") - tb4.set_trans_date("2013-05-6") - tb4.add_entry(sales, 45, Direction.CREDIT) - tb4.add_entry(m_spencer, 45, Direction.DEBIT) - tx4 = tb4.build() - tx4.post() - # self.assertEqual(LedgerHelper.get_account_balance(cash), 4_250) - - tb5 = TransactionBuilder(ledger=ledger, description="Test Transaction - 5") - tb5.set_trans_date("2013-05-10") - tb5.add_entry(d_small, 15, Direction.DEBIT) - tb5.add_entry(purchases_returns, 15, Direction.CREDIT) - tx5 = tb5.build() - tx5.post() - - tb6 = TransactionBuilder(ledger=ledger, description="Test Transaction - 6") - tb6.set_trans_date("2013-05-11") - tb6.add_entry(cash, 210, Direction.DEBIT) - tb6.add_entry(sales, 210, Direction.CREDIT) - tx6 = tb6.build() - tx6.post() - - tb7 = TransactionBuilder(ledger=ledger, description="Test Transaction - 7") - tb7.set_trans_date("2013-05-12") - tb7.add_entry(purchases, 150, Direction.DEBIT) - tb7.add_entry(cash, 150, Direction.CREDIT) - tx7 = tb7.build() - tx7.post() - - tb8 = TransactionBuilder(ledger=ledger, description="Test Transaction - 8") - tb8.set_trans_date("2013-05-19") - tb8.add_entry(m_spencer, 16, Direction.CREDIT) - tb8.add_entry(sales_returns, 16, Direction.DEBIT) - tx8 = tb8.build() - tx8.post() - - tb9 = TransactionBuilder(ledger=ledger, description="Test Transaction - 8") - tb9.set_trans_date("2013-05-21") - tb9.add_entry(cash, 175, Direction.DEBIT) - tb9.add_entry(sales, 175, Direction.CREDIT) - tx9 = tb9.build() - tx9.post() - - tb10 = TransactionBuilder(ledger=ledger, description="Test Transaction - 10") - tb10.set_trans_date("2013-05-21") - tb10.add_entry(cash, 205, Direction.CREDIT) - tb10.add_entry(d_small, 205, Direction.DEBIT) - tx10 = tb10.build() - tx10.post() - - tb11 = TransactionBuilder(ledger=ledger, description="Test Transaction - 11") - tb11.set_trans_date("2013-05-30") - tb11.add_entry(cash, 60, Direction.DEBIT) - tb11.add_entry(d_hughes, 60, Direction.CREDIT) - tx11 = tb11.build() - tx11.post() - - tb12 = TransactionBuilder(ledger=ledger, description="Test Transaction - 12") - tb12.set_trans_date("2013-05-31") - tb12.add_entry(purchases, 214, Direction.DEBIT) - tb12.add_entry(a_lyon, 214, Direction.CREDIT) - tx12 = tb12.build() - tx12.post() - - return ledger +from general_ledger.tests.book.data_chap3 import load_chapter_3_data +from general_ledger.utils.data_loader import tx # Create your tests here. diff --git a/general_ledger/tests/book/test_chap4.py b/general_ledger/tests/book/test_chap4.py index e9dfc57..674b91a 100644 --- a/general_ledger/tests/book/test_chap4.py +++ b/general_ledger/tests/book/test_chap4.py @@ -1,9 +1,9 @@ import logging -from general_ledger.builders import TransactionBuilder +from general_ledger.builders.transaction import TransactionBuilder from general_ledger.factories import BookFactory -from general_ledger.helpers import LedgerHelper -from general_ledger.models import ( +from general_ledger.helpers.ledger_helper import LedgerHelper +from general_ledger.django.models import ( Account, Direction, ) @@ -134,7 +134,9 @@ def test_4_7(self): self.assertTrue(tx1.can_post()) tx1.post() - tb2 = TransactionBuilder(ledger=ledger, description="Owner draws from inventory") + tb2 = TransactionBuilder( + ledger=ledger, description="Owner draws from inventory" + ) tb2.set_trans_date("2024-06-28") tb2.add_entry(drawings, 400, Direction.DEBIT) tb2.add_entry(purchases, 400, Direction.CREDIT) diff --git a/general_ledger/tests/book/test_chap5.py b/general_ledger/tests/book/test_chap5.py index 062d362..7d2c642 100644 --- a/general_ledger/tests/book/test_chap5.py +++ b/general_ledger/tests/book/test_chap5.py @@ -1,247 +1,30 @@ -import datetime import logging +from decimal import Decimal -import pytest from django.template.loader import get_template -from rich import inspect -from rich.pretty import pprint - -from general_ledger.builders import TransactionBuilder -from general_ledger.factories import BookFactory -from general_ledger.factories import TransactionFactory, LedgerFactory -from general_ledger.helpers import LedgerHelper -from general_ledger.models import ( - Account, - Direction, -) +from rich.console import Console +from rich.panel import Panel +from rich.table import Table +from rich.text import Text + +from general_ledger.builders.account_summary_builder import AccountSummaryBuilder +from general_ledger.render.utility_rich import ConsoleReportBuilder from general_ledger.tests import GeneralLedgerBaseTest -from general_ledger.utils.account_balanced import AccountBalancer -from general_ledger.utils.consoler import pr_entry_set, pr_account_balanced - - -def load_chapter_5_data(): - """ - accounts for debtors - """ - book = BookFactory() - ledger = book.get_default_ledger() - coa = book.get_default_coa() - bank = Account.objects.get(name="Bank Account", coa=coa) - cash = Account.objects.get(name="Cash", coa=coa) - purchases, _ = coa.account_set.get_or_create( - name="Purchases", - type__slug="direct-costs", - ) - purchases_returns, _ = coa.account_set.get_or_create( - name="Purchases Returns", - type=book.accounttype_set.get(slug="current-liability"), - tax_rate=book.taxrate_set.get(slug="no-vat"), - ) - sales, _ = coa.account_set.get_or_create( - name="Sales", - type=book.accounttype_set.get(slug="sales"), - tax_rate=book.taxrate_set.get(slug="20-vat-on-income"), - ) - k_tandy, _ = coa.account_set.get_or_create( - name="K Tandy", - type=book.accounttype_set.get(slug="accounts-receivable"), - tax_rate=book.taxrate_set.get(slug="no-vat"), - ) - c_lee, _ = coa.account_set.get_or_create( - name="C Lee", - type=book.accounttype_set.get(slug="accounts-receivable"), - tax_rate=book.taxrate_set.get(slug="no-vat"), - ) - k_wood, _ = coa.account_set.get_or_create( - name="K Wood", - type=book.accounttype_set.get(slug="accounts-receivable"), - tax_rate=book.taxrate_set.get(slug="no-vat"), - ) - d_knight, _ = coa.account_set.get_or_create( - name="D Knight", - type=book.accounttype_set.get(slug="accounts-receivable"), - tax_rate=book.taxrate_set.get(slug="no-vat"), - ) - b_walters, _ = coa.account_set.get_or_create( - name="B Walters", - type=book.accounttype_set.get(slug="accounts-receivable"), - tax_rate=book.taxrate_set.get(slug="no-vat"), - ) - e_williams, _ = coa.account_set.get_or_create( - name="E Williams", - type=book.accounttype_set.get(slug="accounts-payable"), - tax_rate=book.taxrate_set.get(slug="no-vat"), - ) - k_patterson, _ = coa.account_set.get_or_create( - name="K Patterson", - type=book.accounttype_set.get(slug="accounts-payable"), - tax_rate=book.taxrate_set.get(slug="no-vat"), - ) - - tb = TransactionBuilder(ledger=ledger, description="sales to K tandy") - tb.set_trans_date("2012-08-1") - tb.add_entry(sales, 144, Direction.CREDIT) - tb.add_entry(k_tandy, 144, Direction.DEBIT) - tx = tb.build() - assert tx.can_post() - tx.post() - - tb = TransactionBuilder(ledger=ledger, description="sales to K tandy") - tb.set_trans_date("2012-08-19") - tb.add_entry(sales, 300, Direction.CREDIT) - tb.add_entry(k_tandy, 300, Direction.DEBIT) - tx = tb.build() - assert tx.can_post() - tx.post() - - tb = TransactionBuilder(ledger=ledger, description="payment by K tandy") - tb.set_trans_date("2012-08-22") - tb.add_entry(bank, 144, Direction.DEBIT) - tb.add_entry(k_tandy, 144, Direction.CREDIT) - tx = tb.build() - assert tx.can_post() - tx.post() - - tb = TransactionBuilder(ledger=ledger, description="payment by K tandy") - tb.set_trans_date("2012-08-28") - tb.add_entry(bank, 300, Direction.DEBIT) - tb.add_entry(k_tandy, 300, Direction.CREDIT) - tx = tb.build() - assert tx.can_post() - tx.post() - - tb = TransactionBuilder(ledger=ledger, description="sales to C Lee on credit") - tb.set_trans_date("2012-08-11") - tb.add_entry(sales, 177, Direction.CREDIT) - tb.add_entry(c_lee, 177, Direction.DEBIT) - tx = tb.build() - assert tx.can_post() - tx.post() - - tb = TransactionBuilder(ledger=ledger, description="sales to C Lee on credit") - tb.set_trans_date("2012-08-19") - tb.add_entry(sales, "203.00", Direction.CREDIT) - tb.add_entry(c_lee, "203.00", Direction.DEBIT) - tx = tb.build() - assert tx.can_post() - tx.post() - - tb = TransactionBuilder(ledger=ledger, description="sales to C Lee on credit") - tb.set_trans_date("2012-08-22") - tb.add_entry(sales, 100, Direction.CREDIT) - tb.add_entry(c_lee, 100, Direction.DEBIT) - tx = tb.build() - assert tx.can_post() - tx.post() - - tb = TransactionBuilder(ledger=ledger, description="payment by c lee") - tb.set_trans_date("2012-08-30") - tb.add_entry(bank, 480, Direction.DEBIT) - tb.add_entry(c_lee, 480, Direction.CREDIT) - tx = tb.build() - assert tx.can_post() - tx.post() - - tb = TransactionBuilder(ledger=ledger, description="sales to K wood on credit") - tb.set_trans_date("2012-08-6") - tb.add_entry(sales, 214, Direction.CREDIT) - tb.add_entry(k_wood, 214, Direction.DEBIT) - tx = tb.build() - assert tx.can_post() - tx.post() - - tb = TransactionBuilder(ledger=ledger, description="payment by k wood") - tb.set_trans_date("2012-08-30") - tb.add_entry(bank, 214, Direction.DEBIT) - tb.add_entry(k_wood, 214, Direction.CREDIT) - tx = tb.build() - assert tx.can_post() - tx.post() - - tb = TransactionBuilder(ledger=ledger, description="sales to d knight") - tb.set_trans_date("2012-08-1") - tb.add_entry(sales, 158, Direction.CREDIT) - tb.add_entry(d_knight, 158, Direction.DEBIT) - tx = tb.build() - tx.post() - - tb2 = TransactionBuilder(ledger=ledger, description="sales to d knight") - tb2.set_trans_date("2012-08-15") - tb2.add_entry(sales, 206, Direction.CREDIT) - tb2.add_entry(d_knight, 206, Direction.DEBIT) - tx2 = tb2.build() - tx2.post() - - tb2 = TransactionBuilder(ledger=ledger, description="sales to d knight") - tb2.set_trans_date("2012-08-30") - tb2.add_entry(sales, 118, Direction.CREDIT) - tb2.add_entry(d_knight, 118, Direction.DEBIT) - tx2 = tb2.build() - tx2.post() - - tb = TransactionBuilder(ledger=ledger, description="receive payment from k knight") - tb.set_trans_date("2012-08-28") - tb.add_entry(bank, 158, Direction.DEBIT) - tb.add_entry(d_knight, 158, Direction.CREDIT) - tx = tb.build() - tx.post() - - tb = TransactionBuilder(ledger=ledger, description="receive payment from k knight") - tb.set_trans_date("2012-08-18") - tb.add_entry(sales, 51, Direction.CREDIT) - tb.add_entry(b_walters, 51, Direction.DEBIT) - tx = tb.build() - tx.post() - - tb = TransactionBuilder(ledger=ledger, description="Purchases from E williams") - tb.set_trans_date("2012-08-2") - tb.add_entry(purchases, 248, Direction.DEBIT) - tb.add_entry(e_williams, 248, Direction.CREDIT) - tx = tb.build() - tx.post() - - TransactionBuilder( - ledger=ledger, description="Purchases from E williams" - ).set_trans_date("2012-08-18").add_entry(purchases, 116, Direction.DEBIT).add_entry( - e_williams, 116, Direction.CREDIT - ).build().post() - - tb = TransactionBuilder(ledger=ledger, description="Test Transaction - 12") - tb.set_trans_date("2012-08-21") - tb.add_entry(bank, 100, Direction.CREDIT) - tb.add_entry(e_williams, 100, Direction.DEBIT) - tx = tb.build() - tx.post() - - tb = TransactionBuilder(ledger=ledger, description="Test Transaction - 12") - tb.set_trans_date("2012-08-8") - tb.add_entry(purchases, 620, Direction.DEBIT) - tb.add_entry(k_patterson, 620, Direction.CREDIT) - tx = tb.build() - tx.post() - - tb = TransactionBuilder(ledger=ledger, description="Test Transaction - 12") - tb.set_trans_date("2012-08-15") - tb.add_entry(purchases, 200, Direction.DEBIT) - tb.add_entry(k_patterson, 200, Direction.CREDIT) - tx = tb.build() - tx.post() - - tb = TransactionBuilder(ledger=ledger, description="Test Transaction - 12") - tb.set_trans_date("2012-08-28") - tb.add_entry(bank, 600, Direction.CREDIT) - tb.add_entry(k_patterson, 600, Direction.DEBIT) - tx = tb.build() - tx.post() - - tb = TransactionBuilder(ledger=ledger, description="Test Transaction - 12") - tb.set_trans_date("2012-08-14") - tb.add_entry(purchases_returns, 20, Direction.CREDIT) - tb.add_entry(k_patterson, 20, Direction.DEBIT) - tx = tb.build() - tx.post() - - return ledger +from general_ledger.tests.book.data_chap5 import ( + load_chapter_5_data, + load_5_review_5_1_data, + load_5_review_5_2_data, + load_5_review_5_5_data, +) +from general_ledger.render.consoler import pr_account_balanced +from general_ledger.render.renderables import render_2_cols +from general_ledger.render.renderer_rich import RichConsoleRenderer +from general_ledger.render.format_table_rich_three_col import ThreeColumnFormat +from general_ledger.utils.inspect import inspect + +# from rich import print + +console = Console() # Create your tests here. @@ -249,96 +32,362 @@ class TestChap5Woods(GeneralLedgerBaseTest): logger = logging.getLogger(__name__) - def test_5_1(self): + def test_chap_5_1_k_tandy(self): + + print("") + + ledger = load_chapter_5_data() + + k_tandy = ledger.coa.getac("K Tandy") + + account_summary = ( + AccountSummaryBuilder(strict_dates=False) + .with_ledger(ledger=ledger) + .with_account(k_tandy) + .with_final_balance() + .build() + ) - # tb4 = TransactionBuilder(ledger=ledger, description="paid insurance by cheque") - # tb4.set_trans_date("2024-08-28") - # tb4.add_entry(d_knight, 158, Direction.CREDIT) - # tb4.add_entry(bank, 158, Direction.DEBIT) - # tx4 = tb4.build() - # tx4.post() + suffix = account_summary.entries_grouped["suffix"] + assert suffix["debit_bd"] is None + assert suffix["credit_bd"] is None + assert suffix["debit_cd"] is None + assert suffix["credit_cd"] is None + + assert account_summary.debit_balance == Decimal("0") + assert account_summary.credit_balance == Decimal("0") + assert account_summary.debit_total == Decimal("444") + assert account_summary.credit_total == Decimal("444") + + # inspect(account_summary) + col1 = account_summary.render() + col2 = pr_account_balanced( + account_summary.entries_grouped, title="Account Name" + ) + console.print(render_2_cols(col1, Text.from_ansi(col2))) + # console.print(Text.from_ansi(col2)) + + panel = Panel(col1, expand=True) + console.print(panel) + + def test_chap_5_1_c_lee(self): ledger = load_chapter_5_data() - # lh = LedgerHelper(ledger) - # self.logger.info(lh.get_account_summary()) + c_lee = ledger.coa.getac("C Lee") + + account_summary = ( + AccountSummaryBuilder(strict_dates=False) + .with_ledger(ledger=ledger) + .with_account(c_lee) + .build() + ) + account_summary.balance_off() + + col1 = account_summary.render() + col2 = pr_account_balanced(account_summary.entries_grouped) + console.print(render_2_cols(col1, Text.from_ansi(col2))) + + suffix = account_summary.entries_grouped["suffix"] + assert suffix["debit_bd"] is None + assert suffix["credit_bd"] is None + assert suffix["debit_cd"] is None + assert suffix["credit_cd"] is None + + assert account_summary.debit_balance == Decimal("0") + assert account_summary.credit_balance == Decimal("0") + assert account_summary.debit_total == Decimal("480") + assert account_summary.credit_total == Decimal("480") + + def test_chap_5_1_k_wood(self): + + ledger = load_chapter_5_data() + + k_wood = ledger.coa.getac("K Wood") + + account_summary = ( + AccountSummaryBuilder(strict_dates=False) + .with_ledger(ledger=ledger) + .with_account(k_wood) + # .with_start_date("2012-08-15") + .with_final_balance() + .build() + ) + account_summary.balance_off() + col1 = account_summary.render() + col2 = pr_account_balanced(account_summary.entries_grouped) + + console.print(render_2_cols(col1, Text.from_ansi(col2))) + + suffix = account_summary.entries_grouped["suffix"] + assert suffix["debit_bd"] is None + assert suffix["credit_bd"] is None + assert suffix["debit_cd"] is None + assert suffix["credit_cd"] is None + + assert account_summary.debit_balance == Decimal("0") + assert account_summary.credit_balance == Decimal("0") + assert account_summary.debit_total == Decimal("214") + assert account_summary.credit_total == Decimal("214") + + def test_chap_5_1_d_knight(self): + + ledger = load_chapter_5_data() + + d_knight = ledger.coa.getac("D Knight") + + account_summary = ( + AccountSummaryBuilder(strict_dates=False) + .with_ledger(ledger=ledger) + .with_account(d_knight) + # .with_start_date("2012-01-15") + .with_balance_interval("month") + .with_final_balance() + .build() + ) + account_summary.balance_off() + # inspect(account_summary) + col1 = account_summary.render() + col2 = pr_account_balanced(account_summary.entries_grouped) + console.print(render_2_cols(col1, Text.from_ansi(col2))) + + suffix = account_summary.entries_grouped["suffix"] + assert suffix["debit_bd"] == Decimal("324.00") + assert suffix["credit_bd"] is None + + assert account_summary.debit_balance == Decimal("324") + assert account_summary.credit_balance == Decimal("0") + assert account_summary.debit_total == Decimal("482") + assert account_summary.credit_total == Decimal("158") + + def test_chap_5_1_b_walters(self): + + ledger = load_chapter_5_data() + + b_walters = ledger.coa.getac("B Walters") + + account_summary = ( + AccountSummaryBuilder(strict_dates=False) + .with_ledger(ledger=ledger) + .with_account(b_walters) + # .with_start_date("2012-08-15") + # .with_by_group_intervals([]) + .with_balance_interval("month") + .with_end_date("2012-09-3") + .with_final_balance() + .build() + ) + col1 = account_summary.render() + col2 = pr_account_balanced(account_summary.entries_grouped) + console.print(render_2_cols(col1, Text.from_ansi(col2))) + + suffix = account_summary.entries_grouped["suffix"] + assert suffix["debit_bd"] == Decimal("51.00") + assert suffix["credit_bd"] is None + + def test_chap_5_2(self): + """accounts for creditors""" + + print("") + ledger = load_chapter_5_data() + + e_williams = ledger.coa.getac("E Williams") + + account_summary = ( + AccountSummaryBuilder(strict_dates=False) + .with_ledger(ledger=ledger) + .with_account(e_williams) + .with_balance_interval("month") + .with_final_balance() + .build() + ) + col1 = account_summary.render() + col2 = pr_account_balanced(account_summary.entries_grouped) + console.print(render_2_cols(col1, Text.from_ansi(col2))) + + e_williams = ledger.coa.getac("K Patterson") + + account_summary = ( + AccountSummaryBuilder(strict_dates=False) + .with_ledger(ledger=ledger) + .with_account(e_williams) + .with_balance_interval("month") + .with_final_balance() + .build() + ) + col1 = account_summary.render() + col2 = pr_account_balanced(account_summary.entries_grouped) + console.print(render_2_cols(col1, Text.from_ansi(col2))) + + def test_chap_5_3(self): + """ + Three columns accounts + :return: + """ + print("") + ledger = load_chapter_5_data() + + accounts = ledger.coa.account_set.filter( + type__slug__in=[ + "accounts-receivable", + "accounts-payable", + ] + ) + + col1_list = [] + grid = Table.grid() + grid.add_column() + + for account in accounts: + account_summary = ( + AccountSummaryBuilder(strict_dates=False) + .with_ledger(ledger=ledger) + .with_account(account) + # .with_balance_interval("week") + .build() + ) + if account_summary.entries: + print(f"{account=}") + # inspect(account_summary) + account_summary.set_renderer(RichConsoleRenderer()) + account_summary.set_table_format(ThreeColumnFormat()) + out = account_summary.render() + col1_list.append(out) + grid.add_row(out) template = get_template("gl/console/three_col_accounts.j2") context = { "ledger": ledger, - "accounts": Account.objects.filter(coa=ledger.coa), + "accounts": accounts, } self.logger.info(template.render(context=context)) - - print(template.render(context=context)) - - # bank = Account.objects.get(name="Bank Account", coa=self.invoice.ledger.coa) - # cash = Account.objects.get(name="Cash", coa=self.invoice.ledger.coa) - # purchases, _ = Account.objects.get_or_create( - # coa=self.invoice.ledger.coa, - # name="Purchases", - # type__name="Direct Costs", - # ) - # sales, _ = Account.objects.get_or_create( - # coa=self.invoice.ledger.coa, - # name="Sales", - # type__name="Sales", - # ) - # vat_charged, _ = Account.objects.get_or_create( - # coa=self.invoice.ledger.coa, - # name="VAT Charged", - # type=AccountType.objects.get( - # name="Current Liability", - # book=self.invoice.ledger.book, - # ), - # defaults={ - # "tax_rate": TaxRate.objects.get( - # slug="no-vat", - # book=self.invoice.ledger.book, - # ) - # }, - # ) - # accounts_receivable, _ = Account.objects.get_or_create( - # coa=self.invoice.ledger.coa, - # name="Accounts Receivable", - # type__name="Current Asset", - # ) - - -class TestChap5WoodsPyTests: - @pytest.mark.django_db - def test_balancing_off_simple_1(self): + col2 = template.render(context=context) + console.print(render_2_cols(grid, Text.from_ansi(col2))) + + def test_chap_5_review_5_1(self): + """ + Three columns accounts + :return: + """ print("") - print(f"This statement gets mixed with pytest output") - ledger = LedgerFactory() - coa = ledger.coa - accounts_receivable = coa.account_set.get(name="Accounts Receivable") - transactions = TransactionFactory.create_batch( - 50, - ledger=ledger, - create_transaction_entry_lines__accounts=ledger.coa.account_set.filter( - slug__in=[ - "bank-account", - "sales", - "accounts-receivable", - ] - ), + ledger = load_5_review_5_1_data() + + accounts = ledger.coa.account_set.filter( + type__slug__in=["accounts-receivable", "accounts-payable"] ) - # lh = LedgerHelper(ledger) - # print(lh.get_account_summary()) + grid_left = Table.grid() + grid_left.add_column() + grid_right = Table.grid() + grid_right.add_column() + + for account in accounts: + summary = ( + AccountSummaryBuilder(strict_dates=False) + .with_ledger(ledger=ledger) + .with_account(account) + .with_final_balance() + # .with_balance_interval("week") + .build() + ) + if summary.entries: + print(f"{account=}") + summary.balance_off() + # inspect(account_summary) + # account_summary.set_table_format(ThreeColumnFormat()) + grid_left.add_row(summary.render()) + summary.set_table_format(ThreeColumnFormat()) + grid_right.add_row(summary.render()) + + console.print(render_2_cols(grid_left, grid_right)) + + def test_chap_5_review_5_2(self): + """ + Three columns accounts + :return: + """ + print("") + ledger = load_5_review_5_2_data() - entry_set = accounts_receivable.entry_set.filter( - transaction__ledger=ledger, + accounts = ledger.coa.account_set.filter( + type__slug__in=["accounts-receivable", "accounts-payable"] ) - test = AccountBalancer( - entry_set=entry_set, - # start_date="2023-01-01", - balance_interval="week", + grid_left = Table.grid() + grid_left.add_column() + grid_right = Table.grid() + grid_right.add_column() + + for account in accounts: + summary = ( + AccountSummaryBuilder(strict_dates=False) + .with_ledger(ledger=ledger) + .with_account(account) + .with_final_balance() + .with_balance_interval("month") + .build() + ) + if summary.entries: + print(f"{account=}") + summary.set_renderer(RichConsoleRenderer()) + grid_left.add_row(summary.render()) + summary.set_table_format( + ThreeColumnFormat(), + decimal_format="8,.0f", + date_format="%b %e", + ) + grid_right.add_row(summary.render()) + + console.print(render_2_cols(grid_left, grid_right)) + + def test_chap_5_review_5_5(self): + print("") + ledger = load_5_review_5_5_data() + + accounts = ledger.coa.account_set.filter( + type__slug__in=[ + "accounts-receivable", + "accounts-payable", + ] ) - # inspect(test) - print(pr_account_balanced(test.grouped_entries)) + report = ConsoleReportBuilder(panel=True) + + for account in accounts: + summary = ( + AccountSummaryBuilder(strict_dates=False) + .with_ledger(ledger=ledger) + .with_account(account) + .with_final_balance() + .with_balance_interval("month") + .build() + ) + if summary.entries: + report.add_column_item( + "left", + summary.set_renderer( + RichConsoleRenderer( + decimal_format="8,.0f", + date_format="%b %e", + ) + ).render(), + ) + report.add_column_item( + "right", + summary.set_renderer( + RichConsoleRenderer( + decimal_format="8,.0f", + date_format="%b %e", + ) + ) + .set_table_format( + ThreeColumnFormat(), + decimal_format="8,.0f", + date_format="%b %e", + ) + .render(), + ) + + console.print(report.build()) diff --git a/general_ledger/tests/book/test_chap5_pytests.py b/general_ledger/tests/book/test_chap5_pytests.py new file mode 100644 index 0000000..1d23f2d --- /dev/null +++ b/general_ledger/tests/book/test_chap5_pytests.py @@ -0,0 +1,60 @@ +import pytest +from rich.console import Console +from rich.panel import Panel +from rich.table import Table + +from general_ledger.factories import TransactionFactory, LedgerFactory +from general_ledger.helpers.ledger_helper import LedgerHelper + +# from rich import print + +console = Console() + + +class TestChap5WoodsPyTests: + @pytest.mark.django_db + def test_balancing_off_simple_1(self): + print("") + print(f"This statement gets mixed with pytest output") + ledger = LedgerFactory() + + accounts = ledger.account_set.filter( + slug__in=[ + "bank-account", + "sales", + "accounts-receivable", + ] + ) + + transactions = TransactionFactory.create_batch( + 50, + ledger=ledger, + trans_date__start_date="-1y", + create_transaction_entry_lines__accounts=accounts, + ) + + items_left = LedgerHelper.t_accounts_to_grid_col(accounts, ledger) + + # entry_set = accounts_receivable.entry_set.filter( + # transaction__ledger=ledger, + # ) + + items_right = [] + for foo in accounts: + entry_set = foo.entry_set.filter( + transaction__ledger=ledger, + ) + items_right.append( + LedgerHelper.do_account_balancer_accounts( + entry_set=entry_set, + ) + ) + + grid = Table.grid() + grid.add_column() + grid.add_column() + for i, item in enumerate(accounts): + grid.add_row(items_left[i], items_right[i]) + + panel = Panel(grid, expand=True) + console.print(panel) diff --git a/general_ledger/tests/book/test_chap6.py b/general_ledger/tests/book/test_chap6.py index dd1a309..af34c60 100644 --- a/general_ledger/tests/book/test_chap6.py +++ b/general_ledger/tests/book/test_chap6.py @@ -1,18 +1,24 @@ -import pytest -from loguru import logger -from rich import inspect +from decimal import Decimal -from general_ledger.helpers import LedgerHelper -from general_ledger.tests.book.test_chap3 import load_chapter_3_data -from general_ledger.utils.account_balanced import AccountBalancer -from general_ledger.utils.account_t_format import AccountTAccount -from general_ledger.utils.consoler import pr_account_list +import pytest +from django.db.models import Count +from rich.console import Console +from general_ledger.builders.account_set_summary_builder import AccountSetSummaryBuilder +from general_ledger.helpers.ledger_helper import LedgerHelper +from general_ledger.tests.book.data_chap6 import ( + load_chapter_6_data, + load_chapter_6_review_6_1_data, + load_chapter_6_review_6_2_data, + load_chapter_6_review_6_5_data, +) +from general_ledger.render.consoler import pr_account_list -def load_chapter_6_data(): +from general_ledger.render.renderer_rich import RichConsoleRenderer +from general_ledger.render.format_table_rich_trial_balance import TrialBalance +from general_ledger.render.utility_rich import lists_to_grid_cols - ledger = load_chapter_3_data() - return ledger +console = Console() class TestChapter6TrialBalance: @@ -21,48 +27,192 @@ class TestChapter6TrialBalance: """ @pytest.mark.django_db - def test_chap6_show_balanced_accounts(self): + def test_chap_6_2_show_balanced_accounts(self): + """ + print the accounts defined earlier in balanced off T-account format + :return: + """ + + print("") + ledger = load_chapter_6_data() - lh = LedgerHelper(ledger) - print(lh.get_account_summary()) + ledger_accounts = LedgerHelper.ledger_accounts(ledger) + + summaries = LedgerHelper.accounts_to_summaries( + ledger_accounts, + start_date="2020-05-01", + end_date="2020-05-31", + ) + balanced = LedgerHelper.summaries_to_balanced(summaries) + t_accounts = LedgerHelper.balanced_to_t_accounts(balanced) + + summary_set = ( + AccountSetSummaryBuilder() + # .with_entry_set(entry_set) + .with_summary_set(balanced) + .with_start_date("2020-05-01") + .with_end_date("2020-05-31") + .build() + ) + + summary_set.set_renderer(RichConsoleRenderer()) + summary_set.set_table_format( + TrialBalance(), + decimal_format="8,.0f", + date_format="%b %e", + ) + + # inspect(accounts) + col_left = t_accounts + col_right = summary_set.render() + + # inspect(col_left) + + grid = lists_to_grid_cols( + col_left, + col_right, + random_styles=False, + ) + console.print(grid) + # console.print(lists_to_grid_cols(col_left)) + + @pytest.mark.django_db + def test_chap_6_review_6_1(self): + """ + print the accounts defined earlier in balanced off T-account format + :return: + """ + + print("") + + ledger = load_chapter_6_review_6_1_data() + accounts = ledger.coa.account_set.order_by("name") + ledger_accounts = LedgerHelper.ledger_accounts(ledger) + + print(pr_account_list(ledger, accounts)) + + summaries = LedgerHelper.accounts_to_summaries( + ledger_accounts, + start_date="2020-05-01", + end_date="2020-05-31", + final_balance=True, + ) + balanced = LedgerHelper.summaries_to_balanced(summaries) + t_accounts = LedgerHelper.balanced_to_t_accounts(balanced) + + summary_set = LedgerHelper.balanced_to_account_sets( + balanced, + start_date="2020-05-01", + end_date="2020-05-31", + decimal_format="8,.0f", + ) + + # inspect(col_left) + + console.print( + lists_to_grid_cols( + t_accounts, + LedgerHelper.render_account_set_summary(summary_set), + random_styles=False, + ) + ) + + assert summary_set.trial_debit_balance == Decimal("4600") + assert summary_set.trial_credit_balance == Decimal("4600") + + @pytest.mark.django_db + def test_chap_6_review_6_2(self): + """ + print the accounts defined earlier in balanced off T-account format + :return: + """ + + print("") + + ledger = load_chapter_6_review_6_2_data() + accounts = ledger.coa.account_set.order_by("name") + ledger_accounts = LedgerHelper.ledger_accounts(ledger) - purchases = ledger.coa.account_set.get(name="Purchases") + print(pr_account_list(ledger, accounts)) - print( - pr_account_list( - ledger, - ledger.coa.account_set.all(), - title="account report", + summaries = LedgerHelper.accounts_to_summaries( + ledger_accounts, + start_date="2023-08-01", + end_date="2023-08-31", + final_balance=True, + ) + balanced = LedgerHelper.summaries_to_balanced(summaries) + t_accounts = LedgerHelper.balanced_to_t_accounts(balanced) + + summary_set = LedgerHelper.balanced_to_account_sets( + balanced, + start_date="2023-08-01", + end_date="2023-08-31", + decimal_format="8,.0f", + ) + + # inspect(col_left) + + console.print( + lists_to_grid_cols( + t_accounts, + LedgerHelper.render_account_set_summary(summary_set), + random_styles=False, ) ) - test = AccountBalancer( - ledger, - purchases, + assert summary_set.trial_debit_balance == Decimal("7007") + assert summary_set.trial_credit_balance == Decimal("7007") + + @pytest.mark.django_db + def test_chap_6_review_6_5(self): + """ + print the accounts defined earlier in balanced off T-account format + :return: + """ + + print("") + + ledger = load_chapter_6_review_6_5_data() + ledger_accounts = LedgerHelper.ledger_accounts(ledger) + + accounts = ledger.account_set.annotate(entry_count=Count("entry")).filter( + entry_count__gt=0 + ) + print(pr_account_list(ledger, accounts)) + + summaries = LedgerHelper.accounts_to_summaries( + ledger_accounts, + start_date="2023-04-01", + end_date="2023-04-30", + final_balance=True, ) - inspect(test) + # print(summaries) - test2 = AccountBalancer( - ledger, - purchases, - "2013-05-09", + balanced = LedgerHelper.summaries_to_balanced(summaries) + t_accounts = LedgerHelper.balanced_to_t_accounts(balanced) + + # print(balanced) + + summary_set = LedgerHelper.balanced_to_account_sets( + balanced, + start_date="2023-04-01", + end_date="2023-04-30", + decimal_format="8,.0f", + ) + + # inspect(col_left) + + console.print( + lists_to_grid_cols( + t_accounts, + LedgerHelper.render_account_set_summary(summary_set), + random_styles=False, + ) ) - inspect(test2) - # ata = AccountTAccount( - # test2.account.name, - # "ref", - # test2.debit_rows, - # test2.credit_rows, - # ) - # print(ata.report()) - # - # inspect(ledger.coa.account_set.all()) - - # test3 = AccountBalanced( - # ledger, - # purchases, - # end_date="2013-05-09", - # ) - # inspect(test3) + assert summary_set.trial_debit_balance == Decimal("5080") + assert summary_set.trial_credit_balance == Decimal("5080") + # @TODO make account summary only return relevant accounts + assert len(summary_set.summary_set) == 15 diff --git a/general_ledger/tests/book/test_chap7.py b/general_ledger/tests/book/test_chap7.py index 565d368..c6d0bb9 100644 --- a/general_ledger/tests/book/test_chap7.py +++ b/general_ledger/tests/book/test_chap7.py @@ -1,270 +1,231 @@ -from general_ledger.builders import TransactionBuilder -from general_ledger.factories import BookFactory -from general_ledger.models import Direction - - -def get_or_create_account(coa, name, slug=None, tax_slug=None): - account_type = coa.book.accounttype_set.get(slug=slug) if slug else None - tax_rate = coa.book.taxrate_set.get(slug=tax_slug) if tax_slug else None - account, _ = coa.account_set.get_or_create( - name=name, - type=account_type, - tax_rate=tax_rate, - ) - return account - - -def post_transaction(ledger, description, date, entries): - tb = TransactionBuilder(ledger=ledger, description=description) - tb.set_trans_date(date) - for account, amount, direction in entries: - tb.add_entry(account, amount, direction) - tb.build().post() - - -def load_exhibit_7_1(): - book = BookFactory() - ledger = book.get_default_ledger() - coa = book.get_default_coa() - - general_expenses = get_or_create_account( - coa, "General Expenses", "overhead", "20-vat-on-expenses" - ) - drawings = get_or_create_account(coa, "Drawings", "equity", "no-vat") - fixtures = get_or_create_account( - coa, "Fixtures", "non-current-asset", "20-vat-on-expenses" - ) - rent = get_or_create_account(coa, "Rent", "overhead", "20-vat-on-expenses") - lighting = get_or_create_account( - coa, "Lighting Expenses", "overhead", "5-vat-on-expenses" - ) - sales = coa.account_set.get(name="Sales") - purchases = coa.account_set.get(name="Purchases") - accounts_receivable = coa.account_set.get(name="Accounts Receivable") - accounts_payable = coa.account_set.get(name="Accounts Payable") - inventory = coa.account_set.get(name="Inventory") - bank = coa.account_set.get(name="Bank Account") - cash = coa.account_set.get(name="Cash") - capital = coa.account_set.get(name="Capital") - opening_balances = coa.account_set.get(name="Opening Balances") - - transactions = [ - ( - opening_balances, - "38500.00", - Direction.DEBIT, - sales, - "38500.00", - Direction.CREDIT, - ), - ( - opening_balances, - "600.00", - Direction.CREDIT, - general_expenses, - "600.00", - Direction.DEBIT, - ), - ( - opening_balances, - "7000.00", - Direction.CREDIT, - drawings, - "7000.00", - Direction.DEBIT, - ), - ( - opening_balances, - "5000.00", - Direction.CREDIT, - fixtures, - "5000.00", - Direction.DEBIT, - ), - ( - opening_balances, - "6800.00", - Direction.CREDIT, - accounts_receivable, - "6800.00", - Direction.DEBIT, - ), - ( - accounts_payable, - "9100.00", - Direction.CREDIT, - opening_balances, - "9100.00", - Direction.DEBIT, - ), - ( - opening_balances, - "3000.00", - Direction.CREDIT, - inventory, - "3000.00", - Direction.DEBIT, - ), - ( - opening_balances, - "15100.00", - Direction.CREDIT, - bank, - "15100.00", - Direction.DEBIT, - ), - (opening_balances, "200.00", Direction.CREDIT, cash, "200.00", Direction.DEBIT), - ( - capital, - "20000.00", - Direction.CREDIT, - opening_balances, - "20000.00", - Direction.DEBIT, - ), - ( - purchases, - "29000.00", - Direction.DEBIT, - opening_balances, - "29000.00", - Direction.CREDIT, - ), - ( - lighting, - "1500.00", - Direction.DEBIT, - opening_balances, - "1500.00", - Direction.CREDIT, - ), - ( - rent, - "2400.00", - Direction.DEBIT, - opening_balances, - "2400.00", - Direction.CREDIT, - ), - ] - - for entry in transactions: - post_transaction( - ledger, - "Opening Balance", - "2012-12-1", - [(entry[0], entry[1], entry[2]), (entry[3], entry[4], entry[5])], +from datetime import date +from decimal import Decimal + +import pytest +from rich.console import Console + +from general_ledger.helpers.ledger_helper import LedgerHelper +from general_ledger.render.format_statement_rich_table import StatementFormatRichTable +from general_ledger.render.format_statement_rich_tree import StatementTreeFormat +from general_ledger.render.format_table_rich_trial_balance import TrialBalance +from general_ledger.render.renderer_rich import RichConsoleRenderer +from general_ledger.render.renderer_statement import StatementRenderer +from general_ledger.render.utility_rich import ConsoleReportBuilder, lists_to_grid_cols +from general_ledger.statements.meta import NodeMeta, DetailLevel +from general_ledger.statements.nodes.income_statement import IncomeStatementNode +from general_ledger.statements.nodes.trading_account import TradingAccountNode +from general_ledger.statements.provider_django import DjangoProvider +from general_ledger.tests.book.data_chap7 import ( + load_chapter_7_exhibit_7_1, + load_chapter_7_exhibit_7_3, + load_chapter_7_review_7_2, + load_chapter_7_review_7_5, +) +from general_ledger.utils.data_loader import tx + +console = Console() + + +class TestChapter7IncomeStatement: + """ + trial balance stuff + """ + + def setup_method(self): + print("") + + @pytest.mark.django_db + def test_chap_7_exhibit_7_1(self): + """ + load the data and sanity check the trial balance + :return: + """ + print("") + + ledger = load_chapter_7_exhibit_7_1() + ledger_accounts = LedgerHelper.ledger_accounts(ledger) + + kwargs = { + "start_date": "2019-01-01", + "end_date": "2019-12-31", + "balance_interval": "year", + } + + summaries = LedgerHelper.accounts_to_summaries( + ledger_accounts, + **kwargs, ) + balanced = LedgerHelper.summaries_to_balanced(summaries) - return ledger - - -def load_exhibit_7_3(): - book = BookFactory() - ledger = book.get_default_ledger() - coa = book.get_default_coa() - - fixtures, _ = coa.account_set.get_or_create( - name="Fixtures", - type=book.accounttype_set.get(slug="non-current-asset"), - tax_rate=book.taxrate_set.get(slug="20-vat-on-expenses"), - ) - purchases = coa.account_set.get( - name="Purchases", - type__slug="direct-costs", - ) - accounts_receivable = coa.account_set.get( - name="Accounts Receivable", - type__slug="accounts-receivable", - ) - accounts_payable = coa.account_set.get( - name="Accounts Payable", - type__slug="accounts-payable", - ) - inventory = coa.account_set.get( - name="Inventory", - type__slug="inventory", - ) - bank = coa.account_set.get(name="Bank Account") - cash = coa.account_set.get(name="Cash", coa=coa) - capital = coa.account_set.get(name="Capital", coa=coa) - opening_balances = coa.account_set.get(name="Opening Balances", coa=coa) - - tb = TransactionBuilder( - ledger=ledger, - description="Opening Balance", - ) - tb.set_trans_date("2012-12-1") - tb.add_entry(opening_balances, "5000.00", Direction.CREDIT) - tb.add_entry(fixtures, "5000.00", Direction.DEBIT) - tx = tb.build() - assert tx.can_post() - tx.post() - - tb = TransactionBuilder( - ledger=ledger, - description="Opening Balance", - ) - tb.set_trans_date("2012-12-1") - tb.add_entry(opening_balances, "6800.00", Direction.CREDIT) - tb.add_entry(accounts_receivable, "6800.00", Direction.DEBIT) - tx = tb.build() - assert tx.can_post() - tx.post() - - tb = TransactionBuilder( - ledger=ledger, - description="Opening Balance", - ) - tb.set_trans_date("2012-12-1") - tb.add_entry(accounts_payable, "9100.00", Direction.CREDIT) - tb.add_entry(opening_balances, "9100.00", Direction.DEBIT) - tx = tb.build() - assert tx.can_post() - tx.post() - - tb = TransactionBuilder( - ledger=ledger, - description="Opening Balance", - ) - tb.set_trans_date("2012-12-1") - tb.add_entry(opening_balances, "3000.00", Direction.CREDIT) - tb.add_entry(inventory, "3000.00", Direction.DEBIT) - tx = tb.build() - assert tx.can_post() - tx.post() - - tb = TransactionBuilder( - ledger=ledger, - description="Opening Balance", - ) - tb.set_trans_date("2012-12-1") - tb.add_entry(opening_balances, "15100.00", Direction.CREDIT) - tb.add_entry(bank, "15100.00", Direction.DEBIT) - tx = tb.build() - assert tx.can_post() - tx.post() - - tb = TransactionBuilder( - ledger=ledger, - description="Opening Balance", - ) - tb.set_trans_date("2012-12-1") - tb.add_entry(opening_balances, "200.00", Direction.CREDIT) - tb.add_entry(cash, "200.00", Direction.DEBIT) - tx = tb.build() - assert tx.can_post() - tx.post() - - tb = TransactionBuilder( - ledger=ledger, - description="Opening Balance", - ) - tb.set_trans_date("2012-12-1") - tb.add_entry(capital, "21000.00", Direction.CREDIT) - tb.add_entry(opening_balances, "21000.00", Direction.DEBIT) - tx = tb.build() - assert tx.can_post() - tx.post() - - return ledger + summary_set = LedgerHelper.balanced_to_account_sets( + balanced, + **kwargs, + ) + assert summary_set.trial_debit_balance == Decimal("67600") + assert summary_set.trial_credit_balance == Decimal("67600") + + @pytest.mark.django_db + def test_chap_7_trading_account_1(self): + print("") + + ledger = load_chapter_7_exhibit_7_1() + ledger_accounts = LedgerHelper.ledger_accounts(ledger) + provider = DjangoProvider(ledger=ledger) + + context = { + "start_date": "2019-01-01", + "end_date": "2019-12-31", + "balance_interval": "year", + "provider": provider, + } + # print(pr_account_list(ledger, accounts)) + + LedgerHelper.do_stuff1(ledger_accounts, do_print=False, **context) + + trading_account = TradingAccountNode( + meta=NodeMeta( + show_subtotal=True, + ), + **context, + ).ensure_expanded() + + console.print(trading_account) + # inspect(ledger_accounts) + + @pytest.mark.django_db + def test_chap_7_exhibit_7_3(self): + print("") + + ledger = load_chapter_7_exhibit_7_3() + kwargs = { + "start_date": "2019-01-01", + "end_date": "2019-12-31", + "balance_interval": "year", + "ledger": ledger, + } + summary_set = LedgerHelper.quick_summary_set(**kwargs) + # @TODO this needs the renderer set or it fails...?!? + summary_set.set_renderer(RichConsoleRenderer()) + summary_set.set_table_format( + TrialBalance(), + **kwargs, + ) + console.print(summary_set.render()) + + @pytest.mark.django_db + def test_chap_7_review_7_2(self): + + ledger = load_chapter_7_review_7_2() + ledger_accounts = LedgerHelper.ledger_accounts(ledger) + provider = DjangoProvider(ledger=ledger) + context = { + "provider": provider, + "start_date": "2023-7-1", + "end_date": "2024-06-30", + "balance_interval": "year", + "ledger": ledger, + } + + statement = ( + IncomeStatementNode( + meta=NodeMeta( + show_subtotal=True, + ), + label="Income Statement", + **context, + ) + .ensure_expanded() + .prune_empty() + ) + statement.set_renderer(StatementRenderer()) + statement.set_render_format( + "statement", + StatementFormatRichTable(), + # detail_level=DetailLevel.FULL, + ).render() + statement.set_renderer(StatementRenderer()) + statement.set_render_format( + "statement", + StatementTreeFormat(), + detail_level=DetailLevel.FULL, + ).render() + + @pytest.mark.django_db + def test_chap_7_review_7_5(self): + """ + load the data and produce the trial balance without the inventory + as this is applied directly to the trading account + """ + + ledger = load_chapter_7_review_7_5() + coa = ledger.book.get_default_coa() + provider = DjangoProvider(ledger=ledger) + + context = { + "provider": provider, + "ledger": ledger, + "balance_interval": "month", + "start_date": "2023-9-1", + "end_date": "2023-9-30", + "final_balance": True, + } + + summary_set = LedgerHelper.quick_summary_set(**context) + + report = ConsoleReportBuilder().add_column_item("left", summary_set.render()) + trial_balance = summary_set.set_table_format(TrialBalance()).render() + + # fmt: off + inventory, opening_balances = [ + coa.get_or_create(*args) + for k, (args) in { + "inventory": ["Inventory", "inventory", "no-vat"], + "opening_balances": ["Opening Balances", "equity", "no-vat"], + }.items() + ] + # fmt: on + + txs = [ + tx(ledger, dr, amt, dt, cr) + for dr, amt, dt, cr in [ + # applied in the calculation of the trading account + (inventory, "570", "2023-09-30", opening_balances), + ] + ] + statement = ( + IncomeStatementNode( + meta=NodeMeta( + show_subtotal=True, + ), + **context, + ) + .ensure_expanded() + .set_renderer(StatementRenderer()) + .set_render_format( + "statement", + StatementFormatRichTable(), + ) + ) + report.add_column_item( + "right", + lists_to_grid_cols( + [trial_balance, statement], + ), + ) + + console.print(report.build()) + + statement = IncomeStatementNode( + label="Income Statement", + meta=NodeMeta( + show_subtotal=True, + ), + **context, + ).ensure_expanded() + + statement.set_renderer(StatementRenderer()) + statement.set_render_format( + "statement", + StatementFormatRichTable(), + # detail_level=DetailLevel.FULL, + ).render() diff --git a/general_ledger/tests/book/test_chap7_review_7_1.py b/general_ledger/tests/book/test_chap7_review_7_1.py new file mode 100644 index 0000000..0923d0d --- /dev/null +++ b/general_ledger/tests/book/test_chap7_review_7_1.py @@ -0,0 +1,93 @@ +import pytest +from rich.console import Console + +from general_ledger.helpers.ledger_helper import LedgerHelper +from general_ledger.render.format_statement_rich_table import StatementFormatRichTable +from general_ledger.render.format_statement_rich_table_reversed import ( + StatementTableFormatReverse, +) +from general_ledger.render.renderer_statement import StatementRenderer +from general_ledger.render.utility_rich import ConsoleReportBuilder, lists_to_grid_cols +from general_ledger.statements.meta import NodeMeta +from general_ledger.statements.nodes.income_statement import IncomeStatementNode +from general_ledger.statements.provider_django import DjangoProvider +from general_ledger.tests.book.data_chap7 import ( + load_chapter_7_review_7_1, +) + +console = Console() + + +class TestChapter7IncomeStatement: + """ + trial balance stuff + """ + + def setup_method(self): + print("") + + @pytest.mark.django_db + def test_chap_7_review_7_1(self): + print("") + + ledger = load_chapter_7_review_7_1() + ledger_accounts = LedgerHelper.ledger_accounts(ledger) + provider = DjangoProvider(ledger=ledger) + context = { + "provider": provider, + "ledger": ledger, + "start_date": "2022-11-01", + "end_date": "2023-10-31", + "balance_interval": "year", + } + + report = ConsoleReportBuilder() + + statement1 = ( + IncomeStatementNode( + meta=NodeMeta( + show_subtotal=True, + ), + label="Income Statement", + **context, + ) + .ensure_expanded() + .prune_empty() + ) + + statement1.set_renderer( + StatementRenderer(), + ) + statement1.set_render_format( + "statement", + StatementFormatRichTable(), + # detail_level=DetailLevel.FULL, + ) + # statement.set_renderer(StatementRenderer()) + + report.add_column_item("left", statement1) + + provider = DjangoProvider(ledger=ledger) + statement2 = ( + IncomeStatementNode( + meta=NodeMeta( + show_subtotal=True, + ), + **context, + ) + .ensure_expanded() + .set_renderer(StatementRenderer()) + .set_render_format( + "statement", + StatementTableFormatReverse(), + ) + ) + + report.add_column_item( + "right", + lists_to_grid_cols( + [statement2], + ), + ) + + console.print(report.build()) diff --git a/general_ledger/tests/book/test_chap8.py b/general_ledger/tests/book/test_chap8.py new file mode 100644 index 0000000..8e3858b --- /dev/null +++ b/general_ledger/tests/book/test_chap8.py @@ -0,0 +1,288 @@ +import pytest +from rich import print as rprint +from rich.console import Console +from rich.pretty import Pretty + +from general_ledger.django.models import Account +from general_ledger.helpers.ledger_helper import LedgerHelper +from general_ledger.render.format_statement_rich_table import StatementFormatRichTable +from general_ledger.render.format_statement_rich_tree import StatementTreeFormat +from general_ledger.render.format_table_rich_trial_balance import TrialBalance +from general_ledger.render.renderer_rich import RichConsoleRenderer +from general_ledger.render.renderer_statement import StatementRenderer +from general_ledger.render.utility_rich import ConsoleReportBuilder, lists_to_grid_cols +from general_ledger.statements.meta import ( + NodeMeta, + DetailLevel, + AddOperation, + Operation, +) +from general_ledger.statements.nodes.balance_sheet import BalanceSheetNode +from general_ledger.statements.nodes.capital import CapitalNode +from general_ledger.statements.nodes.income_statement import IncomeStatementNode +from general_ledger.statements.provider_django import DjangoProvider +from general_ledger.tests.book.data_chap7 import load_chapter_7_exhibit_7_1 +from general_ledger.tests.book.data_chap8 import load_chapter_8_exhibit_8_1 +from general_ledger.utils.data_loader import tx + +console = Console() + + +class TestChapter8BalanceSheets: + """Balance Sheet stuff""" + + @pytest.mark.django_db + def test_chap_8_exhibit_8_1(self): + """ + load the data and sanity check the trial balance + :return: + """ + + ledger = load_chapter_8_exhibit_8_1() + provider = DjangoProvider(ledger=ledger) + ledger_accounts = LedgerHelper.ledger_accounts(ledger) + + context = { + "provider": provider, + "ledger": ledger, + "start_date": "2019-01-01", + "end_date": "2019-12-31", + "balance_interval": "year", + } + summary_set = LedgerHelper.quick_summary_set(**context) + + # need to set renderer before setting table format + summary_set.set_renderer(RichConsoleRenderer()) + + statement = BalanceSheetNode( + meta=NodeMeta( + show_subtotal=True, + ), + **context, + ).ensure_expanded() + + report = ( + ConsoleReportBuilder() + .add_column_item("left", summary_set.render()) + .add_column_item( + "right", + lists_to_grid_cols( + [ + summary_set.set_table_format(TrialBalance()).render(), + statement.render(), + ], + ), + ) + .build() + ) + + console.print(report) + + @pytest.mark.django_db + def test_chap_8_exhibit_8_5(self): + """ + load the data and sanity check the trial balance + :return: + """ + + # ledger = load_chapter_8_exhibit_8_1() + ledger = load_chapter_7_exhibit_7_1() + coa = ledger.book.get_default_coa() + provider = DjangoProvider(ledger=ledger) + + # fmt: off + inventory, opening_balances = [ + coa.get_or_create(*args) + for k, (args) in { + "inventory": ["Inventory", "inventory", "no-vat"], + "opening_balances": ["Opening Balances", "equity", "no-vat"], + }.items() + ] + # fmt: on + + txs = [ + tx(ledger, dr, amt, dt, cr) + for dr, amt, dt, cr in [ + # applied in the calculation of the trading account + (inventory, "3000", "2019-12-31", opening_balances), + ] + ] + + # ledger_accounts = LedgerHelper.ledger_accounts(ledger) + + context = { + "provider": provider, + "ledger": ledger, + "start_date": "2019-01-01", + "end_date": "2019-12-31", + "balance_interval": "year", + } + summary_set = LedgerHelper.quick_summary_set(**context) + + statement = BalanceSheetNode( + meta=NodeMeta( + show_subtotal=True, + ), + **context, + ).ensure_expanded() + + item1 = statement.render() + + statement.set_renderer(StatementRenderer()) + item2 = statement.set_render_format( + "statement", + StatementTreeFormat(), + detail_level=DetailLevel.FULL, + ).render() + + item_statement_3 = ( + IncomeStatementNode( + meta=NodeMeta( + show_subtotal=True, + ), + label="Income Statement", + **context, + ) + .ensure_expanded() + .prune_empty() + ).render() + + statement4 = CapitalNode( + meta=NodeMeta( + show_subtotal=True, + ), + **context, + ).ensure_expanded() + statement4.set_renderer(StatementRenderer()) + + item5 = statement4.set_render_format( + "statement", + StatementTreeFormat(), + detail_level=DetailLevel.FULL, + ).render() + + report = ( + ConsoleReportBuilder() + .add_column_item( + "left", + lists_to_grid_cols( + [ + summary_set.render(), + ] + ), + ) + .add_column_item( + "right", + lists_to_grid_cols( + [ + summary_set.set_table_format(TrialBalance()).render(), + item1, + item_statement_3, + item2, + statement4, + item5, + ], + ), + ) + .build() + ) + + console.print(report) + + rprint(Account.objects.all()) + + @pytest.mark.django_db + def test_chap_8_exhibit_8_5_b(self): + + ledger = load_chapter_7_exhibit_7_1() + coa = ledger.book.get_default_coa() + provider = DjangoProvider(ledger=ledger) + + context = { + "provider": provider, + "ledger": ledger, + "start_date": "2019-01-01", + "end_date": "2019-12-31", + "balance_interval": "year", + } + # fmt: off + inventory, opening_balances = [ + coa.get_or_create(*args) + for k, (args) in { + "inventory": ["Inventory", "inventory", "no-vat"], + "opening_balances": ["Opening Balances", "equity", "no-vat"], + }.items() + ] + # fmt: on + + txs = [ + tx(ledger, dr, amt, dt, cr) + for dr, amt, dt, cr in [ + # applied in the calculation of the trading account + (inventory, "3000", "2019-12-31", opening_balances), + ] + ] + + capital_node = CapitalNode( + meta=NodeMeta( + show_subtotal=True, + ), + **context, + ).ensure_expanded() + + income_statement = ( + IncomeStatementNode( + label="Income Statement", + operation=AddOperation, + meta=NodeMeta( + show_subtotal=True, + operation=Operation.ADD, + expand=DetailLevel.VALUE, + ), + **context, + ).ensure_expanded() + # .prune_empty() + ) + + console.print(Pretty(income_statement)) + + capital_node.find("Capital").add_child(income_statement) + + capital_node.set_renderer(StatementRenderer()) + right1 = capital_node.set_render_format( + "statement", + StatementFormatRichTable(), + # detail_level=DetailLevel.FULL, + ).render() + + capital_node.set_renderer(StatementRenderer()) + left1 = capital_node.set_render_format( + "statement", + StatementTreeFormat(), + detail_level=DetailLevel.FULL, + ).render() + + report = ( + ConsoleReportBuilder() + .add_column_item( + "left", + lists_to_grid_cols( + [ + left1, + ] + ), + ) + .add_column_item( + "right", + lists_to_grid_cols( + [ + right1, + ], + ), + ) + .build() + ) + + console.print(report) + + rprint(Account.objects.all()) diff --git a/general_ledger/tests/builders/test_balance_sheet_builder.py b/general_ledger/tests/builders/test_balance_sheet_builder.py index 447039b..a693e27 100644 --- a/general_ledger/tests/builders/test_balance_sheet_builder.py +++ b/general_ledger/tests/builders/test_balance_sheet_builder.py @@ -1,17 +1,15 @@ import pytest -from rich import inspect -from rich import print as rprint # from rich import print from rich.console import Console from general_ledger.builders.balance_sheet import BalanceSheetBuilder -from general_ledger.helpers import LedgerHelper -from general_ledger.models import AccountType, Entry +from general_ledger.django.models import AccountType +from general_ledger.helpers.ledger_helper import LedgerHelper +from general_ledger.tests.book.data_chap7 import load_chapter_7_exhibit_7_1 from general_ledger.tests.book.test_chap5 import load_chapter_5_data -from general_ledger.tests.book.test_chap7 import load_exhibit_7_3, load_exhibit_7_1 -from general_ledger.utils.consoler import pr_account_list -from colorama import Back, Style + +from general_ledger.render.utility_rich import lists_to_grid_cols console = Console() @@ -28,62 +26,85 @@ class TestBalanceSheetBuilder: @pytest.mark.django_db def test_simple_balance_sheet_builder_1(self): - ledger = load_exhibit_7_1() - - builder = BalanceSheetBuilder( - ledger=ledger, + ledger = load_chapter_7_exhibit_7_1() + # accounts = ledger.coa.account_set.exclude( + # type__slug__in=["opening-balances", "inventory"] + # ).order_by("name") + accounts = ledger.coa.account_set.order_by("name") + ledger_accounts = LedgerHelper.ledger_accounts(ledger) + + summaries = LedgerHelper.accounts_to_summaries( + ledger_accounts, + # start_date="2020-05-01", + # end_date="2020-05-31", + final_balance=True, ) - - lh = LedgerHelper(ledger) - print(lh.get_account_summary()) - - # inspect(ledger) - - # ac = AccountContext(cash) - # self.logger.info(ac.get_context_report()) - - balance_sheet = builder.build() - - # accounts = balance_sheet.ledger.coa.account_set.all() - # print(pr_account_list(accounts, title="All accounts in COA")) - - accounts_nca = balance_sheet.get_non_current_asset_accounts() - print(pr_account_list(ledger, accounts_nca, title="Non Current Asset accounts")) - - # Combine the querysets to get only entries in both - # combined_entries = transaction_entries & account_entries - combined_entries_nca = Entry.objects.filter( - transaction__ledger=ledger, - account__in=accounts_nca, + balanced = LedgerHelper.summaries_to_balanced(summaries) + t_accounts = LedgerHelper.balanced_to_t_accounts(balanced) + + summary_set = LedgerHelper.balanced_to_account_sets( + balanced, + start_date="2019-01-01", + end_date="2019-12-31", + decimal_format="8,.0f", ) - # inspect(combined_entries_nca.balance()) - - accounts_ca = balance_sheet.get_current_asset_accounts() - print(pr_account_list(ledger, accounts_ca, title="Current Asset account")) + # inspect(col_left) - # Combine the querysets to get only entries in both - # combined_entries = transaction_entries & account_entries - combined_entries_ca = Entry.objects.filter( - transaction__ledger=ledger, - account__in=accounts_ca, + console.print( + lists_to_grid_cols( + t_accounts, + LedgerHelper.render_account_set_summary(summary_set), + random_styles=False, + ) ) - rprint( - "total assets " - + str( - combined_entries_nca.debit_balance() - + combined_entries_ca.debit_balance() - ) + builder = BalanceSheetBuilder( + ledger=ledger, ) - # inspect(combined_entries_ca.balance()) + balance_sheet = builder.build() - accounts = balance_sheet.get_current_liabilities_accounts() - print(pr_account_list(ledger, accounts, title="Current Liability accounts")) + # accounts = balance_sheet.ledger.coa.account_set.all() + # print(pr_account_list(accounts, title="All accounts in COA")) - accounts = balance_sheet.get_non_current_liabilities_accounts() - print(pr_account_list(ledger, accounts, title="Non Current Liability accounts")) + # accounts_nca = balance_sheet.get_non_current_asset_accounts() + # print(pr_account_list(ledger, accounts_nca, title="Non Current Asset accounts")) + # + # # Combine the querysets to get only entries in both + # # combined_entries = transaction_entries & account_entries + # combined_entries_nca = Entry.objects.filter( + # transaction__ledger=ledger, + # account__in=accounts_nca, + # ) + # + # # inspect(combined_entries_nca.balance()) + # + # accounts_ca = balance_sheet.get_current_asset_accounts() + # print(pr_account_list(ledger, accounts_ca, title="Current Asset account")) + # + # # Combine the querysets to get only entries in both + # # combined_entries = transaction_entries & account_entries + # combined_entries_ca = Entry.objects.filter( + # transaction__ledger=ledger, + # account__in=accounts_ca, + # ) + # + # rprint( + # "total assets " + # + str( + # combined_entries_nca.debit_balance() + # + combined_entries_ca.debit_balance() + # ) + # ) + # + # # inspect(combined_entries_ca.balance()) + # + # accounts = balance_sheet.get_current_liabilities_accounts() + # print(pr_account_list(ledger, accounts, title="Current Liability accounts")) + # + # accounts = balance_sheet.get_non_current_liabilities_accounts() + # print(pr_account_list(ledger, accounts, title="Non Current Liability accounts")) @pytest.mark.django_db def test_simple_balance_sheet_builder_2(self): @@ -98,55 +119,55 @@ def test_simple_balance_sheet_builder_2(self): # print(lh.get_account_summary()) balance_sheet = builder.build() - - accounts = balance_sheet.ledger.coa.account_set.all() - print(pr_account_list(ledger, accounts, title="All accounts in COA")) - - # Combine the querysets to get only entries in both - # combined_entries = transaction_entries & account_entries - combined_entries = Entry.objects.filter( - transaction__ledger=ledger, - account__in=accounts, - ) - - # inspect(combined_entries.balance()) - - accounts = balance_sheet.get_non_current_asset_accounts() - print(pr_account_list(ledger, accounts, title="Non Current Asset accounts")) - - accounts = balance_sheet.get_current_asset_accounts() - print(pr_account_list(ledger, accounts, title="Current Asset account")) - - accounts = balance_sheet.get_current_liabilities_accounts() - print(pr_account_list(ledger, accounts, title="Current Liability accounts")) - - # Combine the querysets to get only entries in both + # + # accounts = balance_sheet.ledger.coa.account_set.all() + # print(pr_account_list(ledger, accounts, title="All accounts in COA")) + # + # # Combine the querysets to get only entries in both + # # combined_entries = transaction_entries & account_entries + # combined_entries = Entry.objects.filter( + # transaction__ledger=ledger, + # account__in=accounts, + # ) + # + # # inspect(combined_entries.balance()) + # + # accounts = balance_sheet.get_non_current_asset_accounts() + # print(pr_account_list(ledger, accounts, title="Non Current Asset accounts")) + # + # accounts = balance_sheet.get_current_asset_accounts() + # print(pr_account_list(ledger, accounts, title="Current Asset account")) + # + # accounts = balance_sheet.get_current_liabilities_accounts() + # print(pr_account_list(ledger, accounts, title="Current Liability accounts")) + # + # # Combine the querysets to get only entries in both + # # combined_entries = transaction_entries & account_entries + # combined_entries = Entry.objects.filter( + # transaction__ledger=ledger, + # account__in=accounts, + # ) + # + # # inspect(combined_entries.balance()) + # + # for foo in combined_entries: + # print( + # f"entry: {Back.LIGHTGREEN_EX}{foo}{Style.RESET_ALL} {Back.YELLOW}{foo.account.name}{Style.RESET_ALL} {foo.transaction}" + # ) + # + # accounts = balance_sheet.get_non_current_liabilities_accounts() + # print(pr_account_list(ledger, accounts, title="Non Current Liability accounts")) + # + # # Assuming you have a transaction object and a list of account objects + # transaction_entries = Entry.objects.filter(transaction__ledger=ledger) + # print(f"count of transaction_entries: {transaction_entries.count()}") + # account_entries = Entry.objects.filter(account__in=accounts) + # print(f"count of account_entries: {account_entries.count()}") + # + # # Combine the querysets to get only entries in both # combined_entries = transaction_entries & account_entries - combined_entries = Entry.objects.filter( - transaction__ledger=ledger, - account__in=accounts, - ) - - # inspect(combined_entries.balance()) - - for foo in combined_entries: - print( - f"entry: {Back.LIGHTGREEN_EX}{foo}{Style.RESET_ALL} {Back.YELLOW}{foo.account.name}{Style.RESET_ALL} {foo.transaction}" - ) - - accounts = balance_sheet.get_non_current_liabilities_accounts() - print(pr_account_list(ledger, accounts, title="Non Current Liability accounts")) - - # Assuming you have a transaction object and a list of account objects - transaction_entries = Entry.objects.filter(transaction__ledger=ledger) - print(f"count of transaction_entries: {transaction_entries.count()}") - account_entries = Entry.objects.filter(account__in=accounts) - print(f"count of account_entries: {account_entries.count()}") - - # Combine the querysets to get only entries in both - combined_entries = transaction_entries & account_entries - - for foo in combined_entries: - print( - f"entry: {Back.LIGHTGREEN_EX}{foo}{Style.RESET_ALL} {Back.YELLOW}{foo.account.name}{Style.RESET_ALL} {foo.transaction}" - ) + # + # for foo in combined_entries: + # print( + # f"entry: {Back.LIGHTGREEN_EX}{foo}{Style.RESET_ALL} {Back.YELLOW}{foo.account.name}{Style.RESET_ALL} {foo.transaction}" + # ) diff --git a/general_ledger/tests/builders/test_income_statement_builder.py b/general_ledger/tests/builders/test_income_statement_builder.py index ea13a93..b9ca794 100644 --- a/general_ledger/tests/builders/test_income_statement_builder.py +++ b/general_ledger/tests/builders/test_income_statement_builder.py @@ -1,16 +1,14 @@ -from decimal import Decimal +# from rich import print +from datetime import date import pytest -from django.template.loader import get_template -from rich import inspect - -# from rich import print from rich.console import Console -from general_ledger.builders.income_statement import IncomeStatementBuilder -from general_ledger.helpers import LedgerHelper -from general_ledger.models import Account -from general_ledger.tests.book.test_chap7 import load_exhibit_7_1 +from general_ledger.factories import TransactionFactory +from general_ledger.helpers.ledger_helper import LedgerHelper +from general_ledger.tests.book.test_chap7 import load_chapter_7_exhibit_7_1 + +# from rich import print console = Console() @@ -20,46 +18,55 @@ class TestIncomeStatementBuilder: @pytest.mark.django_db def test_simple_income_statement_builder_1(self): - ledger = load_exhibit_7_1() - - assert ledger.balance_by_type_slug("sales") == Decimal("38500") - assert ledger.balance_by_type_slug("overhead") == Decimal("4500") - assert ledger.balance_by_type_slug("inventory") == Decimal("3000.00") - assert ledger.balance_by_type_slug( - "inventory", - balance_date="2012-11-30", - ) == Decimal("0.00") - - @pytest.mark.django_db - def test_simple_income_statement_builder_2(self): + ledger = load_chapter_7_exhibit_7_1() + ledger_accounts = LedgerHelper.ledger_accounts(ledger) - ledger = load_exhibit_7_1() + accounts = ledger.account_set.filter( + slug__in=[ + "bank-account", + "sales", + "accounts-receivable", + ] + ) - builder = IncomeStatementBuilder( + transactions = TransactionFactory.create_batch( + 100, ledger=ledger, - start_date="2012-12-01", - end_date="2012-12-31", + trans_date__start_date=date(2018, 11, 15), + trans_date__end_date=date(2021, 1, 15), + create_transaction_entry_lines__accounts=accounts, ) - income_statement = builder.build() - - # lh = LedgerHelper(ledger) - # print(lh.get_account_summary()) - - template = get_template("gl/console/three_col_accounts.j2") - - context = { - "ledger": ledger, - "accounts": Account.objects.filter(coa=ledger.coa), + kwargs = { + "start_date": "2018-01-01", + "end_date": "2019-12-31", + "balance_interval": "year", } - # self.logger.info(template.render(context=context)) - - print(template.render(context=context)) - - inspect(income_statement) - - # inspect(ledger) + summary_set = LedgerHelper.do_stuff1( + ledger_accounts, + do_print=False, + **kwargs, + ) - # ac = AccountContext(cash) - # self.logger.info(ac.get_context_report()) + # console.print(summary_set) + + console.print(summary_set.summary_set) + + # provider = DjangoProvider(ledger=ledger) + # + # trading_account = TradingAccountNode( + # provider=provider, + # meta=NodeMeta( + # show_subtotal=True, + # ), + # **kwargs, + # ) + # + # income_statement = IncomeStatement( + # ledger=ledger, + # trading_account=summary_set.summary_set["sales"], + # **kwargs, + # ) + + # inspect(summary_set, dunder=True) diff --git a/general_ledger/tests/builders/test_payment_builder.py b/general_ledger/tests/builders/test_payment_builder.py index c74e054..8f940fa 100644 --- a/general_ledger/tests/builders/test_payment_builder.py +++ b/general_ledger/tests/builders/test_payment_builder.py @@ -9,7 +9,7 @@ from general_ledger.builders.payment import PaymentBuilder from general_ledger.factories import ContactFactory from general_ledger.factories.bank_statement_line_factory import BankTransactionFactory -from general_ledger.models import ( +from general_ledger.django.models import ( Book, Bank, Invoice, diff --git a/general_ledger/tests/builders/test_transaction_builder.py b/general_ledger/tests/builders/test_transaction_builder.py new file mode 100644 index 0000000..8640ee4 --- /dev/null +++ b/general_ledger/tests/builders/test_transaction_builder.py @@ -0,0 +1,72 @@ +from decimal import Decimal + +import pytest +from rich.console import Console + +from general_ledger.builders.transaction import TransactionBuilder +from general_ledger.factories import BookFactory + + +class TestTransactionBuilder: + + @pytest.mark.django_db + def test_simple_transaction_builder_1(self): + book = BookFactory() + ledger = book.get_default_ledger() + coa = book.get_default_coa() + sales = coa.account_set.get(name="Sales") + purchases = coa.account_set.get(name="Purchases") + accounts_receivable = coa.account_set.get(name="Accounts Receivable") + accounts_payable = coa.account_set.get(name="Accounts Payable") + bank = coa.account_set.get(name="Bank Account") + + tb = TransactionBuilder( + ledger=ledger, + description="Simple Invoiced Credit Sale", + ) + + tb.add_credit(sales, Decimal("1000.00")) + tb.add_debit(accounts_receivable, Decimal("1000.00")) + tb.set_trans_date("2021-01-01") + tx = tb.build() + tx.post() + + assert ledger.transaction_set.count() == 1 + assert ledger.transaction_set.first().entry_set.count() == 2 + # @TODO inconsistent use of property decorator and method + # for is_x boolean queries + assert tx.is_valid() + assert tx.is_posted + + console = Console() + # inspect(console) + # console.log("hello", log_locals=True) + # console.rule("[bold red]Chapter 2", align="left") + # inspect(console) + + # obj = tb + # print("-- some stuff about tb --") + # console.print(obj) + # print(obj) + # rprint(obj) + # print(f"{obj!r}") + # inspect(obj) + # + # obj = tx + # print("-- some stuff about tx --") + # inspect(obj) + # console.print(obj) + # print(obj) + # rprint(obj) + # print(f"{obj!r}") + # inspect(obj) + # + # print(type(tx.entry_set)) + # inspect(tx.entry_set) + # inspect(tx.entry_set.all()) + # + # rprint(Panel("Hello, [red]World!")) + + # print(f"test{Fore.WHITE}{Back.BLACK}wefewffwefe{Style.RESET_ALL}") + + # rprint("Visit my [link=https://www.willmcgugan.com]blog[/link]!") diff --git a/general_ledger/tests/factories/test_bank_account_factory.py b/general_ledger/tests/factories/test_bank_account_factory.py index edc6e2a..c9d0950 100644 --- a/general_ledger/tests/factories/test_bank_account_factory.py +++ b/general_ledger/tests/factories/test_bank_account_factory.py @@ -3,8 +3,8 @@ from general_ledger.factories import BankAccountFactory from general_ledger.factories.bank_statement_line_factory import BankTransactionFactory -from general_ledger.models import Bank, Account -from general_ledger.models.bank_statement_line_type import BankStatementLineType +from general_ledger.django.models import Bank, Account +from general_ledger.django.models.bank_statement_line_type import BankStatementLineType class TestBankAccountFactory(TestCase): diff --git a/general_ledger/tests/factories/test_book_factory.py b/general_ledger/tests/factories/test_book_factory.py index 3f2977a..d6eddb0 100644 --- a/general_ledger/tests/factories/test_book_factory.py +++ b/general_ledger/tests/factories/test_book_factory.py @@ -2,7 +2,7 @@ from rich import inspect from general_ledger.factories import BankAccountFactory, BookFactory -from general_ledger.models import Bank, Account, Book +from general_ledger.django.models import Bank, Account, Book class TestBookFactory(TestCase): diff --git a/general_ledger/tests/factories/test_invoice_factory.py b/general_ledger/tests/factories/test_invoice_factory.py index 4486efe..4fcdeb9 100644 --- a/general_ledger/tests/factories/test_invoice_factory.py +++ b/general_ledger/tests/factories/test_invoice_factory.py @@ -2,7 +2,7 @@ from rich import inspect from general_ledger.factories.invoice import InvoiceFactory -from general_ledger.models import Invoice +from general_ledger.django.models import Invoice class TestInvoiceFactory: @@ -15,3 +15,5 @@ def test_create_invoice_simple_1(self): invoices = InvoiceFactory.create_batch( 10, ) + + assert len(invoices) == 10 diff --git a/general_ledger/tests/factories/test_transaction_factory.py b/general_ledger/tests/factories/test_transaction_factory.py index 178cfdf..19be23d 100644 --- a/general_ledger/tests/factories/test_transaction_factory.py +++ b/general_ledger/tests/factories/test_transaction_factory.py @@ -1,12 +1,9 @@ import pytest from rich import inspect -from rich.pretty import pprint -from colorama import Fore, Style, Back from general_ledger.factories import TransactionFactory, LedgerFactory -from general_ledger.factories.invoice import InvoiceFactory -from general_ledger.models import Invoice, Transaction -from general_ledger.utils.consoler import pr_tx_list +from general_ledger.django.models import Transaction +from general_ledger.render.consoler import pr_tx_list class TestTransactionFactory: diff --git a/general_ledger/tests/forms/test_bank_form.py b/general_ledger/tests/forms/test_bank_form.py index eee475f..a2a7d13 100644 --- a/general_ledger/tests/forms/test_bank_form.py +++ b/general_ledger/tests/forms/test_bank_form.py @@ -4,7 +4,7 @@ from general_ledger.factories import BookFactory from general_ledger.forms.bank import BankForm -from general_ledger.models import Bank, Account +from general_ledger.django.models import Bank, Account class AddBankFormTests(TestCase): diff --git a/general_ledger/tests/helpers/test_matcher_helper.py b/general_ledger/tests/helpers/test_matcher_helper.py index 91f8e22..cdb35b0 100644 --- a/general_ledger/tests/helpers/test_matcher_helper.py +++ b/general_ledger/tests/helpers/test_matcher_helper.py @@ -8,17 +8,17 @@ from general_ledger.factories import BookFactory, ContactFactory, BankAccountFactory from general_ledger.factories.bank_statement_line_factory import BankTransactionFactory from general_ledger.helpers.matcher import MatcherHelper -from general_ledger.models import Bank -from general_ledger.models.bank_statement_line_type import BankStatementLineType +from general_ledger.django.models import Bank +from general_ledger.django.models.bank_statement_line_type import BankStatementLineType @pytest.fixture() def resources(): - #print("setup") + # print("setup") book = BookFactory() bank = BankAccountFactory(book=book) yield book, bank - #print("teardown") + # print("teardown") class TestMatcherHelper: @@ -144,7 +144,6 @@ def test_xfer_matcher(self, resources): book=book, ) matcher.reconcile_bank_statement() - # inspect(matcher.candidates) assert len(matcher.candidates["transfer"]) == num_transfers # this shouldn't change anything @@ -153,8 +152,6 @@ def test_xfer_matcher(self, resources): for xfer in matcher.candidates["transfer"]: bsl_from, bsl_to = xfer - # inspect(bsl_from) - # inspect(bsl_to) pb = PaymentBuilder( ledger=book.get_default_ledger(), book=book, @@ -165,4 +162,3 @@ def test_xfer_matcher(self, resources): to_object=bsl_to, ) payment = pb.build() - # inspect(pb) diff --git a/general_ledger/tests/io/test_dumpdata.py b/general_ledger/tests/io/test_dumpdata.py index 026e14a..dbb3d90 100644 --- a/general_ledger/tests/io/test_dumpdata.py +++ b/general_ledger/tests/io/test_dumpdata.py @@ -5,7 +5,7 @@ from rich import inspect from general_ledger.factories import BookFactory -from general_ledger.models import Book +from general_ledger.django.models import Book @pytest.mark.django_db diff --git a/general_ledger/tests/io/test_io_samples.py b/general_ledger/tests/io/test_io_samples.py index 016aa6d..15b1ce6 100644 --- a/general_ledger/tests/io/test_io_samples.py +++ b/general_ledger/tests/io/test_io_samples.py @@ -33,13 +33,10 @@ def test_multi_stmttrnrs_1(self): parser = ParserFactory.get_parser(file_path) parsed_data = parser.parse(file_path) - # inspect(parser) with open(file_path, "r") as fileobj: ofx = OfxParser.parse(fileobj) - # inspect(ofx) - for account in parsed_data["accounts"]: # statement = account[statement # inspect(account) diff --git a/general_ledger/tests/management/commands/test_generate_xfers.py b/general_ledger/tests/management/commands/test_generate_xfers.py index 8711d94..6b8bcb2 100644 --- a/general_ledger/tests/management/commands/test_generate_xfers.py +++ b/general_ledger/tests/management/commands/test_generate_xfers.py @@ -7,7 +7,7 @@ get_or_create_customers, get_or_create_banks, ) -from general_ledger.models import Bank, Book +from general_ledger.django.models import Bank, Book class TestGenerateTransfers: diff --git a/general_ledger/tests/managers/test_bank_account_manager.py b/general_ledger/tests/managers/test_bank_account_manager.py index 81dec13..b29d63c 100644 --- a/general_ledger/tests/managers/test_bank_account_manager.py +++ b/general_ledger/tests/managers/test_bank_account_manager.py @@ -1,7 +1,7 @@ import pytest from general_ledger.factories import BankAccountFactory -from general_ledger.models import Bank +from general_ledger.django.models import Bank class TestBankAccountManager: diff --git a/general_ledger/tests/mixins/__init__.py b/general_ledger/tests/mixins/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/general_ledger/tests/mixins/test_tree.py b/general_ledger/tests/mixins/test_tree.py new file mode 100644 index 0000000..099744c --- /dev/null +++ b/general_ledger/tests/mixins/test_tree.py @@ -0,0 +1,159 @@ +import unittest + +from rich.console import Console + +from general_ledger.statements.mixins import TreeMixin + +console = Console() + + +class TestTreeMixin(unittest.TestCase): + def setUp(self): + """Setup method to create a sample tree for testing.""" + self.tree = TreeMixin("root") + self.tree.add_child(TreeMixin("A")) + self.tree.add_child(TreeMixin("B")) + self.tree["A"].add_child(TreeMixin("A1")) + self.tree["A"].add_child(TreeMixin("A2")) + self.tree["B"].add_child(TreeMixin("B1")) + + def test_init(self): + """Test the constructor of the TreeMixin class.""" + self.assertEqual(self.tree.name, "root") + self.assertEqual(len(self.tree._children), 2) + self.assertIsNone(self.tree.parent) + + def test_add_child(self): + """Test adding a child to the tree.""" + self.tree.add_child(TreeMixin("C")) + self.assertEqual(len(self.tree._children), 3) + self.assertEqual(self.tree["C"].name, "C") + self.assertEqual(self.tree["C"].parent, self.tree) + + def test_get_child(self): + """Test retrieving a child node.""" + self.assertEqual(self.tree.get_child("A").name, "A") + self.assertIsNone(self.tree.get_child("Nonexistent", strict=False)) + with self.assertRaises(KeyError): + self.tree.get_child("Nonexistent") + + def test_is_leaf(self): + """Test checking if a node is a leaf.""" + self.assertTrue(self.tree.find("A1").is_leaf) + self.assertFalse(self.tree["A"].is_leaf) + + def test_has_children(self): + """Test checking if a node has children.""" + self.assertTrue(self.tree["A"].has_children) + self.assertFalse(self.tree.find("B1").has_children) + + def test_depth(self): + """Test calculating the depth of a node.""" + self.assertEqual(self.tree.depth, 0) + self.assertEqual(self.tree["A"].depth, 1) + self.assertEqual(self.tree.find("A1").depth, 2) + + def test_get_path(self): + """Test retrieving the path to a node.""" + self.assertEqual(self.tree.get_path(), "root") + self.assertEqual(self.tree["A"].get_path(), "root/A") + self.assertEqual(self.tree.find("A1").get_path(), "root/A/A1") + + def test_show_nest(self): + """Test retrieving the path to a node in dictionary format.""" + self.assertEqual(self.tree.show_nest(), "['root']") + self.assertEqual(self.tree["A"].show_nest(), "['root']['A']") + self.assertEqual(self.tree["A"]["A1"].show_nest(), "['root']['A']['A1']") + + def test_delitem(self): + """Test deleting a child node.""" + del self.tree["A"] + self.assertEqual(len(self.tree._children), 1) + with self.assertRaises(KeyError): + del self.tree["A"] + + def test_remove(self): + """Test removing a node from the tree.""" + TreeMixin.remove(self.tree["A"]) + self.assertEqual(len(self.tree._children), 1) + with self.assertRaises(ValueError): + TreeMixin.remove(self.tree) + + def test_removes_node_correctly(self): + root = TreeMixin(name="root") + child = TreeMixin(name="child") + root.add_child(child) + TreeMixin.remove(child) + assert child not in root._children.values() + assert child.parent is None + + def test_find(self): + """Test finding a node by name.""" + self.assertEqual(self.tree.find("A1"), self.tree["A"]["A1"]) + self.assertIsNone(self.tree.find("Nonexistent", strict=False)) + + def test_pre_order_traversal(self): + """Test pre-order traversal of the tree.""" + expected_order = ["root", "A", "A1", "A2", "B", "B1"] + self.assertEqual( + [node.name for node in self.tree.pre_order_traversal()], expected_order + ) + + def test_post_order_traversal(self): + """Test post-order traversal of the tree.""" + expected_order = ["A1", "A2", "A", "B1", "B", "root"] + self.assertEqual( + [node.name for node in self.tree.post_order_traversal()], expected_order + ) + + def test_level_order_traversal(self): + """Test level-order traversal of the tree.""" + expected_order = ["root", "A", "B", "A1", "A2", "B1"] + self.assertEqual( + [node.name for node in self.tree.level_order_traversal()], expected_order + ) + + def test_reversed_traversals(self): + """Test the traversal methods with the reverse flag.""" + expected_pre_order = ["root", "B", "B1", "A", "A2", "A1"] + self.assertEqual( + [node.name for node in self.tree.pre_order_traversal(reverse=True)], + expected_pre_order, + ) + + expected_post_order = ["B1", "B", "A2", "A1", "A", "root"] + self.assertEqual( + [node.name for node in self.tree.post_order_traversal(reverse=True)], + expected_post_order, + ) + + expected_level_order = ["root", "B", "A", "B1", "A2", "A1"] + self.assertEqual( + [node.name for node in self.tree.level_order_traversal(reverse=True)], + expected_level_order, + ) + + def test_len(self): + """Test getting the number of children.""" + self.assertEqual(len(self.tree), 2) + self.assertEqual(len(self.tree["A"]), 2) + self.assertEqual(len(self.tree["B"]["B1"]), 0) + property() + + def test_getitem(self): + """Test accessing children using indexing.""" + self.assertEqual(self.tree["A"].name, "A") + with self.assertRaises(KeyError): + _ = self.tree["Nonexistent"] + + def test_setitem(self): + """Test setting a child node using indexing.""" + new_node = TreeMixin("C") + self.tree["C"] = new_node + self.assertEqual(self.tree["C"], new_node) + self.assertEqual(new_node.parent, self.tree) + + def test_iter(self): + """Test iterating over the children.""" + children_names = [child.name for child in self.tree.values()] + self.assertEqual(children_names, ["A", "B"]) diff --git a/general_ledger/tests/models/test_bank_account.py b/general_ledger/tests/models/test_bank_account.py index 4842f1d..43f199d 100644 --- a/general_ledger/tests/models/test_bank_account.py +++ b/general_ledger/tests/models/test_bank_account.py @@ -7,7 +7,7 @@ from rich import inspect from general_ledger.factories import BankAccountFactory, BookFactory -from general_ledger.models import Ledger, Bank, Account, TaxRate, AccountType +from general_ledger.django.models import Ledger, Bank, Account, TaxRate, AccountType from general_ledger.tests import GeneralLedgerBaseTest @@ -77,9 +77,6 @@ def test_whether_can_create_unsaved_bank_account(self): account.save() bank.save() - # inspect(account) - # inspect(bank) - def test_bank_with_account_updating(self): """ test bank with account updating @@ -90,11 +87,5 @@ def test_bank_with_account_updating(self): name="BeforeName", ) - # inspect(bank) - # inspect(bank.account) - bank.name = "AfterName" bank.save() - - # inspect(bank.name) - # inspect(bank.account.name) diff --git a/general_ledger/tests/models/test_document_status.py b/general_ledger/tests/models/test_document_status.py index 2d442d0..0b23de1 100644 --- a/general_ledger/tests/models/test_document_status.py +++ b/general_ledger/tests/models/test_document_status.py @@ -9,8 +9,8 @@ from general_ledger.factories import BookFactory, BankAccountFactory from general_ledger.factories.bank_statement_line_factory import BankTransactionFactory from general_ledger.factories.invoice import InvoiceFactory -from general_ledger.models import Book, Ledger, Payment, Bank -from general_ledger.models.document_status import DocumentStatus +from general_ledger.django.models import Book, Ledger, Payment, Bank +from general_ledger.django.models.document_status import DocumentStatus from general_ledger.tests import GeneralLedgerBaseTest @@ -33,9 +33,8 @@ def test_document_status(self): payment.refresh_from_db() # inspect(payment.state) - - assert (payment.state == DocumentStatus.RECORDED) - assert (payment.state != DocumentStatus.DRAFT) + assert payment.state == DocumentStatus.RECORDED + assert payment.state != DocumentStatus.DRAFT # payment.state = DocumentStatus.VOID diff --git a/general_ledger/tests/models/test_invoice.py b/general_ledger/tests/models/test_invoice.py index 26b0116..d5f2a9e 100644 --- a/general_ledger/tests/models/test_invoice.py +++ b/general_ledger/tests/models/test_invoice.py @@ -6,8 +6,8 @@ from general_ledger.factories import LedgerFactory, ContactFactory from general_ledger.factories.invoice import InvoiceFactory -from general_ledger.models import Invoice, Ledger, Contact, InvoiceLine, TaxRate -from general_ledger.models.tax_inclusive import TaxInclusive +from general_ledger.django.models import Invoice, Ledger, Contact, InvoiceLine, TaxRate +from general_ledger.django.models.tax_inclusive import TaxInclusive from general_ledger.tests import GeneralLedgerBaseTest @@ -56,7 +56,6 @@ def test_stuff1(self): template = get_template("gl/console/invoice-lines.j2") print(template.render(context={"invoice": invoice})) - # inspect(invoice) print(f"{invoice.tax_inclusive=}") print(f"invoice_line: {invoice_line}") print(f"line_total_exclusive: {invoice_line.line_total_exclusive()}") diff --git a/general_ledger/tests/models/test_invoice1.py b/general_ledger/tests/models/test_invoice1.py index 4ce9c6e..7e59525 100644 --- a/general_ledger/tests/models/test_invoice1.py +++ b/general_ledger/tests/models/test_invoice1.py @@ -6,8 +6,8 @@ from general_ledger.factories import LedgerFactory, ContactFactory from general_ledger.factories.invoice import InvoiceFactory -from general_ledger.models import Invoice, Ledger, Contact, InvoiceLine, TaxRate -from general_ledger.models.tax_inclusive import TaxInclusive +from general_ledger.django.models import Invoice, Ledger, Contact, InvoiceLine, TaxRate +from general_ledger.django.models.tax_inclusive import TaxInclusive from general_ledger.tests import GeneralLedgerBaseTest diff --git a/general_ledger/tests/models/test_invoice_constraints.py b/general_ledger/tests/models/test_invoice_constraints.py index 96e9414..ecdef4f 100644 --- a/general_ledger/tests/models/test_invoice_constraints.py +++ b/general_ledger/tests/models/test_invoice_constraints.py @@ -8,8 +8,8 @@ from general_ledger.factories import LedgerFactory, ContactFactory from general_ledger.factories.invoice import InvoiceFactory -from general_ledger.models import Invoice, Ledger, Contact, InvoiceLine, TaxRate -from general_ledger.models.tax_inclusive import TaxInclusive +from general_ledger.django.models import Invoice, Ledger, Contact, InvoiceLine, TaxRate +from general_ledger.django.models.tax_inclusive import TaxInclusive from general_ledger.tests import GeneralLedgerBaseTest @@ -111,14 +111,12 @@ def test_customer_contact_constraint(self): quantity=1, unit_price=40.0000, ) - # inspect(invoice) with self.assertRaises(ValidationError) as _: invoice.full_clean() self.assertFalse(invoice.is_valid) - def test_customer_due_date_required(self): ledger = LedgerFactory() contact = ContactFactory.customer( @@ -145,7 +143,6 @@ def test_customer_due_date_required(self): self.assertFalse(invoice.is_valid) - def test_customer_due_date_before_date(self): ledger = LedgerFactory() contact = ContactFactory.customer( diff --git a/general_ledger/tests/models/test_invoice_line.py b/general_ledger/tests/models/test_invoice_line.py index 59caeb9..3a700af 100644 --- a/general_ledger/tests/models/test_invoice_line.py +++ b/general_ledger/tests/models/test_invoice_line.py @@ -3,8 +3,8 @@ from rich import inspect from general_ledger.factories.invoice import InvoiceFactory, InvoiceLineFactory -from general_ledger.models import InvoiceLine -from general_ledger.models.tax_inclusive import TaxInclusive +from general_ledger.django.models import InvoiceLine +from general_ledger.django.models.tax_inclusive import TaxInclusive from general_ledger.tests import GeneralLedgerBaseTest diff --git a/general_ledger/tests/models/test_invoice_purchaseinvoice.py b/general_ledger/tests/models/test_invoice_purchaseinvoice.py index fbd4e94..aaa2d09 100644 --- a/general_ledger/tests/models/test_invoice_purchaseinvoice.py +++ b/general_ledger/tests/models/test_invoice_purchaseinvoice.py @@ -1,7 +1,9 @@ from general_ledger.factories import LedgerFactory, ContactFactory -from general_ledger.models import PurchaseInvoice, InvoiceLine -from general_ledger.models.invoice_purchaseinvoice_line import PurchaseInvoiceLine -from general_ledger.models.tax_inclusive import TaxInclusive +from general_ledger.django.models import PurchaseInvoice +from general_ledger.django.models.invoice_purchaseinvoice_line import ( + PurchaseInvoiceLine, +) +from general_ledger.django.models.tax_inclusive import TaxInclusive from general_ledger.tests import GeneralLedgerBaseTest diff --git a/general_ledger/tests/models/test_payment_searching.py b/general_ledger/tests/models/test_payment_searching.py index 2ecc2fb..f2a31aa 100644 --- a/general_ledger/tests/models/test_payment_searching.py +++ b/general_ledger/tests/models/test_payment_searching.py @@ -5,10 +5,10 @@ from general_ledger.factories import BookFactory, BankAccountFactory from general_ledger.factories.bank_statement_line_factory import BankTransactionFactory from general_ledger.helpers.matcher import MatcherHelper -from general_ledger.models import Bank, Payment +from general_ledger.django.models import Bank, Payment -class TestPaymentSearching(): +class TestPaymentSearching: @pytest.mark.django_db def test_search_payments(self): book = BookFactory() @@ -19,20 +19,23 @@ def test_search_payments(self): banks=[bank_1], banks_to=[bank_2], ) - #inspect(txs, title="txs") matcher = MatcherHelper(bank=bank_1) matcher.reconcile_bank_statement() - #inspect(matcher, title="matcher") + # inspect(matcher, title="matcher") matcher.process_matches() - #inspect(matcher, title="matcher") - #inspect(Payment.objects.all(), title="payments") - qs = Payment.objects.filter( - Q(items__from_object_id__in=bank_1.bankstatementline_set.values_list('id', flat=True)) | - Q(items__to_object_id__in=bank_2.bankstatementline_set.values_list('id', flat=True)) + Q( + items__from_object_id__in=bank_1.bankstatementline_set.values_list( + "id", flat=True + ) + ) + | Q( + items__to_object_id__in=bank_2.bankstatementline_set.values_list( + "id", flat=True + ) + ) ) # print(qs.query) - #inspect(qs, title="payments") + # inspect(qs, title="payments") assert qs.count() == 2 - diff --git a/general_ledger/tests/models/test_payment_state.py b/general_ledger/tests/models/test_payment_state.py index e27847f..68b21b6 100644 --- a/general_ledger/tests/models/test_payment_state.py +++ b/general_ledger/tests/models/test_payment_state.py @@ -9,8 +9,8 @@ from general_ledger.factories import BookFactory, BankAccountFactory from general_ledger.factories.bank_statement_line_factory import BankTransactionFactory from general_ledger.factories.invoice import InvoiceFactory -from general_ledger.models import Book, Ledger, Payment, Bank -from general_ledger.models.document_status import DocumentStatus +from general_ledger.django.models import Book, Ledger, Payment, Bank +from general_ledger.django.models.document_status import DocumentStatus from general_ledger.tests import GeneralLedgerBaseTest diff --git a/general_ledger/tests/models/test_transactions.py b/general_ledger/tests/models/test_transactions.py deleted file mode 100644 index 6888e04..0000000 --- a/general_ledger/tests/models/test_transactions.py +++ /dev/null @@ -1,56 +0,0 @@ -import logging - -from general_ledger import constants -from general_ledger.helpers import LedgerHelper -from general_ledger.models import Transaction, Account, Ledger, Book -from general_ledger.tests import GeneralLedgerBaseTest - -from loguru import logger - - -# Create your tests here. -class TestTransactionCreatePost(GeneralLedgerBaseTest): - - # logger = logging.getLogger(__name__) - # def test_something(self): - # self.assertGreaterEqual(len(self.book.name), 3) - # - # print(self.book) - - def test_something_else(self): - - for acct in Account.objects.all(): - logger.trace( - f"{acct.name} - {acct.type} {acct.code}", - extra={ - "user_id": 123, - "username": "johndoe", - "event": "login", - }, - ) - - book = Book.objects.first() - ledger = book.get_default_ledger() - - logger.trace(ledger) - - lh = LedgerHelper(ledger) - tx = lh.build_transaction( - description="Test Transaction", - entries=[ - { - "account": "102", - "amount": 100, - "tx_type": constants.TxType.CREDIT, - }, - { - "account": "103", - "amount": 100, - "tx_type": constants.TxType.DEBIT, - }, - ], - ) - # print(tx) - self.assertIsInstance(tx, Transaction) - self.assertEqual(tx.description, "Test Transaction") - # self.assertEqual(tx.ledger, self.ledger) diff --git a/general_ledger/tests/statements/__init__.py b/general_ledger/tests/statements/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/general_ledger/tests/statements/test_financial_statement.py b/general_ledger/tests/statements/test_financial_statement.py new file mode 100644 index 0000000..08ef81d --- /dev/null +++ b/general_ledger/tests/statements/test_financial_statement.py @@ -0,0 +1,44 @@ +from datetime import date +from decimal import Decimal + +from rich.console import Console + +from general_ledger.render.format_statement_rich_table import StatementFormatRichTable +from general_ledger.render.format_statement_rich_tree import StatementTreeFormat +from general_ledger.render.renderer_statement import StatementRenderer +from general_ledger.statements.financial_statement import FinancialStatement +from general_ledger.statements.in_memory import ( + InMemoryProvider, + InMemoryAccount, + InMemoryProviderBuilder, + InMemoryAccountBuilder, +) +from general_ledger.statements.meta import DetailLevel, NodeMeta +from general_ledger.statements.nodes.income_statement import IncomeStatementNode +from general_ledger.statements.nodes.income_statement_corp import ( + IncomeStatementCorpNode, +) +from general_ledger.statements.nodes.profit_and_loss import ProfitAndLossAccount +from general_ledger.statements.nodes.trading_account import TradingAccountNode + +console = Console() + + +class TestFinancialStatement: + """This is a test class for FinancialStatement""" + + def test_create_statement(self): + """Test creating a statement""" + print("") # for stupid pytest mixing up the console output + statement = FinancialStatement("Income Statement") + assert statement.title == "Income Statement" + assert statement.data == [] + + statement.add_row("Sales", ["", "", "38500"]) + statement.add_row("Less Cost of goods sold", ["", "", ""]) + statement.add_row("Purchases", ["", "", "29000"], 1) + statement.add_row("Closing Inventory", ["", "-3000", ""], 1) + statement.add_row("Gross Profit", ["", "", "12500"]) + + +# statement.render() diff --git a/general_ledger/tests/statements/test_statement_node.py b/general_ledger/tests/statements/test_statement_node.py new file mode 100644 index 0000000..c1b99bd --- /dev/null +++ b/general_ledger/tests/statements/test_statement_node.py @@ -0,0 +1,313 @@ +from datetime import date +from decimal import Decimal + +from rich.console import Console + +from general_ledger.render.format_statement_rich_table import StatementFormatRichTable +from general_ledger.render.format_statement_rich_tree import StatementTreeFormat +from general_ledger.render.renderer_statement import StatementRenderer +from general_ledger.statements.in_memory import ( + InMemoryProvider, + InMemoryAccount, + InMemoryProviderBuilder, + InMemoryAccountBuilder, +) +from general_ledger.statements.meta import DetailLevel, NodeMeta +from general_ledger.statements.nodes.income_statement import IncomeStatementNode +from general_ledger.statements.nodes.income_statement_corp import ( + IncomeStatementCorpNode, +) +from general_ledger.statements.nodes.profit_and_loss import ProfitAndLossAccount +from general_ledger.statements.nodes.trading_account import TradingAccountNode + +console = Console() + + +def test_inmemory_quickloader(): + # Usage becomes: + provider = InMemoryProvider() + provider.quick_account( + "sales1:sales:revenue|20240101:1000|20241231:5000|tx:20240315:2000|tx:20240701:2000" + ) + assert provider.get_account("sales1").id == "sales1" + + +# Usage in tests +def test_sales_calculation(): + provider = ( + InMemoryProviderBuilder() + .with_date_range(date(2024, 1, 1), date(2024, 12, 31)) + .with_sales_account("SALES1", Decimal("1000"), Decimal("5000")) + .with_sales_account("SALES2", Decimal("2000"), Decimal("8000")) + .build() + ) + + # inspect(provider) + + +# Example Usage and Testing +def test_trading_account(): + print("") + # Create test data + provider = InMemoryProvider() + + # Add test accounts + provider.add_account( + InMemoryAccount( + id="sales1", + name="Product Sales", + account_type="sales", + category="revenue", + transactions=[ + (date(2024, 7, 1), Decimal("38500.00")), + ], + ) + ) + + provider.add_account( + InMemoryAccountBuilder("Purchases") + .with_type("purchases") + .with_category("asset") + .tx(2024, 7, 1, 29000) + .build() + ) + + provider.add_account( + InMemoryAccountBuilder("Inventory") + .with_type("inventory") + .with_category("asset") + .bal(2024, 1, 1, 0) + .bal(2024, 12, 31, 3000) + .build() + ) + + # Create trading account + trading = TradingAccountNode( + provider=provider, + start_date=date(2024, 1, 1), + end_date=date(2024, 12, 31), + meta=NodeMeta( + show_subtotal=True, + ), + ).ensure_expanded() + + # force build the tree + # results = trading.render(DetailLevel.DETAILED) + # console.print(trading) + # trading._children["cost_of_goods_sold"]._children[ + # "opening_inventory" + # ]._is_set_visible = True + # display_trading_account(trading) + # console = Console() + # tree = render_statement_tree(trading, show_calculation=True) + # tree.guide_style = "bold bright_blue" + # console.print(tree) + + assert trading.value == Decimal("12500.00") + + +def test_profit_and_loss_account(): + # Create test data + provider = InMemoryProvider() + # provider.add_account( + # InMemoryAccountBuilder("Other Income") + # .with_type("other-income") + # .with_category("revenue") + # .tx(2024, 7, 1, 1000) + # .build() + # ) + + provider.add_account( + InMemoryAccountBuilder("Rent") + .with_type("overhead") + .with_category("expense") + .tx(2024, 12, 31, 2400) + .build() + ) + + provider.add_account( + InMemoryAccountBuilder("Lighting Expenses") + .with_type("overhead") + .with_category("expense") + .tx(2024, 12, 31, 1500) + .build() + ) + + provider.add_account( + InMemoryAccountBuilder("General Expenses") + .with_type("overhead") + .with_category("expense") + .tx(2024, 12, 31, 600) + .build() + ) + + profit_and_loss = ProfitAndLossAccount( + provider=provider, + start_date=date(2024, 1, 1), + end_date=date(2024, 12, 31), + meta=NodeMeta( + show_subtotal=True, + ), + ) + + # profit_and_loss.expand(DetailLevel.FULL) + + # display_trading_account(profit_and_loss) + + +def test_income_statement_node_0(): + + print("") + + provider = InMemoryProvider() + + [ + provider.quick_account(a) + for a in [ + "sales:sales:revenue|2024-01-01:1000|2024-12-31:5_000|tx:2024-03-01:38_500", + "purchases:purchases:asset|2024-01-01:1_000|2024-12-31:5000|tx:2024-07-01:29000", + "Inventory:inventory:asset|20240101:0|20241231:0|20241231:3000", + "Rent:overhead:expense|20240101:0|20241231:0|tx:20241231:2400", + "Lighting Expenses:overhead:expense|20240101:0|20241231:0|tx:20241231:1500", + "General Expenses:overhead:expense|20240101:0|20241231:0|tx:20241231:600", + ] + ] + + ic = ( + IncomeStatementNode( + provider=provider, + start_date=date(2024, 1, 1), + end_date=date(2024, 12, 31), + meta=NodeMeta( + show_subtotal=True, + ), + label="Income Statement", + ) + .ensure_expanded() + .prune_empty() + ) + + ic.set_renderer(StatementRenderer()) + console.print("\n=== Full View ===") + ic.set_render_format( + "statement", + StatementTreeFormat(), + detail_level=DetailLevel.FULL, + ).render() + + console.print("\n=== Detailed View ===") + ic.set_render_format( + "statement", + StatementTreeFormat(), + detail_level=DetailLevel.DETAILED, + ).render() + + console.print("\n=== Summary View ===") + ic.set_render_format( + "statement", + StatementTreeFormat(), + detail_level=DetailLevel.SUMMARY, + ).render() + + ic.set_renderer(StatementRenderer()) + console.print("\n=== Full View with ValueStrategy label ===") + ic.set_render_format( + "statement", + StatementTreeFormat(), + detail_level=DetailLevel.FULL, + show_calculation=True, + ).render() + + +def test_income_statement_node_1(): + + print("") + provider = InMemoryProvider() + + [ + provider.quick_account(a) + for a in [ + # Main revenue/turnover + "sales:sales:revenue|2023-01-01:0|2023-12-31:0|tx:2023-06-30:44_995_186", + # Cost of sales components + "purchases:purchases:asset|2023-01-01:0|2023-12-31:0|tx:2023-06-30:29_938_003", + "inventory:inventory:asset|2023-01-01:0|2023-12-31:0", # No inventory movement + # Administrative expenses + "admin-expenses:overhead:expense|2023-01-01:0|2023-12-31:0|tx:2023-06-30:10_254_147", + # Other operating income + "other-income:other-income:revenue|2023-01-01:0|2023-12-31:0", # Zero for 2023 + # Interest income + "interest-income:interest-income:revenue|2023-01-01:0|2023-12-31:0|tx:2023-06-30:1_464_998", + # Disposal profit + "disposal-profit:disposals:revenue|2023-01-01:0|2023-12-31:0|tx:20230630:114_231_109", + # Tax + "tax:tax:expense|2023-01-01:0|2023-12-31:0|tx:2023-12-31:1_671_492", + "tax:tax:expense|2023-01-01:0|2023-12-31:0|tx:2023-12-31:1_671_492", + ] + ] + + income_statement = ( + IncomeStatementCorpNode( + provider=provider, + start_date=date(2023, 1, 1), + end_date=date(2023, 12, 31), + meta=NodeMeta( + show_subtotal=True, + expand=DetailLevel.SUMMARY, + ), + ) + .ensure_expanded() + .prune_empty() + ) + # console.print(Pretty(income_statement)) + # + # income_statement.prune_empty() + + # console.print(Pretty(income_statement)) + # console.print(income_statement) + + income_statement.render() + + # display_trading_account(income_statement) + # income_statement.build() + + +def test_income_statement_node_2(): + + print("") + + provider = InMemoryProvider() + + [ + provider.quick_account(a) + for a in [ + "sales:sales:revenue|2024-01-01:1000|2024-12-31:5_000|tx:2024-03-01:38_500", + "purchases:purchases:asset|2024-01-01:1_000|2024-12-31:5000|tx:2024-07-01:29000", + "Inventory:inventory:asset|20240101:0|20241231:0|20241231:3000", + "Rent:overhead:expense|20240101:0|20241231:0|tx:20241231:2400", + "Lighting Expenses:overhead:expense|20240101:0|20241231:0|tx:20241231:1500", + "General Expenses:overhead:expense|20240101:0|20241231:0|tx:20241231:600", + ] + ] + + ic = ( + IncomeStatementNode( + provider=provider, + start_date=date(2024, 1, 1), + end_date=date(2024, 12, 31), + meta=NodeMeta( + show_subtotal=True, + ), + label="Income Statement", + ) + .ensure_expanded() + .prune_empty() + ) + + ic.set_renderer(StatementRenderer()) + console.print("\n=== Full View ===") + ic.set_render_format( + "statement", + StatementFormatRichTable(), + # detail_level=DetailLevel.FULL, + ).render() diff --git a/general_ledger/tests/test_account_context.py b/general_ledger/tests/test_account_context.py index cc1b562..b0a6c93 100644 --- a/general_ledger/tests/test_account_context.py +++ b/general_ledger/tests/test_account_context.py @@ -1,21 +1,13 @@ -import logging -from loguru import logger -from general_ledger import constants +from general_ledger.builders.transaction import TransactionBuilder +from general_ledger.builders.account_summary_builder import AccountSummaryBuilder from general_ledger.factories import BookFactory -from general_ledger.helpers import LedgerHelper -from general_ledger.models import ( - Transaction, +from general_ledger.helpers.ledger_helper import LedgerHelper +from general_ledger.django.models import ( Account, - Ledger, Direction, - AccountType, - TaxRate, ) from general_ledger.tests import GeneralLedgerBaseTest - -from general_ledger.builders import TransactionBuilder -from general_ledger.utils.account_balanced import AccountBalancer -from general_ledger.utils.consoler import pr_account_balanced +from general_ledger.render.consoler import pr_account_balanced class AccountContextTests(GeneralLedgerBaseTest): @@ -82,10 +74,13 @@ def test_1(self): lh = LedgerHelper(ledger) # self.logger.info(lh.get_account_summary()) - cash_balanced = AccountBalancer( - account=computer_software, - ledger=ledger, + cash_balanced = ( + AccountSummaryBuilder(strict_dates=False) + .with_account(computer_software) + .with_ledger(ledger) + .build() ) + cash_balanced.balance_off() # inspect(test) - print(pr_account_balanced(cash_balanced.grouped_entries)) + print(pr_account_balanced(cash_balanced.entries_grouped)) diff --git a/general_ledger/tests/test_payment_workflow.py b/general_ledger/tests/test_payment_workflow.py index d353e2e..a716116 100644 --- a/general_ledger/tests/test_payment_workflow.py +++ b/general_ledger/tests/test_payment_workflow.py @@ -1,7 +1,7 @@ from general_ledger.factories.bank_statement_line_factory import BankTransactionFactory from general_ledger.factories.invoice import InvoiceFactory -from general_ledger.models import Book, Payment, Bank -from general_ledger.models.document_status import DocumentStatus +from general_ledger.django.models import Book, Payment, Bank +from general_ledger.django.models.document_status import DocumentStatus from general_ledger.tests import GeneralLedgerBaseTest diff --git a/general_ledger/tests/test_validations.py b/general_ledger/tests/test_validations.py index da7b1c5..aef48bc 100644 --- a/general_ledger/tests/test_validations.py +++ b/general_ledger/tests/test_validations.py @@ -3,8 +3,8 @@ from rich import inspect from general_ledger.factories import LedgerFactory, ContactFactory -from general_ledger.models import Invoice -from general_ledger.models.tax_inclusive import TaxInclusive +from general_ledger.django.models import Invoice +from general_ledger.django.models.tax_inclusive import TaxInclusive from general_ledger.tests import GeneralLedgerBaseTest @@ -26,21 +26,9 @@ def test_validation_mixin(self): tax_inclusive=TaxInclusive.NONE, ) - # print(invoice.is_overdue) assert invoice.is_valid assert invoice.is_overdue is False - # inspect(invoice) - # inspect(invoice.date) - # inspect(invoice.due_date) invoice.save() - # inspect(invoice) - # inspect(invoice.date) - # inspect(invoice.due_date) - invoice.refresh_from_db() - - # inspect(invoice) - # inspect(invoice.date) - # inspect(invoice.due_date) diff --git a/general_ledger/tests/utils/__init__.py b/general_ledger/tests/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/general_ledger/tests/utils/test_date_stuff.py b/general_ledger/tests/utils/test_date_stuff.py new file mode 100644 index 0000000..1261368 --- /dev/null +++ b/general_ledger/tests/utils/test_date_stuff.py @@ -0,0 +1,84 @@ +import unittest +import datetime +import itertools +import numbers +from collections import namedtuple + +from colorama import Fore, Back, Style +from dateutil.relativedelta import relativedelta +from general_ledger.utils.utility_date_stuff import pad_around_char, last_day_of + + +class TestPadding(unittest.TestCase): + + def test_pad_around_char_typical(self): + self.assertEqual(pad_around_char("1.1"), " 1.1 ") + + def test_pad_around_char_already_padded(self): + self.assertEqual(pad_around_char("10.1 "), "10.1 ") + + def test_pad_around_char_double_digit(self): + self.assertEqual(pad_around_char("12.12"), "12.12") + + def test_pad_around_char_different_separator(self): + self.assertEqual(pad_around_char("1-1", char="-"), " 1-1 ") + + +class TestLastDayOf(unittest.TestCase): + def test_last_day_of(self): + date = datetime.datetime(2022, 1, 1) + yesterday = datetime.datetime.today().date() - datetime.timedelta(days=1) + self.assertEqual( + last_day_of(date, "year"), datetime.datetime(2022, 12, 31).date() + ) + self.assertEqual( + last_day_of(date, None), + yesterday, + ) + self.assertEqual( + last_day_of(date, "week"), datetime.datetime(2022, 1, 2).date() + ) + self.assertIsInstance(last_day_of(date, "day"), datetime.date) + self.assertIsInstance(last_day_of(date.date(), "year"), datetime.date) + self.assertIsInstance(last_day_of(date, "other"), datetime.date) + self.assertIsInstance(last_day_of(date, None), datetime.date) + + def test_last_day_of_year(self): + self.assertEqual( + last_day_of(datetime.date(2023, 1, 15), "year"), datetime.date(2023, 12, 31) + ) + self.assertEqual( + last_day_of(datetime.date(2024, 2, 29), "year"), datetime.date(2024, 12, 31) + ) + self.assertEqual( + last_day_of(datetime.date(2023, 12, 31), "year"), + datetime.date(2023, 12, 31), + ) + + def test_last_day_of_month(self): + self.assertEqual( + last_day_of(datetime.date(2023, 1, 15), "month"), datetime.date(2023, 1, 31) + ) + self.assertEqual( + last_day_of(datetime.date(2024, 2, 29), "month"), datetime.date(2024, 2, 29) + ) + self.assertEqual( + last_day_of(datetime.date(2023, 3, 31), "month"), datetime.date(2023, 3, 31) + ) + + def test_last_day_of_week(self): + self.assertEqual( + last_day_of(datetime.date(2023, 4, 17), "week"), datetime.date(2023, 4, 23) + ) + self.assertEqual( + last_day_of(datetime.date(2023, 2, 28), "week"), datetime.date(2023, 3, 5) + ) + self.assertEqual( + last_day_of(datetime.date(2023, 4, 23), "week"), datetime.date(2023, 4, 23) + ) + + def test_last_day_of_unknown(self): + # Test the current behavior for unknown "kind" + today = datetime.date.today() + yesterday = today - relativedelta(days=1) + self.assertEqual(last_day_of(datetime.date(2023, 4, 17), "unknown"), yesterday) diff --git a/general_ledger/tests/utils/test_inspect.py b/general_ledger/tests/utils/test_inspect.py new file mode 100644 index 0000000..59c0783 --- /dev/null +++ b/general_ledger/tests/utils/test_inspect.py @@ -0,0 +1,71 @@ +import pytest + +from rich.console import Console +from rich import print as rprint +from rich.pretty import Pretty + +from general_ledger.django.models import Account, Transaction, Invoice +from general_ledger.factories import BookFactory, TransactionFactory +from general_ledger.factories.invoice import InvoiceFactory +from general_ledger.utils.inspect import inspect +from rich import inspect as rich_inspect + +console = Console() + + +class TestInspectWrapper: + """ + Balance Sheet stuff + """ + + @pytest.mark.django_db + def test_inspect_wrapper(self): + + book = BookFactory() + ledger = book.get_default_ledger() + coa = book.get_default_coa() + + rich_inspect(ledger) + inspect(ledger) + console.print(ledger) + print(ledger) + rprint(ledger) + + account_set = Account.objects.all() + inspect(account_set) + + @pytest.mark.django_db + def test_inspect_wrapper_2(self): + + print("") + + book = BookFactory() + ledger = book.get_default_ledger() + coa = book.get_default_coa() + + transactions = TransactionFactory.create_batch( + 50, + ledger=ledger, + ) + + account_set = Account.objects.all() + console.print(account_set) + + txs = Transaction.objects.all() + print("") + console.print(txs) + print("") + + console.print(txs[0]) + + print("") + + console.print(account_set[0]) + + console.print(Pretty(account_set[0])) + + invoices = InvoiceFactory.create_batch( + 10, + ) + + rprint(Invoice.objects.all()) diff --git a/general_ledger/tests/utils/test_utility.py b/general_ledger/tests/utils/test_utility.py new file mode 100644 index 0000000..ea92662 --- /dev/null +++ b/general_ledger/tests/utils/test_utility.py @@ -0,0 +1,22 @@ +from general_ledger.utils.utility import str_to_bool + +from general_ledger.utils.utility import is_iterable + + +def test_str_to_bool(): + assert str_to_bool("True") == True + assert str_to_bool("true") == True + assert str_to_bool("1") == True + assert str_to_bool("yes") == True + assert str_to_bool("y") == True + assert str_to_bool("on") == True + assert str_to_bool("false") == False + # assert str_to_bool("") == False + + +def test_is_iterable(): + + assert is_iterable([1, 2, 3]) == True + assert is_iterable("hello") == False + assert is_iterable(123) == False + assert is_iterable({1: 2, 3: 4}) == True diff --git a/general_ledger/tests/views/test_inventory.py b/general_ledger/tests/views/test_inventory.py index 8917c80..fd6866b 100644 --- a/general_ledger/tests/views/test_inventory.py +++ b/general_ledger/tests/views/test_inventory.py @@ -9,7 +9,6 @@ @pytest.mark.django_db def test_inventory_basic_1(user, client): - # inspect(user) client.force_login(user) book = BookFactory( diff --git a/general_ledger/urls_api.py b/general_ledger/urls_api.py index b957680..503702d 100644 --- a/general_ledger/urls_api.py +++ b/general_ledger/urls_api.py @@ -4,8 +4,10 @@ SpectacularSwaggerView, SpectacularRedocView, ) +from dynamic_preferences.users.viewsets import UserPreferencesViewSet from rest_framework import routers +from dashboard.views.book_preference_viewset import BookPreferenceViewSet from general_ledger.views.api import ( PaymentViewSet, BankAccountViewSet, @@ -14,8 +16,8 @@ TransactionViewSet, ) from general_ledger.views.api.account import AccountViewSet -from general_ledger.views.api.bank_statement import BankStatementGroupedView from general_ledger.views.api.bank_balance import BankBalanceViewSet +from general_ledger.views.api.bank_statement import BankStatementGroupedView from general_ledger.views.api.contact import ContactViewSet from general_ledger.views.api.invoice import InvoiceViewSet @@ -32,6 +34,14 @@ router.register(r"bank_balances", BankBalanceViewSet) # router.register(r"invoicelines", InvoiceLineViewSet) +# router.register( +# r"book_preference", +# BookPreferenceViewSet, +# "book_preference", +# ) +router.register(r"user", UserPreferencesViewSet, "user") + + urlpatterns = [ path("", include(router.urls)), # YOUR PATTERNS diff --git a/general_ledger/utils/balance_sheet.py b/general_ledger/utils/balance_sheet.py deleted file mode 100644 index b4ddd57..0000000 --- a/general_ledger/utils/balance_sheet.py +++ /dev/null @@ -1,33 +0,0 @@ -from general_ledger.models import AccountType - - -class BalanceSheet: - def __init__(self, ledger): - self.ledger = ledger - if not self.ledger: - raise ValueError("Ledger is required") - - def get_non_current_asset_accounts(self): - return self.ledger.coa.account_set.non_current_asset() - - def get_current_asset_accounts(self): - return self.ledger.coa.account_set.current_asset() - - def get_entries_for_transactions_for_account(self, account): - return self.ledger.entries.filter(account=account) - - def get_current_liabilities_accounts(self): - return self.ledger.coa.account_set.current_liability().order_by( - "-type__liquidity" - ) - - def get_non_current_liabilities_accounts(self): - return self.ledger.coa.account_set.non_current_liability().order_by( - "-type__liquidity" - ) - - def get_total_equity(self): - return self.equity - - def get_total_liabilities_and_equity(self): - return self.liabilities + self.equity diff --git a/general_ledger/utils/config.py b/general_ledger/utils/config.py new file mode 100644 index 0000000..a48933f --- /dev/null +++ b/general_ledger/utils/config.py @@ -0,0 +1,16 @@ +from typing import Optional + + +class Config: + """this is a singleton class that for config items that need to be migrated into dedicated classes""" + + _instance: Optional["Config"] = None + + def __new__(cls) -> "Config": + instance: Config + if cls._instance is None: + instance = object.__new__(cls) + cls._instance = instance + else: + instance = cls._instance + return instance diff --git a/general_ledger/utils/data_loader.py b/general_ledger/utils/data_loader.py new file mode 100644 index 0000000..a281c12 --- /dev/null +++ b/general_ledger/utils/data_loader.py @@ -0,0 +1,43 @@ +from decimal import Decimal + +from general_ledger.django.models.account import Account +from general_ledger.django.models.direction import Direction +from general_ledger.django.models.ledger import Ledger +from general_ledger.helpers.ledger_helper import LedgerHelper + + +def tx( + ledger: "Ledger", + dr: "Account", + amt: Decimal | int | str, + dt, + cr: "Account", + description="Chapter 3.8 data", +): + """ + convenience function to post a transaction with two entries + :param ledger: + :param dr: the account to debit + :param amt: a Decimal amount + :param dt: a datetime.date + :param cr: the account to credit + :param description: + :return: + """ + return LedgerHelper.post_transaction( + ledger, + description, + dt, + [ + [ + dr, + amt, + Direction.DEBIT, + ], + [ + cr, + amt, + Direction.CREDIT, + ], + ], + ) diff --git a/general_ledger/utils/django.py b/general_ledger/utils/django.py new file mode 100644 index 0000000..0153c65 --- /dev/null +++ b/general_ledger/utils/django.py @@ -0,0 +1,20 @@ +from django.contrib import admin + + +class DjangoUtil: + @staticmethod + def get_fields(model): + """find a subset of fields for display in a table or UI""" + fields = [getattr(field, "name") for field in model._meta.fields] + if hasattr(model, "generic_list_display"): + return [x for x in fields if x in model.generic_list_display] + elif hasattr(model, "list_display"): + return [x for x in fields if x in model.list_display] + else: + try: + modeladmin = admin.site._registry[model] + if modeladmin: + return [x for x in fields if x in modeladmin.list_display] + except KeyError: + pass + return [getattr(field, "name") for field in model._meta.fields] diff --git a/general_ledger/utils/inspect.py b/general_ledger/utils/inspect.py new file mode 100644 index 0000000..51bad9a --- /dev/null +++ b/general_ledger/utils/inspect.py @@ -0,0 +1,49 @@ +import importlib +import inspect as std_inspect + +from rich import inspect as rich_inspect +from rich.columns import Columns +from rich.console import Console + +models = importlib.import_module("general_ledger.django.models") + +from general_ledger.render.utility_rich_custom import Table +from functools import wraps + +console = Console() + + +def make_table(account): + my_table = Table("Attribute", "Value") + # my_table.add_row("id", fmt(account.id)) + my_table.add_row("name", account.name) + my_table.add_row("code", str(account.code)) + return my_table + + +@wraps(rich_inspect) +def inspect(item, *args, **kwargs): + caller_frame = std_inspect.stack()[1] + caller_file = caller_frame.filename + caller_lineno = caller_frame.lineno + print(f"Called from {caller_file}, line {caller_lineno}") + # print( std_inspect.stack()) + # traceback.print_stack(file=sys.stdout + if isinstance(item, models.Ledger): + print("we printing in ledger things") + # check if ledger is initialized? + accounts = item.coa.account_set.all() + columns = Columns( + accounts, + # expand=True, + # equal=True, + align="left", + ) + print(type(columns)) + console.print(columns) + else: + rich_inspect(item, *args, **kwargs) + + +inspect.__doc__ = rich_inspect.__doc__ +inspect.__signature__ = std_inspect.signature(rich_inspect) diff --git a/general_ledger/utils/utility.py b/general_ledger/utils/utility.py new file mode 100644 index 0000000..0cabe45 --- /dev/null +++ b/general_ledger/utils/utility.py @@ -0,0 +1,185 @@ +from colorama import Fore, Style + +from rich import inspect + +import hashlib +from rich.style import Style as RichStyle +import functools +from loguru import logger + + +# logger = logger.opt(colors=True) + +def logger_wraps(*, entry=True, exit=True, level="DEBUG"): + def wrapper(func): + name = func.__name__ + + @functools.wraps(func) + def wrapped(*args, **kwargs): + logger_ = logger.opt(depth=1) + if entry: + logger_.log( + level, "Entering '{}' (args={}, kwargs={})", name, args, kwargs + ) + result = func(*args, **kwargs) + if exit: + logger_.log(level, "Exiting '{}' (result={})", name, result) + return result + + return wrapped + + return wrapper + + +def logger_init(*, entry=True, exit=True, level="DEBUG"): + def wrapper(func): + name = func.__name__ + + @functools.wraps(func) + def wrapped(*args, **kwargs): + logger_ = logger.opt(depth=1) + if entry: + logger_.log( + level, + "Entering '{}' (args={}, kwargs={})", + name, + args, + kwargs, + ) + result = func(*args, **kwargs) + if exit: + logger_.log(level, "Exiting '{}' (result={})", name, result) + return result + + return wrapped + + return wrapper + + +def is_iterable(obj): + """Check if an object is iterable. except strings""" + if isinstance(obj, str): + return False + try: + iter(obj) + return True + except TypeError: + return False + + +def bool_colorize(match): + content = match.group(1) + if str_to_bool(content): + return f"{Fore.GREEN}{content}{Style.RESET_ALL}" # Green for True + elif content == "False": + return f"{Fore.RED}{content}{Style.RESET_ALL}" # Red for False + return content # Just in case there's something unexpected + + +def b(content): + """Decorator to colorize boolean values in the output.""" + if str_to_bool(content): + return f"{Fore.GREEN}{content} {Style.RESET_ALL}" # Green for True + elif not str_to_bool(content): + return f"{Fore.RED}{content}{Style.RESET_ALL}" + raise ValueError(f"Cannot interpret '{content}' as a boolean") + + +def str_to_bool(value): + """Converts a string to its truthy or falsy boolean equivalent.""" + if value is True or value is False: + return value + truthy_values = {"true", "1", "yes", "y", "on"} + falsy_values = {"false", "0", "no", "n", "off"} + + value_lower = value.strip().lower() # Normalize the input + + if value_lower in truthy_values: + return True + elif value_lower in falsy_values: + return False + else: + raise ValueError(f"Cannot interpret '{value}' as a boolean") + + +max_transition_len = 18 + + +def log_change( + logger, + transition, + accumulated, + node, + context, +): + global max_transition_len + max_transition_len = min(max(max_transition_len, len(transition)), 18) + logger.trace( + "{:<{max_transition_len}.{max_transition_len}}: {:14.14} [{:>6}:{:>6}] {} {} {} {} sub:{}", + node.label, + transition, + node.value, + accumulated, + b(node.is_leaf), + b(node.is_visible), + context.current_level, + "{:<4}".format(node.meta.operation.name), + context.get_current_total(), + max_transition_len=max_transition_len, + ) + + +max_message_len = 18 + + +def visit_logger( + logger, + message, + node, + current_level, + logger_level="INFO", +): + global max_message_len + logger_ = logger.opt(depth=1, colors=True) + max_message_len = min(max(max_message_len, len(message)), 18) + logger_.log( + logger_level, + "{:18.18} [{:>6}] {} {} {} {message!r}", + node.label, + node.value, + b(node.is_leaf), + current_level, + "{:<4}".format(node.meta.operation.name), + message=message, + max_message_len=max_message_len, + ) + + +def string_to_color(input_string: str) -> RichStyle: + # Create a hash of the input string + hash_object = hashlib.md5(input_string.encode()) + hash_hex = hash_object.hexdigest() + + # Use the first 6 characters of the hash to create a color + r = int(hash_hex[:2], 16) % 64 + 192 # Ensure the value is in the range 192-255 + g = int(hash_hex[2:4], 16) % 64 + 192 + b = int(hash_hex[4:6], 16) % 64 + 192 + color_code = f"#{r:02x}{g:02x}{b:02x}" + + # Create a rich Style with the generated color + style = RichStyle(bgcolor=color_code) + return style + + +def slugu(text, sep="_"): + """ + Convert a string to a slug. + """ + chars_to_replace = [" ", ".", ",", "-"] + for char in chars_to_replace: + text = text.replace(char, sep) + return text.lower() + + +def raiseu(): + raise ValueError("This error thrown by raiseu() function") diff --git a/general_ledger/utils/utility_date_stuff.py b/general_ledger/utils/utility_date_stuff.py new file mode 100644 index 0000000..2e3c215 --- /dev/null +++ b/general_ledger/utils/utility_date_stuff.py @@ -0,0 +1,86 @@ +import datetime +from collections import OrderedDict +from collections import namedtuple +from enum import Enum + +from dateutil.relativedelta import relativedelta + +EntryObject = namedtuple( + "EntryObject", + [ + "trans_date", + "narrative", + "amount", + ], +) + + +class PeriodKind(str, Enum): + """Enumeration of valid period types""" + + YEAR = "year" + MONTH = "month" + WEEK = "week" + DAY = "day" + + +def get_interval_key(date, group_intervals): + key = [] + for interval in group_intervals: + if interval == "year": + key.append(date.year) + elif interval == "month": + key.append(date.year) + key.append(date.month) + elif interval == "week": + key.append(date.year) + key.append(date.isocalendar()[1]) + elif interval == "day": + key.append(date.year) + key.append(date.month) + key.append(date.day) + return tuple(OrderedDict.fromkeys(key)) if key else "none" + + +def last_day_of(dt, kind): + """ + this function returns the last day of the period + :param dt: + :param kind: + :return: + """ + last_day = None + if kind == "year": + last_day = (dt + relativedelta(years=1)).replace( + month=1, day=1 + ) - relativedelta(days=1) + elif kind == "month": + last_day = (dt + relativedelta(months=1)).replace(day=1) - relativedelta(days=1) + elif kind == "week": + last_day = dt + relativedelta(days=(7 - dt.isoweekday())) + else: + # @TODO this should throw error ii the kind is not recognized + # but it is being used somewhere to fall through + last_day = datetime.datetime.today() - relativedelta(days=1) + # else: + # raise ValueError(f"kind: {kind} not supported") + result = last_day if type(last_day) is datetime.date else last_day.date() + return result + + +def first_day_of_next(dt, kind): + first_day = last_day_of(dt, kind) + relativedelta(days=1) + return first_day + + +def pad_around_char(date_str, char="."): + """Pads a date string centered on the dot. convenience method for columnar dates + + Args: + date_str: The date string to pad, in the format "day.month". + + Returns: + The padded date string. + """ + day, month = date_str.split(char) + return f"{day.rjust(2)}{char}{month.ljust(2)}" diff --git a/general_ledger/views/account.py b/general_ledger/views/account.py index 11f6ebf..6a2d7d8 100644 --- a/general_ledger/views/account.py +++ b/general_ledger/views/account.py @@ -3,8 +3,8 @@ from django.views.generic import ListView, CreateView from django_filters.views import FilterView -from general_ledger.filters import AccountFilter -from general_ledger.models import Account +from general_ledger.django.filters import AccountFilter +from general_ledger.django.models import Account from general_ledger.views.generic import GenericDetailView, GenericUpdateView from general_ledger.views.mixins import ( GeneralLedgerSecurityMixIn, diff --git a/general_ledger/views/api/account.py b/general_ledger/views/api/account.py index 32e999b..3efb83c 100644 --- a/general_ledger/views/api/account.py +++ b/general_ledger/views/api/account.py @@ -2,7 +2,7 @@ from django.contrib.auth.models import User from rest_framework import routers, serializers, viewsets -from general_ledger.models import Contact, Account +from general_ledger.django.models import Contact, Account from general_ledger.serializers.account import AccountSerializer from general_ledger.serializers.contact import ContactSerializer diff --git a/general_ledger/views/api/bank_account.py b/general_ledger/views/api/bank_account.py index 980c235..60116b0 100644 --- a/general_ledger/views/api/bank_account.py +++ b/general_ledger/views/api/bank_account.py @@ -2,7 +2,7 @@ from django.contrib.auth.models import User from rest_framework import routers, serializers, viewsets -from general_ledger.models import Contact, Bank +from general_ledger.django.models import Contact, Bank from general_ledger.serializers.bank_account import BankAccountSerializer from general_ledger.serializers.contact import ContactSerializer diff --git a/general_ledger/views/api/bank_balance.py b/general_ledger/views/api/bank_balance.py index ce09a7a..f4b12cf 100644 --- a/general_ledger/views/api/bank_balance.py +++ b/general_ledger/views/api/bank_balance.py @@ -1,7 +1,7 @@ from rest_framework import viewsets from rich import inspect -from general_ledger.models import BankBalance +from general_ledger.django.models import BankBalance from general_ledger.serializers.bank_balance import BankBalanceSerializer from datetime import datetime, timedelta diff --git a/general_ledger/views/api/bank_statement.py b/general_ledger/views/api/bank_statement.py index a4369b7..d3a209f 100644 --- a/general_ledger/views/api/bank_statement.py +++ b/general_ledger/views/api/bank_statement.py @@ -3,12 +3,11 @@ from django.db import connection from django.db.models import Sum from django.db.models.functions import TruncDate -from rest_framework import generics, viewsets +from rest_framework import viewsets from rest_framework.response import Response -from rich import inspect -from general_ledger.filters.bank_transaction import BankStatementFilter -from general_ledger.models import BankStatementLine +from general_ledger.django.filters import BankStatementFilter +from general_ledger.django.models import BankStatementLine from general_ledger.serializers.bank_transaction import BankStatementGroupedSerializer from datetime import datetime, timedelta @@ -62,21 +61,15 @@ def list(self, request, *args, **kwargs): for item in queryset } - # inspect(data_dict, methods=False) - # Generate a complete date range and fill in missing dates with zero complete_data = [] current_date = start_date while current_date <= end_date: date_str = current_date.strftime("%Y-%m-%d") amount = data_dict.get(date_str, Decimal("0")) - # inspect(f"{current_date=}, {amount=}") - # print(f"{current_date=}, {amount=}") complete_data.append({"date": date_str, "amount": amount}) current_date += timedelta(days=1) - # inspect(complete_data, methods=False) - serializer = self.get_serializer(complete_data, many=True) else: print(f"not filtered") diff --git a/general_ledger/views/api/book.py b/general_ledger/views/api/book.py index c5e9b69..80aa613 100644 --- a/general_ledger/views/api/book.py +++ b/general_ledger/views/api/book.py @@ -2,7 +2,7 @@ from django.contrib.auth.models import User from rest_framework import routers, serializers, viewsets -from general_ledger.models import Contact, Book +from general_ledger.django.models import Contact, Book from general_ledger.serializers.book import BookSerializer from general_ledger.serializers.contact import ContactSerializer diff --git a/general_ledger/views/api/contact.py b/general_ledger/views/api/contact.py index 1495bf1..4e7a90a 100644 --- a/general_ledger/views/api/contact.py +++ b/general_ledger/views/api/contact.py @@ -2,7 +2,7 @@ from django.contrib.auth.models import User from rest_framework import routers, serializers, viewsets -from general_ledger.models import Contact +from general_ledger.django.models import Contact from general_ledger.serializers.contact import ContactSerializer diff --git a/general_ledger/views/api/invoice.py b/general_ledger/views/api/invoice.py index 1c74446..2cee9fa 100644 --- a/general_ledger/views/api/invoice.py +++ b/general_ledger/views/api/invoice.py @@ -1,6 +1,6 @@ from rest_framework import permissions, viewsets -from general_ledger.models import Invoice +from general_ledger.django.models import Invoice from general_ledger.serializers.invoice import InvoiceSerializer diff --git a/general_ledger/views/api/invoice_line.py b/general_ledger/views/api/invoice_line.py index eaf210c..86b3976 100644 --- a/general_ledger/views/api/invoice_line.py +++ b/general_ledger/views/api/invoice_line.py @@ -1,6 +1,6 @@ from rest_framework import permissions, viewsets -from general_ledger.models import Invoice, InvoiceLine +from general_ledger.django.models import Invoice, InvoiceLine from general_ledger.serializers.invoice import InvoiceSerializer from general_ledger.serializers.invoice_line import InvoiceLineSerializer diff --git a/general_ledger/views/api/ledger.py b/general_ledger/views/api/ledger.py index 178edef..08dc44b 100644 --- a/general_ledger/views/api/ledger.py +++ b/general_ledger/views/api/ledger.py @@ -2,7 +2,7 @@ from django.contrib.auth.models import User from rest_framework import routers, serializers, viewsets -from general_ledger.models import Contact, Ledger +from general_ledger.django.models import Contact, Ledger from general_ledger.serializers.contact import ContactSerializer from general_ledger.serializers.ledger import LedgerSerializer diff --git a/general_ledger/views/api/payment.py b/general_ledger/views/api/payment.py index f4cec9e..3fa584a 100644 --- a/general_ledger/views/api/payment.py +++ b/general_ledger/views/api/payment.py @@ -1,6 +1,6 @@ from rest_framework import permissions, viewsets -from general_ledger.models import Invoice, Payment +from general_ledger.django.models import Invoice, Payment from general_ledger.serializers.invoice import InvoiceSerializer from general_ledger.serializers.payment import PaymentSerializer diff --git a/general_ledger/views/api/transaction.py b/general_ledger/views/api/transaction.py index 52022f3..0a1fa49 100644 --- a/general_ledger/views/api/transaction.py +++ b/general_ledger/views/api/transaction.py @@ -2,7 +2,7 @@ from django.contrib.auth.models import User from rest_framework import routers, serializers, viewsets -from general_ledger.models import Transaction +from general_ledger.django.models import Transaction from general_ledger.serializers.transaction import TransactionSerializer diff --git a/general_ledger/views/bank.py b/general_ledger/views/bank.py index f7654ff..5d10185 100644 --- a/general_ledger/views/bank.py +++ b/general_ledger/views/bank.py @@ -7,15 +7,15 @@ from django_filters.views import FilterView from formset.views import FormViewMixin -import general_ledger from general_ledger.forms.bank import BankForm -from general_ledger.models import Bank +from general_ledger.django.models import Bank from general_ledger.views.generic import GenericListView from general_ledger.views.mixins import ( GeneralLedgerSecurityMixIn, ActiveBookRequiredMixin, ) -from general_ledger.filters.bank_account_filter import BankAccountFilter +from general_ledger.django.filters import BankAccountFilter + class BankListView( GeneralLedgerSecurityMixIn, diff --git a/general_ledger/views/bank_matching.py b/general_ledger/views/bank_matching.py index 2b4fbeb..9654d1e 100644 --- a/general_ledger/views/bank_matching.py +++ b/general_ledger/views/bank_matching.py @@ -7,7 +7,7 @@ from general_ledger.forms.matching_xfer import TransferForm from general_ledger.forms.payment import PaymentCreateForm from general_ledger.forms.payment_edit import PaymentEditForm -from general_ledger.models import BankStatementLine, Payment, Payment +from general_ledger.django.models import BankStatementLine, Payment, Payment from general_ledger.views.mixins import ( GeneralLedgerSecurityMixIn, ActiveBookRequiredMixin, diff --git a/general_ledger/views/bank_statement_import.py b/general_ledger/views/bank_statement_import.py index 84f42a6..bba16b2 100644 --- a/general_ledger/views/bank_statement_import.py +++ b/general_ledger/views/bank_statement_import.py @@ -6,7 +6,7 @@ from tablib import Dataset from general_ledger.forms.bank_statement_import_form import BankStatementImportForm -from general_ledger.models import Bank +from general_ledger.django.models import Bank from general_ledger.resources.bank_statement_import import BankStatementTsbCsvResource from general_ledger.views.mixins import ( GeneralLedgerSecurityMixIn, diff --git a/general_ledger/views/bank_transactions.py b/general_ledger/views/bank_transactions.py index d62d05a..32ee6a7 100644 --- a/general_ledger/views/bank_transactions.py +++ b/general_ledger/views/bank_transactions.py @@ -8,7 +8,7 @@ from general_ledger.forms.bank import BankForm from general_ledger.forms.bank_transaction import BankTransactionForm -from general_ledger.models import Bank, BankStatementLine +from general_ledger.django.models import Bank, BankStatementLine from general_ledger.views.mixins import ( GeneralLedgerSecurityMixIn, ActiveBookRequiredMixin, @@ -27,7 +27,7 @@ def get_queryset(self): return BankStatementLine.objects.filter(bank_id=bank_id) model = BankStatementLine - template_name = "gl/bank_transaction_list.html.j2" + template_name = "gl/bank/bank_transaction_list.html.j2" context_object_name = "transactions" # filterset_class = BankFilter diff --git a/general_ledger/views/bill.py b/general_ledger/views/bill.py index a0aa964..d4542f2 100644 --- a/general_ledger/views/bill.py +++ b/general_ledger/views/bill.py @@ -1,10 +1,9 @@ -from django.contrib.auth.mixins import LoginRequiredMixin from django.urls import reverse_lazy from django.views.generic import ListView, CreateView from django_filters.views import FilterView -from general_ledger.filters import BillFilter -from general_ledger.models import PurchaseInvoice +from general_ledger.django.filters import BillFilter +from general_ledger.django.models import PurchaseInvoice from general_ledger.views.mixins import ActiveBookRequiredMixin from general_ledger.views.mixins import GeneralLedgerSecurityMixIn diff --git a/general_ledger/views/book.py b/general_ledger/views/book.py index 7984d50..3268ee6 100644 --- a/general_ledger/views/book.py +++ b/general_ledger/views/book.py @@ -3,7 +3,7 @@ from django.views.generic import ListView, CreateView from django_filters.views import FilterView -from general_ledger.models import Bank, Book +from general_ledger.django.models import Bank, Book from general_ledger.views.generic import GenericListView, GenericDetailView from general_ledger.views.mixins import ( GeneralLedgerSecurityMixIn, @@ -27,6 +27,9 @@ class BookListView( # paginate_by = 25 # filterset_class = BankFilter + def get_queryset(self): + return self.model.objects.for_user(self.request.user) + class BookDetailView( LoginRequiredMixin, diff --git a/general_ledger/views/book_preferences.py b/general_ledger/views/book_preferences.py index b03a540..235e420 100644 --- a/general_ledger/views/book_preferences.py +++ b/general_ledger/views/book_preferences.py @@ -2,7 +2,7 @@ from dynamic_preferences.views import PreferenceFormView from dashboard.forms.book_preferences import book_preference_form_builder -from general_ledger.models import Book +from general_ledger.django.models import Book from general_ledger.views import GeneralLedgerSecurityMixIn from general_ledger.views.mixins import ActiveBookRequiredMixin diff --git a/general_ledger/views/contact.py b/general_ledger/views/contact.py index f5a5d6e..c94d6f8 100644 --- a/general_ledger/views/contact.py +++ b/general_ledger/views/contact.py @@ -4,16 +4,14 @@ from django.http import JsonResponse from django.urls import reverse_lazy from django.views.generic import ( - ListView, UpdateView, - DetailView, ) from django_filters.views import FilterView from formset.views import IncompleteSelectResponseMixin, FormViewMixin -from general_ledger.filters import ContactFilter +from general_ledger.django.filters import ContactFilter from general_ledger.forms.contact import ContactUpdateForm -from general_ledger.models import Contact +from general_ledger.django.models import Contact from general_ledger.views.generic import GenericListView, GenericDetailView from general_ledger.views.mixins import ActiveBookRequiredMixin, FormsetifyMixin from general_ledger.views.mixins import GeneralLedgerSecurityMixIn diff --git a/general_ledger/views/file_upload.py b/general_ledger/views/file_upload.py index 44b69dc..6670a8e 100644 --- a/general_ledger/views/file_upload.py +++ b/general_ledger/views/file_upload.py @@ -8,7 +8,7 @@ DetailView, ) from general_ledger.forms.file_upload import FileUploadForm -from general_ledger.models import FileUpload +from general_ledger.django.models import FileUpload from ofxparse import OfxParser from general_ledger.views import GeneralLedgerSecurityMixIn diff --git a/general_ledger/views/formset/invoice_formsetified.py b/general_ledger/views/formset/invoice_formsetified.py index 5565899..4d26658 100644 --- a/general_ledger/views/formset/invoice_formsetified.py +++ b/general_ledger/views/formset/invoice_formsetified.py @@ -8,7 +8,7 @@ from general_ledger.forms.formset.invoice_collection import InvoiceCollection -from general_ledger.models import Invoice +from general_ledger.django.models import Invoice from general_ledger.views.formset.formset_mixins import ( CollectionViewMixin, SessionFormCollectionMixin, diff --git a/general_ledger/views/home.py b/general_ledger/views/home.py index 68fd5b9..b397bef 100644 --- a/general_ledger/views/home.py +++ b/general_ledger/views/home.py @@ -10,6 +10,18 @@ class HomeView( ActiveBookRequiredMixin, TemplateView, ): + + def get_context_data(self, **kwargs): + # print(f"caling get_context_data in HomeView") + context = super().get_context_data(**kwargs) + # print(f"{context=}") + user = self.request.user + + context["layout"] = user.preferences.get("layout__dashboard_layout_json") + + # print(context["layout"]) + return context + template_name = "gl/home.html.j2" diff --git a/general_ledger/views/invoice.py b/general_ledger/views/invoice.py index aa19cbe..f1dd772 100644 --- a/general_ledger/views/invoice.py +++ b/general_ledger/views/invoice.py @@ -6,19 +6,17 @@ from django.shortcuts import render, redirect from django.urls import reverse_lazy, reverse from django.views.generic import ( - ListView, CreateView, UpdateView, ) from django_filters.views import FilterView -from rich import inspect -from general_ledger.filters import InvoiceFilter +from general_ledger.django.filters import InvoiceFilter from general_ledger.forms import InvoiceForm, InvoiceLineFormSet from general_ledger.forms.contact_inline import ContactInlineForm from general_ledger.forms.invoice import create_invoice_line_formset from general_ledger.forms.invoice_status import InvoiceStatusForm -from general_ledger.models import Invoice +from general_ledger.django.models import Invoice from general_ledger.views.generic import GenericListView, GenericDetailView from general_ledger.views.history.history import HistoryView from general_ledger.views.mixins import ( diff --git a/general_ledger/views/payment.py b/general_ledger/views/payment.py index 1601079..4a0a044 100644 --- a/general_ledger/views/payment.py +++ b/general_ledger/views/payment.py @@ -6,8 +6,8 @@ from general_ledger.forms.payment import PaymentCreateForm from general_ledger.forms.payment_edit import PaymentEditForm -from general_ledger.models import BankStatementLine, Payment, Payment -from general_ledger.models.document_status import DocumentStatus +from general_ledger.django.models import BankStatementLine, Payment, Payment +from general_ledger.django.models.document_status import DocumentStatus from general_ledger.views.generic import GenericListView from general_ledger.views.mixins import ( GeneralLedgerSecurityMixIn, diff --git a/general_ledger/views/reports/income_statement.py b/general_ledger/views/reports/income_statement.py index 18345be..e2a875b 100644 --- a/general_ledger/views/reports/income_statement.py +++ b/general_ledger/views/reports/income_statement.py @@ -1,53 +1,102 @@ -from pprint import pprint +from datetime import datetime + +from bs4 import BeautifulSoup as bs -from django.views.generic import TemplateView -from django.template import TemplateDoesNotExist -from django.http import Http404 # import pprint -from pprint import pprint, pp from django.utils import timezone -from datetime import datetime +from django.views.generic import TemplateView +from rich.console import Console + +from general_ledger.helpers.ledger_helper import LedgerHelper +from general_ledger.render.format_statement_j2 import StatementFormatJ2 +from general_ledger.render.renderer_statement import StatementRenderer +from general_ledger.statements.meta import NodeMeta +from general_ledger.statements.nodes.income_statement import IncomeStatementNode +from general_ledger.statements.provider_django import DjangoProvider + +console = Console() + class IncomeStatementView(TemplateView): - template_name = "gl/income_statement.html.j2" + template_name = "gl/statements/income_statement.html.j2" def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - context["from_date"] = self.request.session.get('from_date', None) - context["to_date"] = self.request.session.get('to_date', None) + context["from_date"] = self.request.session.get("from_date", None) + context["to_date"] = self.request.session.get("to_date", None) return context - def get(self, request, *args, **kwargs): context = self.get_context_data(**kwargs) - from_date = request.GET.get('from_date') or request.session.get('from_date') - to_date = request.GET.get('to_date') or request.session.get('to_date') - + from_date = request.GET.get("from_date") or request.session.get("from_date") + to_date = request.GET.get("to_date") or request.session.get("to_date") if not from_date: today = timezone.now().date() - from_date = today.replace(month=1, day=1) + from_date = today - timezone.timedelta(days=365) else: - from_date = datetime.strptime(from_date, '%Y-%m-%d').date() + from_date = datetime.strptime(from_date, "%Y-%m-%d").date() if not to_date: today = timezone.now().date() to_date = today else: - to_date = datetime.strptime(to_date, '%Y-%m-%d').date() + to_date = datetime.strptime(to_date, "%Y-%m-%d").date() # Store the dates in the session - request.session['from_date'] = from_date.strftime('%Y-%m-%d') - request.session['to_date'] = to_date.strftime('%Y-%m-%d') + request.session["from_date"] = from_date.strftime("%Y-%m-%d") + request.session["to_date"] = to_date.strftime("%Y-%m-%d") + + # print(f"from_date: {from_date}") + # print(f"to_date: {to_date}") + + # pp(context) + + context["from_date"] = from_date.strftime("%Y-%m-%d") + context["to_date"] = to_date.strftime("%Y-%m-%d") + + ledger = self.request.active_book.get_default_ledger() + + # ledger = load_chapter_7_review_7_5() + ledger_accounts = LedgerHelper.ledger_accounts(ledger) + + kwargs = { + "balance_interval": "month", + "start_date": "2023-9-1", + "end_date": "2023-9-30", + "ledger": ledger, + "final_balance": True, + } + + provider = DjangoProvider(ledger=ledger) + statement = IncomeStatementNode( + provider=provider, + meta=NodeMeta( + show_subtotal=True, + ), + label="Income Statement", + **kwargs, + ).ensure_expanded() + + statement.set_renderer(StatementRenderer()) + renderer = statement.renderer + renderer.print_console = False + renderer.return_renderable = True + statement.set_render_format( + "statement", + StatementFormatJ2(), + ).render() + + html_rendered = statement.render() + + # console.print(html_rendered) - print(f"from_date: {from_date}") - print(f"to_date: {to_date}") + soup = bs(html_rendered) - #pp(context) + # console.print(soup.prettify()) - context["from_date"] = from_date.strftime('%Y-%m-%d') - context["to_date"] = to_date.strftime('%Y-%m-%d') + context["statement"] = html_rendered return self.render_to_response(context) diff --git a/general_ledger/views/reports/three_col_accounts.py b/general_ledger/views/reports/three_col_accounts.py index 31199cf..ad9314c 100644 --- a/general_ledger/views/reports/three_col_accounts.py +++ b/general_ledger/views/reports/three_col_accounts.py @@ -5,7 +5,7 @@ from general_ledger.forms import ThreeColumnAccountsForm from general_ledger.forms.trial_balance_date_form import TrialBalanceDateForm -from general_ledger.models import Account +from general_ledger.django.models import Account class ThreeColumnAccounts(FormView): diff --git a/general_ledger/views/reports/trial_balance.py b/general_ledger/views/reports/trial_balance.py index d6d922c..d1b9a9d 100644 --- a/general_ledger/views/reports/trial_balance.py +++ b/general_ledger/views/reports/trial_balance.py @@ -4,7 +4,7 @@ from django.views.generic.edit import FormView from general_ledger.forms.trial_balance_date_form import TrialBalanceDateForm -from general_ledger.models import Account +from general_ledger.django.models import Account from general_ledger.views.mixins import ( GeneralLedgerSecurityMixIn, diff --git a/general_ledger/views/urls/banks.py b/general_ledger/views/urls/banks.py index bef8708..6012341 100644 --- a/general_ledger/views/urls/banks.py +++ b/general_ledger/views/urls/banks.py @@ -3,7 +3,7 @@ from django.contrib.auth.decorators import login_required from django.urls import path -from general_ledger.models import Bank +from general_ledger.django.models import Bank from general_ledger.views.bank import BankListView, BankDetailView, BankUpdateView from general_ledger.views.bank_matching import BankReconciliation from general_ledger.views.bank_transactions import BankTransactionsListView diff --git a/general_ledger/views/utils.py b/general_ledger/views/utils.py index 49ca8f8..3f0e36f 100644 --- a/general_ledger/views/utils.py +++ b/general_ledger/views/utils.py @@ -2,7 +2,7 @@ from django.shortcuts import render, redirect from django.contrib.auth.decorators import login_required -from general_ledger.models import Book +from general_ledger.django.models import Book from django.views import View @@ -44,6 +44,7 @@ def select_active_entity(request): }, ) + class ServerResponseSimulator(View): def get(self, request): raise Exception("This is a test exception") diff --git a/notebooks/tutorial-1.ipynb b/notebooks/tutorial-1.ipynb index 41b4572..a554ad5 100644 --- a/notebooks/tutorial-1.ipynb +++ b/notebooks/tutorial-1.ipynb @@ -119,31 +119,31 @@ "\n" ], "text/plain": [ - "\u001b[34m╭─\u001b[0m\u001b[34m───────────────────────\u001b[0m\u001b[34m \u001b[0m\u001b[1;34m<\u001b[0m\u001b[1;95mclass\u001b[0m\u001b[39m \u001b[0m\u001b[32m'general_ledger.models.ledger.Ledger'\u001b[0m\u001b[1;34m>\u001b[0m\u001b[34m \u001b[0m\u001b[34m────────────────────────\u001b[0m\u001b[34m─╮\u001b[0m\n", - "\u001b[34m│\u001b[0m \u001b[36mThis class demonstrates various ways of linking in docstrings.\u001b[0m \u001b[34m│\u001b[0m\n", - "\u001b[34m│\u001b[0m \u001b[34m│\u001b[0m\n", - "\u001b[34m│\u001b[0m \u001b[32m╭────────────────────────────────────────────────────────────────────────────────────────────╮\u001b[0m \u001b[34m│\u001b[0m\n", - "\u001b[34m│\u001b[0m \u001b[32m│\u001b[0m \u001b[1m<\u001b[0m\u001b[1;95mLedger:\u001b[0m\u001b[39m general-ledger\u001b[0m\u001b[1m>\u001b[0m \u001b[32m│\u001b[0m \u001b[34m│\u001b[0m\n", - "\u001b[34m│\u001b[0m \u001b[32m╰────────────────────────────────────────────────────────────────────────────────────────────╯\u001b[0m \u001b[34m│\u001b[0m\n", - "\u001b[34m│\u001b[0m \u001b[34m│\u001b[0m\n", - "\u001b[34m│\u001b[0m \u001b[3;33mbook\u001b[0m = \u001b[1m<\u001b[0m\u001b[1;95mBook:\u001b[0m\u001b[39m My Company Ltd\u001b[0m\u001b[1m>\u001b[0m \u001b[34m│\u001b[0m\n", - "\u001b[34m│\u001b[0m \u001b[3;33mbook_id\u001b[0m = \u001b[1;35mUUID\u001b[0m\u001b[1m(\u001b[0m\u001b[32m'2c54f25d-c8e6-48da-9b0b-8691ba46c59b'\u001b[0m\u001b[1m)\u001b[0m \u001b[34m│\u001b[0m\n", - "\u001b[34m│\u001b[0m \u001b[3;33mcoa\u001b[0m = \u001b[1m<\u001b[0m\u001b[1;95mChartOfAccounts:\u001b[0m\u001b[39m default coa My Company Ltd\u001b[0m\u001b[1m>\u001b[0m \u001b[34m│\u001b[0m\n", - "\u001b[34m│\u001b[0m \u001b[3;33mcoa_id\u001b[0m = \u001b[1;35mUUID\u001b[0m\u001b[1m(\u001b[0m\u001b[32m'e5d6375c-cc88-4a1f-a081-4921904b5c95'\u001b[0m\u001b[1m)\u001b[0m \u001b[34m│\u001b[0m\n", - "\u001b[34m│\u001b[0m \u001b[3;33mcreated_at\u001b[0m = \u001b[1;35mdatetime.datetime\u001b[0m\u001b[1m(\u001b[0m\u001b[1;36m2024\u001b[0m, \u001b[1;36m10\u001b[0m, \u001b[1;36m9\u001b[0m, \u001b[1;36m22\u001b[0m, \u001b[1;36m58\u001b[0m, \u001b[1;36m39\u001b[0m, \u001b[1;36m463105\u001b[0m, \u001b[33mtzinfo\u001b[0m=\u001b[35mdatetime\u001b[0m.timezone.utc\u001b[1m)\u001b[0m \u001b[34m│\u001b[0m\n", - "\u001b[34m│\u001b[0m \u001b[3;33mdescription\u001b[0m = \u001b[3;35mNone\u001b[0m \u001b[34m│\u001b[0m\n", - "\u001b[34m│\u001b[0m \u001b[3;33mid\u001b[0m = \u001b[1;35mUUID\u001b[0m\u001b[1m(\u001b[0m\u001b[32m'3e81f74f-cc7e-4826-8492-aa2d4b6ab57e'\u001b[0m\u001b[1m)\u001b[0m \u001b[34m│\u001b[0m\n", - "\u001b[34m│\u001b[0m \u001b[3;33mis_hidden\u001b[0m = \u001b[3;91mFalse\u001b[0m \u001b[34m│\u001b[0m\n", - "\u001b[34m│\u001b[0m \u001b[3;33mis_locked\u001b[0m = \u001b[3;91mFalse\u001b[0m \u001b[34m│\u001b[0m\n", - "\u001b[34m│\u001b[0m \u001b[3;33mis_posted\u001b[0m = \u001b[3;91mFalse\u001b[0m \u001b[34m│\u001b[0m\n", - "\u001b[34m│\u001b[0m \u001b[3;33mis_system\u001b[0m = \u001b[3;91mFalse\u001b[0m \u001b[34m│\u001b[0m\n", - "\u001b[34m│\u001b[0m \u001b[3;33mlogger\u001b[0m = \u001b[1m<\u001b[0m\u001b[1;95mLogger\u001b[0m\u001b[39m general_ledger.models.mixins.uuid \u001b[0m\u001b[1;39m(\u001b[0m\u001b[39mWARNING\u001b[0m\u001b[1;39m)\u001b[0m\u001b[1m>\u001b[0m \u001b[34m│\u001b[0m\n", - "\u001b[34m│\u001b[0m \u001b[3;33mname\u001b[0m = \u001b[32m'general-ledger'\u001b[0m \u001b[34m│\u001b[0m\n", - "\u001b[34m│\u001b[0m \u001b[1;3;31mobjects\u001b[0m\u001b[1;31m =\u001b[0m \u001b[1;35mAttributeError\u001b[0m\u001b[1m(\u001b[0m\u001b[32m\"Manager isn't accessible via Ledger instances\"\u001b[0m\u001b[1m)\u001b[0m \u001b[34m│\u001b[0m\n", - "\u001b[34m│\u001b[0m \u001b[3;33mpk\u001b[0m = \u001b[1;35mUUID\u001b[0m\u001b[1m(\u001b[0m\u001b[32m'3e81f74f-cc7e-4826-8492-aa2d4b6ab57e'\u001b[0m\u001b[1m)\u001b[0m \u001b[34m│\u001b[0m\n", - "\u001b[34m│\u001b[0m \u001b[3;33mslug\u001b[0m = \u001b[32m'general-ledger-1'\u001b[0m \u001b[34m│\u001b[0m\n", - "\u001b[34m│\u001b[0m \u001b[3;33mupdated_at\u001b[0m = \u001b[1;35mdatetime.datetime\u001b[0m\u001b[1m(\u001b[0m\u001b[1;36m2024\u001b[0m, \u001b[1;36m10\u001b[0m, \u001b[1;36m9\u001b[0m, \u001b[1;36m22\u001b[0m, \u001b[1;36m58\u001b[0m, \u001b[1;36m39\u001b[0m, \u001b[1;36m463126\u001b[0m, \u001b[33mtzinfo\u001b[0m=\u001b[35mdatetime\u001b[0m.timezone.utc\u001b[1m)\u001b[0m \u001b[34m│\u001b[0m\n", - "\u001b[34m╰────────────────────────────────────────────────────────────────────────────────────────────────╯\u001b[0m\n" + "\u001B[34m╭─\u001B[0m\u001B[34m───────────────────────\u001B[0m\u001B[34m \u001B[0m\u001B[1;34m<\u001B[0m\u001B[1;95mclass\u001B[0m\u001B[39m \u001B[0m\u001B[32m'general_ledger.models.ledger.Ledger'\u001B[0m\u001B[1;34m>\u001B[0m\u001B[34m \u001B[0m\u001B[34m────────────────────────\u001B[0m\u001B[34m─╮\u001B[0m\n", + "\u001B[34m│\u001B[0m \u001B[36mThis class demonstrates various ways of linking in docstrings.\u001B[0m \u001B[34m│\u001B[0m\n", + "\u001B[34m│\u001B[0m \u001B[34m│\u001B[0m\n", + "\u001B[34m│\u001B[0m \u001B[32m╭────────────────────────────────────────────────────────────────────────────────────────────╮\u001B[0m \u001B[34m│\u001B[0m\n", + "\u001B[34m│\u001B[0m \u001B[32m│\u001B[0m \u001B[1m<\u001B[0m\u001B[1;95mLedger:\u001B[0m\u001B[39m general-ledger\u001B[0m\u001B[1m>\u001B[0m \u001B[32m│\u001B[0m \u001B[34m│\u001B[0m\n", + "\u001B[34m│\u001B[0m \u001B[32m╰────────────────────────────────────────────────────────────────────────────────────────────╯\u001B[0m \u001B[34m│\u001B[0m\n", + "\u001B[34m│\u001B[0m \u001B[34m│\u001B[0m\n", + "\u001B[34m│\u001B[0m \u001B[3;33mbook\u001B[0m = \u001B[1m<\u001B[0m\u001B[1;95mBook:\u001B[0m\u001B[39m My Company Ltd\u001B[0m\u001B[1m>\u001B[0m \u001B[34m│\u001B[0m\n", + "\u001B[34m│\u001B[0m \u001B[3;33mbook_id\u001B[0m = \u001B[1;35mUUID\u001B[0m\u001B[1m(\u001B[0m\u001B[32m'2c54f25d-c8e6-48da-9b0b-8691ba46c59b'\u001B[0m\u001B[1m)\u001B[0m \u001B[34m│\u001B[0m\n", + "\u001B[34m│\u001B[0m \u001B[3;33mcoa\u001B[0m = \u001B[1m<\u001B[0m\u001B[1;95mChartOfAccounts:\u001B[0m\u001B[39m default coa My Company Ltd\u001B[0m\u001B[1m>\u001B[0m \u001B[34m│\u001B[0m\n", + "\u001B[34m│\u001B[0m \u001B[3;33mcoa_id\u001B[0m = \u001B[1;35mUUID\u001B[0m\u001B[1m(\u001B[0m\u001B[32m'e5d6375c-cc88-4a1f-a081-4921904b5c95'\u001B[0m\u001B[1m)\u001B[0m \u001B[34m│\u001B[0m\n", + "\u001B[34m│\u001B[0m \u001B[3;33mcreated_at\u001B[0m = \u001B[1;35mdatetime.datetime\u001B[0m\u001B[1m(\u001B[0m\u001B[1;36m2024\u001B[0m, \u001B[1;36m10\u001B[0m, \u001B[1;36m9\u001B[0m, \u001B[1;36m22\u001B[0m, \u001B[1;36m58\u001B[0m, \u001B[1;36m39\u001B[0m, \u001B[1;36m463105\u001B[0m, \u001B[33mtzinfo\u001B[0m=\u001B[35mdatetime\u001B[0m.timezone.utc\u001B[1m)\u001B[0m \u001B[34m│\u001B[0m\n", + "\u001B[34m│\u001B[0m \u001B[3;33mdescription\u001B[0m = \u001B[3;35mNone\u001B[0m \u001B[34m│\u001B[0m\n", + "\u001B[34m│\u001B[0m \u001B[3;33mid\u001B[0m = \u001B[1;35mUUID\u001B[0m\u001B[1m(\u001B[0m\u001B[32m'3e81f74f-cc7e-4826-8492-aa2d4b6ab57e'\u001B[0m\u001B[1m)\u001B[0m \u001B[34m│\u001B[0m\n", + "\u001B[34m│\u001B[0m \u001B[3;33mis_hidden\u001B[0m = \u001B[3;91mFalse\u001B[0m \u001B[34m│\u001B[0m\n", + "\u001B[34m│\u001B[0m \u001B[3;33mis_locked\u001B[0m = \u001B[3;91mFalse\u001B[0m \u001B[34m│\u001B[0m\n", + "\u001B[34m│\u001B[0m \u001B[3;33mis_posted\u001B[0m = \u001B[3;91mFalse\u001B[0m \u001B[34m│\u001B[0m\n", + "\u001B[34m│\u001B[0m \u001B[3;33mis_system\u001B[0m = \u001B[3;91mFalse\u001B[0m \u001B[34m│\u001B[0m\n", + "\u001B[34m│\u001B[0m \u001B[3;33mlogger\u001B[0m = \u001B[1m<\u001B[0m\u001B[1;95mLogger\u001B[0m\u001B[39m general_ledger.models.mixins.uuid \u001B[0m\u001B[1;39m(\u001B[0m\u001B[39mWARNING\u001B[0m\u001B[1;39m)\u001B[0m\u001B[1m>\u001B[0m \u001B[34m│\u001B[0m\n", + "\u001B[34m│\u001B[0m \u001B[3;33mname\u001B[0m = \u001B[32m'general-ledger'\u001B[0m \u001B[34m│\u001B[0m\n", + "\u001B[34m│\u001B[0m \u001B[1;3;31mobjects\u001B[0m\u001B[1;31m =\u001B[0m \u001B[1;35mAttributeError\u001B[0m\u001B[1m(\u001B[0m\u001B[32m\"Manager isn't accessible via Ledger instances\"\u001B[0m\u001B[1m)\u001B[0m \u001B[34m│\u001B[0m\n", + "\u001B[34m│\u001B[0m \u001B[3;33mpk\u001B[0m = \u001B[1;35mUUID\u001B[0m\u001B[1m(\u001B[0m\u001B[32m'3e81f74f-cc7e-4826-8492-aa2d4b6ab57e'\u001B[0m\u001B[1m)\u001B[0m \u001B[34m│\u001B[0m\n", + "\u001B[34m│\u001B[0m \u001B[3;33mslug\u001B[0m = \u001B[32m'general-ledger-1'\u001B[0m \u001B[34m│\u001B[0m\n", + "\u001B[34m│\u001B[0m \u001B[3;33mupdated_at\u001B[0m = \u001B[1;35mdatetime.datetime\u001B[0m\u001B[1m(\u001B[0m\u001B[1;36m2024\u001B[0m, \u001B[1;36m10\u001B[0m, \u001B[1;36m9\u001B[0m, \u001B[1;36m22\u001B[0m, \u001B[1;36m58\u001B[0m, \u001B[1;36m39\u001B[0m, \u001B[1;36m463126\u001B[0m, \u001B[33mtzinfo\u001B[0m=\u001B[35mdatetime\u001B[0m.timezone.utc\u001B[1m)\u001B[0m \u001B[34m│\u001B[0m\n", + "\u001B[34m╰────────────────────────────────────────────────────────────────────────────────────────────────╯\u001B[0m\n" ] }, "metadata": {}, diff --git a/requirements.txt b/requirements.txt index ce08843..f08e322 100644 --- a/requirements.txt +++ b/requirements.txt @@ -19,6 +19,7 @@ click==8.1.7 comm==0.2.2 crispy-bootstrap5==2024.2 cryptography==43.0.1 +currency-symbols==2.0.3 debugpy==1.8.6 decorator==5.1.1 defusedxml==0.7.1 @@ -167,5 +168,4 @@ websocket-client==1.8.0 Werkzeug==3.0.6 widgetsnbextension==4.0.13 xstate-machine==3.2.0 - -colorama~=0.4.6 \ No newline at end of file +colorama~=0.4.6 diff --git a/text-app/__init__.py b/text-app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/text-app/main.py b/text-app/main.py new file mode 100644 index 0000000..af74de1 --- /dev/null +++ b/text-app/main.py @@ -0,0 +1,143 @@ +from time import monotonic + +from textual.app import App, ComposeResult +from textual.widgets import Header, Footer +from textual.containers import ScrollableContainer +from textual.reactive import reactive +from textual.widgets import Button, Footer, Header, Static +from textual import log +from textual import events +from textual.app import App, ComposeResult + +from textual_plotext import PlotextPlot + + +class TimeDisplay(Static): + """A widget to display elapsed time.""" + + start_time = reactive(monotonic) + time = reactive(0.0) + total = reactive(0.0) + + def on_mount(self) -> None: + """Event handler called when widget is added to the app.""" + # self.set_interval(1 / 60, self.update_time) + self.update_timer = self.set_interval(1 / 60, self.update_time, pause=True) + # log("Hello, World") # simple string + # log(locals()) # Log local variables + # log(children=self.children, pi=3.141592) # key/values + # log(self.tree) # Rich renderables + + def update_time(self) -> None: + """Method to update the time to the current time.""" + # self.time = monotonic() - self.start_time + self.time = self.total + (monotonic() - self.start_time) + + def watch_time(self, time: float) -> None: + """Called when the time attribute changes.""" + minutes, seconds = divmod(time, 60) + hours, minutes = divmod(minutes, 60) + self.update(f"{hours:02,.0f}:{minutes:02.0f}:{seconds:05.2f}") + + def start(self) -> None: + """Method to start (or resume) time updating.""" + self.start_time = monotonic() + self.update_timer.resume() + + def stop(self) -> None: + """Method to stop the time display updating.""" + self.update_timer.pause() + self.total += monotonic() - self.start_time + self.time = self.total + + def reset(self) -> None: + """Method to reset the time display to zero.""" + self.total = 0 + self.time = 0 + + +class Stopwatch(Static): + """A stopwatch widget.""" + + def on_button_pressed(self, event: Button.Pressed) -> None: + """Event handler called when a button is pressed.""" + button_id = event.button.id + time_display = self.query_one(TimeDisplay) + if button_id == "start": + time_display.start() + self.add_class("started") + elif button_id == "stop": + time_display.stop() + self.remove_class("started") + elif button_id == "reset": + time_display.reset() + + def compose(self) -> ComposeResult: + """Create child widgets of a stopwatch.""" + yield Button("Start", id="start", variant="success") + yield Button("Stop", id="stop", variant="error") + yield Button("Reset", id="reset") + yield TimeDisplay() + + +class StopwatchApp(App): + """A Textual app to manage stopwatches.""" + + CSS_PATH = "main.tcss" + BINDINGS = [ + ("d", "toggle_dark", "Toggle dark mode"), + ("a", "add_stopwatch", "Add"), + ("r", "remove_stopwatch", "Remove"), + ] + COLORS = [ + "white", + "maroon", + "red", + "purple", + "fuchsia", + "olive", + "yellow", + "navy", + "teal", + "aqua", + ] + + def compose(self) -> ComposeResult: + """Create child widgets for the app.""" + yield Header() + yield Footer() + yield ScrollableContainer( + PlotextPlot(), Stopwatch(), Stopwatch(), Stopwatch(), id="timers" + ) + + def action_add_stopwatch(self) -> None: + """An action to add a timer.""" + new_stopwatch = Stopwatch() + self.query_one("#timers").mount(new_stopwatch) + new_stopwatch.scroll_visible() + + def action_remove_stopwatch(self) -> None: + """Called to remove a timer.""" + timers = self.query("Stopwatch") + if timers: + timers.last().remove() + + def action_toggle_dark(self) -> None: + """An action to toggle dark mode.""" + self.dark = not self.dark + + def on_mount(self) -> None: + # self.screen.styles.background = "gray" + plt = self.query_one(PlotextPlot).plt + y = plt.sin() # sinusoidal test signal + plt.scatter(y) + plt.title("Scatter Plot") # to apply a title + + def on_key(self, event: events.Key) -> None: + if event.key.isdecimal(): + self.screen.styles.background = self.COLORS[int(event.key)] + + +if __name__ == "__main__": + app = StopwatchApp() + app.run() diff --git a/text-app/main.tcss b/text-app/main.tcss new file mode 100644 index 0000000..3cd8e67 --- /dev/null +++ b/text-app/main.tcss @@ -0,0 +1,54 @@ +Stopwatch { + layout: horizontal; + background: $boost; + height: 5; + margin: 1; + min-width: 50; + padding: 1; +} + +TimeDisplay { + content-align: center middle; + text-opacity: 60%; + height: 3; +} + +Button { + width: 16; +} + +#start { + dock: left; +} + +#stop { + dock: left; + display: none; +} + +#reset { + dock: right; +} + + +.started { + text-style: bold; + background: $success; + color: $text; +} + +.started TimeDisplay { + text-opacity: 100%; +} + +.started #start { + display: none +} + +.started #stop { + display: block +} + +.started #reset { + visibility: hidden +} \ No newline at end of file diff --git a/util/__init__.py b/util/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/util/interpreter/__init__.py b/util/interpreter/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/util/interpreter/expression.py b/util/interpreter/expression.py new file mode 100644 index 0000000..ddab3cd --- /dev/null +++ b/util/interpreter/expression.py @@ -0,0 +1,14 @@ +# +# +# +from typing import Protocol, Dict, Any + + +class Expression(Protocol): + def interpret(self, context: Dict[Any, Any]) -> Any: ... + + +class TerminalExpression(Expression): ... + + +class NonTerminalExpression(Expression): ... diff --git a/util/mixins/__init__.py b/util/mixins/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/util/tests/__init__.py b/util/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/util/tests/interpreter/__init__.py b/util/tests/interpreter/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/util/tests/interpreter/arithmetic.py b/util/tests/interpreter/arithmetic.py new file mode 100644 index 0000000..72ad789 --- /dev/null +++ b/util/tests/interpreter/arithmetic.py @@ -0,0 +1,88 @@ +from typing import Dict, Any + +from util.interpreter.expression import ( + NonTerminalExpression, + Expression, + TerminalExpression, +) + +# +# +# + + +class Integer(TerminalExpression): + def __init__(self, value: int) -> None: + self._value = value + + def interpret(self, context: Dict[Any, Any]) -> int: + return self._value + + def __repr__(self): + return str(self._value) + + +class Add(NonTerminalExpression): + def __init__( + self, + left: Expression, + right: Expression, + ) -> None: + self._left = left + self._right = right + + def interpret( + self, + context: Dict[Any, Any], + ) -> Any: + return self._left.interpret(context) + self._right.interpret(context) + + def __repr__(self): + return f"({self._left} Add {self._right})" + + +class Subtract(Expression): + def __init__(self, left, right): + self._left = left + self._right = right + + def interpret(self, context): + return self._left.interpret(context) - self._right.interpret(context) + + def __repr__(self): + return f"({self._left} Subtract {self._right})" + + +class Multiply(NonTerminalExpression): + def __init__( + self, + left: Expression, + right: Expression, + ) -> None: + self._left = left + self._right = right + + def interpret( + self, + context: Dict[Any, Any], + ) -> Any: + return self._left.interpret(context) * self._right.interpret(context) + + def __repr__(self): + return f"({self._left} Times {self._right})" + + +class Divide(NonTerminalExpression): + def __init__( + self, + left: Expression, + right: Expression, + ) -> None: + self._left = left + self._right = right + + def interpret( + self, + context: Dict[Any, Any], + ) -> Any: + return self._left.interpret(context) // self._right.interpret(context) diff --git a/util/tests/interpreter/test_interpreter.py b/util/tests/interpreter/test_interpreter.py new file mode 100644 index 0000000..a0222a7 --- /dev/null +++ b/util/tests/interpreter/test_interpreter.py @@ -0,0 +1,28 @@ +import unittest + +from .arithmetic import Add, Multiply, Integer, Subtract, Divide + + +class TestInterpreter(unittest.TestCase): + def setUp(self): + print("") + + def test_interpreter_01(self): + # self.assertEqual() + expression = Multiply( + Subtract( + Add( + Multiply( + Integer(3), + Integer(4), + ), + Integer(2), + ), + Integer(5), + ), + Add( + Integer(3), + Integer(2), + ), + ) + print(expression.interpret({})) diff --git a/util/visitors/__init__.py b/util/visitors/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/util/visitors/generic.py b/util/visitors/generic.py new file mode 100644 index 0000000..c2ee985 --- /dev/null +++ b/util/visitors/generic.py @@ -0,0 +1,15 @@ +# +# pure visitor pattern +# + + +class GenericVisitor: + def __init__(self, method): + self.method = method + + def visit(self, node, *args): + func = getattr(node, self.method) + # func = getattr(node, self.method) + # print(func()) + # for child in node._children.values(): + # self.visit(child) diff --git a/util/visitors/recursive_func.py b/util/visitors/recursive_func.py new file mode 100644 index 0000000..fb0ad0d --- /dev/null +++ b/util/visitors/recursive_func.py @@ -0,0 +1,35 @@ +# +# pass and pre and post callable functions to the visitor +# + +from general_ledger.utils.inspect import inspect + + +class RecursiveFuncVisitor: + """A visitor that allows for pre- and post-functions to be called on each node""" + + def __init__( + self, + pre_func: callable = None, + post_func: callable = None, + reverse: bool = False, + ): + self._pre_func = pre_func + self._post_func = post_func + self.reverse = reverse + super().__init__() + + def visit(self, node, level: int = 0): + results = [] + # inspect(self, all=True) + if self._pre_func: + results.append( + self._pre_func(node, level + 1), + ) + for _, child in node.items(self.reverse): + results.extend(child.accept(self, level + 1)) + if self._post_func: + results.append( + self._post_func(node, level + 1), + ) + return results