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 %}
+
+
+
+
+{% 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?' %}
+
+
+
+
+{% 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 %}
+
+
+
+{% 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 %}
-
-
-
-
-
-
-
-
-
-
-
-
- {#
- Last 3 Months
- Last 6 Months
- Last 12 Months
- All Time
- #}
+{% 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 @@
+
+
+
+
+
+
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 @@
+
+
+
+
+
+ {#
+ Last 3 Months
+ Last 6 Months
+ Last 12 Months
+ All Time
+ #}
+
+
+
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 @@
+
+
+
+
+
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 @@
+
+
+
+
+
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
-