Compare commits

..

No commits in common. "main" and "v0.5.0" have entirely different histories.
main ... v0.5.0

244 changed files with 9340 additions and 22170 deletions

3
.gitignore vendored
View File

@ -1,4 +1,4 @@
# The Mia! Accounting Project.
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/27
# Copyright (c) 2022 imacat.
@ -38,4 +38,3 @@ excludes
*.mo
zh_Hans
test_temp.py
dummy.js

View File

@ -1,41 +0,0 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/5
# Copyright (c) 2023 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# .readthedocs.yaml
# Read the Docs configuration file
# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details
# Required
version: 2
# Set the version of Python and other tools you might need
build:
os: ubuntu-22.04
tools:
python: "3.11"
# Build documentation in the docs/ directory with Sphinx
# If using Sphinx, optionally build your docs in additional formats such as PDF
formats: all
# Optionally declare the Python requirements required to build your docs
python:
install:
- method: pip
path: .
- requirements: docs/requirements.txt

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Project.
# The Mia! Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2022/8/21
# Copyright (c) 2022-2023 imacat.
@ -15,14 +15,14 @@
# See the License for the specific language governing permissions and
# limitations under the License.
recursive-include src/accounting/static *
exclude src/accounting/static/js/dummy.js
recursive-include src/accounting/templates *
recursive-include src/accounting/translations *
recursive-include src/accounting/data *
recursive-include docs *
recursive-exclude docs/build *
recursive-include tests *
exclude tests/test_temp.py
recursive-exclude tests *.pyc
recursive-exclude tests/instance *
include src/accounting/translations/*
include src/accounting/translations/*/LC_MESSAGES/*
include docs/*
include docs/source/*
include docs/source/_static/*
include docs/source/_templates/*
include tests/*
include tests/test_site/*
include tests/test_site/templates/*
include tests/test_site/translations/*
include tests/test_site/translations/*/LC_MESSAGES/*

View File

@ -1,65 +1,30 @@
===============
Mia! Accounting
===============
=====================
Mia! Accounting Flask
=====================
Description
===========
*Mia! Accounting* is an accounting module for Flask_ applications.
It is designed both for mobile and desktop environments. It
implements `double-entry bookkeeping`_. It generates the following
accounting reports:
* Trial balance
* Income statement
* Balance sheet
In addition, *Mia! Accounting* tracks offsets for unpaid payables and
receivables.
This is the Mia! Accounting Flask project. It is an accounting
module for the Flask_ applications.
Live Demonstration and Test Site
================================
Install
=======
There is a `live demonstration`_ for *Mia! Accounting*. It runs the
same code as the `test site`_ in the `source distribution`_. It is
the simplest website that works with *Mia! Accounting*. It is also
used in the automatic tests.
If you do not have a running Flask application or do not know how to
start one, you may start with the test site.
Installation
============
Install *Mia! Accounting* with ``pip``:
Install the latest source from the
`Mia! Accounting Flask repository`_.
::
pip install mia-accounting
You may also download from the `PyPI project page`_ or the
`release page`_ on the `Git repository`_.
Documentation
=============
Refer to the `documentation on Read the Docs`_.
Change Log
==========
Refer to the `change log`_.
pip install git+https://gitea.imacat.idv.tw/imacat/mia-accounting-flask.git
Copyright
=========
Copyright (c) 2023-2024 imacat.
Copyright (c) 2023 imacat.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -81,14 +46,5 @@ Authors
| imacat@mail.imacat.idv.tw
| 2023/1/27
.. _Flask: https://flask.palletsprojects.com
.. _double-entry bookkeeping: https://en.wikipedia.org/wiki/Double-entry_bookkeeping
.. _live demonstration: https://accounting.imacat.idv.tw
.. _test site: https://github.com/imacat/mia-accounting/tree/main/tests/test_site
.. _source distribution: https://pypi.org/project/mia-accounting/#files
.. _PyPI project page: https://pypi.org/project/mia-accounting
.. _release page: https://github.com/imacat/mia-accounting/releases
.. _Git repository: https://github.com/imacat/mia-accounting
.. _documentation on Read the Docs: https://mia-accounting.readthedocs.io
.. _change log: https://mia-accounting.readthedocs.io/en/latest/changelog.html
.. _Mia! Accounting Flask repository: https://gitea.imacat.idv.tw/imacat/mia-accounting-flask

View File

@ -1 +0,0 @@
sphinx_rtd_theme

View File

@ -1,45 +0,0 @@
accounting.journal\_entry.forms package
=======================================
Submodules
----------
accounting.journal\_entry.forms.currency module
-----------------------------------------------
.. automodule:: accounting.journal_entry.forms.currency
:members:
:undoc-members:
:show-inheritance:
accounting.journal\_entry.forms.journal\_entry module
-----------------------------------------------------
.. automodule:: accounting.journal_entry.forms.journal_entry
:members:
:undoc-members:
:show-inheritance:
accounting.journal\_entry.forms.line\_item module
-------------------------------------------------
.. automodule:: accounting.journal_entry.forms.line_item
:members:
:undoc-members:
:show-inheritance:
accounting.journal\_entry.forms.reorder module
----------------------------------------------
.. automodule:: accounting.journal_entry.forms.reorder
:members:
:undoc-members:
:show-inheritance:
Module contents
---------------
.. automodule:: accounting.journal_entry.forms
:members:
:undoc-members:
:show-inheritance:

View File

@ -1,46 +0,0 @@
accounting.journal\_entry package
=================================
Subpackages
-----------
.. toctree::
:maxdepth: 4
accounting.journal_entry.forms
accounting.journal_entry.utils
Submodules
----------
accounting.journal\_entry.converters module
-------------------------------------------
.. automodule:: accounting.journal_entry.converters
:members:
:undoc-members:
:show-inheritance:
accounting.journal\_entry.template\_filters module
--------------------------------------------------
.. automodule:: accounting.journal_entry.template_filters
:members:
:undoc-members:
:show-inheritance:
accounting.journal\_entry.views module
--------------------------------------
.. automodule:: accounting.journal_entry.views
:members:
:undoc-members:
:show-inheritance:
Module contents
---------------
.. automodule:: accounting.journal_entry
:members:
:undoc-members:
:show-inheritance:

View File

@ -1,45 +0,0 @@
accounting.journal\_entry.utils package
=======================================
Submodules
----------
accounting.journal\_entry.utils.account\_option module
------------------------------------------------------
.. automodule:: accounting.journal_entry.utils.account_option
:members:
:undoc-members:
:show-inheritance:
accounting.journal\_entry.utils.description\_editor module
----------------------------------------------------------
.. automodule:: accounting.journal_entry.utils.description_editor
:members:
:undoc-members:
:show-inheritance:
accounting.journal\_entry.utils.operators module
------------------------------------------------
.. automodule:: accounting.journal_entry.utils.operators
:members:
:undoc-members:
:show-inheritance:
accounting.journal\_entry.utils.original\_line\_items module
------------------------------------------------------------
.. automodule:: accounting.journal_entry.utils.original_line_items
:members:
:undoc-members:
:show-inheritance:
Module contents
---------------
.. automodule:: accounting.journal_entry.utils
:members:
:undoc-members:
:show-inheritance:

View File

@ -1,29 +0,0 @@
accounting.option package
=========================
Submodules
----------
accounting.option.forms module
------------------------------
.. automodule:: accounting.option.forms
:members:
:undoc-members:
:show-inheritance:
accounting.option.views module
------------------------------
.. automodule:: accounting.option.views
:members:
:undoc-members:
:show-inheritance:
Module contents
---------------
.. automodule:: accounting.option
:members:
:undoc-members:
:show-inheritance:

View File

@ -60,38 +60,6 @@ accounting.report.reports.trial\_balance module
:undoc-members:
:show-inheritance:
accounting.report.reports.unapplied module
------------------------------------------
.. automodule:: accounting.report.reports.unapplied
:members:
:undoc-members:
:show-inheritance:
accounting.report.reports.unapplied\_accounts module
----------------------------------------------------
.. automodule:: accounting.report.reports.unapplied_accounts
:members:
:undoc-members:
:show-inheritance:
accounting.report.reports.unmatched module
------------------------------------------
.. automodule:: accounting.report.reports.unmatched
:members:
:undoc-members:
:show-inheritance:
accounting.report.reports.unmatched\_accounts module
----------------------------------------------------
.. automodule:: accounting.report.reports.unmatched_accounts
:members:
:undoc-members:
:show-inheritance:
Module contents
---------------

View File

@ -28,10 +28,10 @@ accounting.report.utils.csv\_export module
:undoc-members:
:show-inheritance:
accounting.report.utils.offset\_matcher module
----------------------------------------------
accounting.report.utils.ie\_account module
------------------------------------------
.. automodule:: accounting.report.utils.offset_matcher
.. automodule:: accounting.report.utils.ie_account
:members:
:undoc-members:
:show-inheritance:
@ -60,22 +60,6 @@ accounting.report.utils.report\_type module
:undoc-members:
:show-inheritance:
accounting.report.utils.unapplied module
----------------------------------------
.. automodule:: accounting.report.utils.unapplied
:members:
:undoc-members:
:show-inheritance:
accounting.report.utils.unmatched module
----------------------------------------
.. automodule:: accounting.report.utils.unmatched
:members:
:undoc-members:
:show-inheritance:
accounting.report.utils.urls module
-----------------------------------

View File

@ -10,30 +10,13 @@ Subpackages
accounting.account
accounting.base_account
accounting.currency
accounting.journal_entry
accounting.option
accounting.report
accounting.transaction
accounting.utils
Submodules
----------
accounting.commands module
--------------------------
.. automodule:: accounting.commands
:members:
:undoc-members:
:show-inheritance:
accounting.forms module
-----------------------
.. automodule:: accounting.forms
:members:
:undoc-members:
:show-inheritance:
accounting.locale module
------------------------

View File

@ -0,0 +1,61 @@
accounting.transaction package
==============================
Submodules
----------
accounting.transaction.converters module
----------------------------------------
.. automodule:: accounting.transaction.converters
:members:
:undoc-members:
:show-inheritance:
accounting.transaction.forms module
-----------------------------------
.. automodule:: accounting.transaction.forms
:members:
:undoc-members:
:show-inheritance:
accounting.transaction.operators module
---------------------------------------
.. automodule:: accounting.transaction.operators
:members:
:undoc-members:
:show-inheritance:
accounting.transaction.summary\_editor module
---------------------------------------------
.. automodule:: accounting.transaction.summary_editor
:members:
:undoc-members:
:show-inheritance:
accounting.transaction.template\_filters module
-----------------------------------------------
.. automodule:: accounting.transaction.template_filters
:members:
:undoc-members:
:show-inheritance:
accounting.transaction.views module
-----------------------------------
.. automodule:: accounting.transaction.views
:members:
:undoc-members:
:show-inheritance:
Module contents
---------------
.. automodule:: accounting.transaction
:members:
:undoc-members:
:show-inheritance:

View File

@ -4,22 +4,6 @@ accounting.utils package
Submodules
----------
accounting.utils.cast module
----------------------------
.. automodule:: accounting.utils.cast
:members:
:undoc-members:
:show-inheritance:
accounting.utils.current\_account module
----------------------------------------
.. automodule:: accounting.utils.current_account
:members:
:undoc-members:
:show-inheritance:
accounting.utils.flash\_errors module
-------------------------------------
@ -28,14 +12,6 @@ accounting.utils.flash\_errors module
:undoc-members:
:show-inheritance:
accounting.utils.journal\_entry\_types module
---------------------------------------------
.. automodule:: accounting.utils.journal_entry_types
:members:
:undoc-members:
:show-inheritance:
accounting.utils.next\_uri module
---------------------------------
@ -44,22 +20,6 @@ accounting.utils.next\_uri module
:undoc-members:
:show-inheritance:
accounting.utils.offset\_alias module
-------------------------------------
.. automodule:: accounting.utils.offset_alias
:members:
:undoc-members:
:show-inheritance:
accounting.utils.options module
-------------------------------
.. automodule:: accounting.utils.options
:members:
:undoc-members:
:show-inheritance:
accounting.utils.pagination module
----------------------------------
@ -100,10 +60,10 @@ accounting.utils.strip\_text module
:undoc-members:
:show-inheritance:
accounting.utils.title\_case module
-----------------------------------
accounting.utils.txn\_types module
----------------------------------
.. automodule:: accounting.utils.title_case
.. automodule:: accounting.utils.txn_types
:members:
:undoc-members:
:show-inheritance:

View File

@ -1,455 +0,0 @@
Change Log
==========
Version 1.6.0
--------------
Released 2024/6/4
* Updated Python version to 3.12.
* Revised the calculation of "today" to use the client's timezone instead of
the server's timezone.
* Updated the Bootstrap, FontAwesome, and Tempus-Dominus versions in the test
site.
Version 1.5.11
--------------
Released 2023/12/26
Bug fix.
* Refined to enable the selection of the 3351-001 Accumulated Profit or Loss
account.
Version 1.5.10
--------------
Released 2023/11/28
Bug fix.
* Fixed the form validator to enable the selection of Accumulated Profit or
Loss accounts other than 3351-001.
Version 1.5.9
-------------
Released 2023/11/28
Bug fix.
* Refined to enable the selection of Accumulated Profit or Loss accounts other
than 3351-001, facilitating the consolidation of existing balances.
Version 1.5.8
-------------
Released 2023/10/24
Bug fix.
* Fixed an icon in the detail of the cash receipt journal entry.
Released at Jaipur, India on vacation.
Version 1.5.7
-------------
Released 2023/7/29
Revised account title capitalization to capitalize account titles
upon initialization of base accounts, rather than when displaying
the accounts. This prevents the system from incorrectly
capitalizing titles of user-added accounts.
For existing installation, run the ``accounting-titleize`` console
command to capitalize the existing account titles that were already
initialized.
Other fixes:
* Added missing documentation to the global variables, class
properties, and object properties.
* Various minor fixes.
Version 1.5.6
-------------
Released 2023/5/23
Bug fixes.
* Fixed the return URI of the creation forms to decode the next URI.
* Fixed the unmatched offset list to use the encoded next URI.
Version 1.5.5
-------------
Released 2023/5/23
Security fixes.
* Revised the next URI utilities to encode and decode the next URI
preventing tampering with the next URI.
* Added the integrity value of the CDN stylesheet links.
* Various fixes.
Version 1.5.4
-------------
Released 2023/5/18
Security fixes.
* Added safeguard to the next URI utilities, to prevent Cross-Site
Scripting (XSS) attacks.
* Applied the safe next URI utilities to the test site.
* Added the ``SameSite`` and ``Secure`` flags to the session cookie
of the test site.
Version 1.5.3
-------------
Released 2023/4/30
* Fixed the error of the net balance in the unmatched offset list.
* Revised the original line item editor not to override the existing
amount when the existing amount is less or equal to the net
balance.
Version 1.5.2
-------------
Released 2023/4/30
* Fixed the error of the net balance in the unmatched offset list.
Version 1.5.1
-------------
Released 2023/4/30
* Fixed the error calling the old ``setEnableDescriptionAccount``
method in the ``saveOriginalLineItem`` method of the JavaScript
``JournalEntryLineItemEditor`` class.
Version 1.5.0
-------------
Released 2023/4/23
* Updated to require ``SQLAlchemy >= 2``.
* Added the change log.
* Added the ``VERSION`` constant to the ``accounting`` module for
the package version, and revised ``pyproject.toml`` and ``conf.py``
to read the version from it.
Version 1.4.1
-------------
Released 2023/4/22
* Updated to allow editing the description of the journal entry line
item with offsets or are offsetting to original line items.
* Updated not to override the existing description of a journal entry
line item after choosing the original line item to offset to.
Version 1.4.0
-------------
Released 2023/4/18
* Rewrote the unapplied original line items and unmatched offsets.
* The unapplied original line items and unmatched offsets are both
in the report submodule. They can be filtered with currency and
period now.
* Show the unapplied original line items and unmatched offsets
together, and added the accumulated balance in the unmatched
offset list, for ease of reference.
* Removed the account code from the journal entry detail and journal
entry form for mobile devices.
* Made the account options in the reports to be scrollable.
Version 1.3.3
-------------
Released 2023/4/13
Changed the sample data generation in the test site live demonstration
from pre-recorded data to real-time generation, to avoid the problem
with the start of months and weeks changed with the date of the
import.
Version 1.3.2
-------------
Released 2023/4/12
Added the sample data generation and database reset on the test site
for live demonstration.
Version 1.3.1
-------------
Released 2023/4/11
* Fixed the permission of the navigation menu of the unmatched offsets.
* Revised the test site to be more accessible as the live demonstration.
Version 1.3.0
-------------
Released 2023/4/11
Added the ``accounting-init-db`` console command to replace all the
other console commands to initialize the accounting database. The
test site does not work with previous versions (<1.3.0).
Version 1.2.1
-------------
Released 2023/4/9
Fixed the search result to allow full ``year/month/day``
specification.
Version 1.2.0
-------------
Released 2023/4/9
* Simplified the URL of the default reports.
* Fixed the crash with malformed Chinese translation.
* Fixed the crash when downloading CSV data with non-US-ASCII
filenames.
Version 1.1.0
-------------
Released 2023/4/9
* Added the unapplied original line item list, to track unpaid
payables, unreceived receivables, assets, prepaids, refundable
deposits, etc.
* Added the offset matcher to match unapplied original line items
with unmatched offsets.
Version 1.0.1
-------------
Released 2023/4/6
Documentation fixes.
Version 1.0.0
-------------
Released 2023/4/6
The first formal release in Flask.
Added the documentation.
Version 0.11.1 (Pre-release)
----------------------------
Released 2023/4/5
Removed the zero balances from the trial balance, the income
statement, and the balance sheet.
Version 0.11.0 (Pre-release)
----------------------------
Released 2023/4/5
* Renamed the project from ``mia-accounting-flask`` to
``mia-accounting``.
* Updated the URL of the reports, as the default views of the
accounting application.
* Updated ``README``.
* Various fixes.
Version 0.10.0 (Pre-release)
----------------------------
Released 2023/4/3
* Added the unauthorized method to the ``UserUtilityInterface``
interface to allow fine control to how to handle the case when the
user has not logged in.
* Revised the JavaScript description editor to respect the account
that the user has confirmed or specifically selected.
* Various fixes.
Version 0.9.1 (Pre-release)
---------------------------
Released 2023/3/24
* A distinguishable look in the option detail than the option form.
* A better look in the new journal entry forms when there is no line
item yet.
* Fixed the search in the original entry selector in the journal
entry form to always do a partial match, to fix the problem that
there is no match when typing is not finished yet.
* Fixed the search in the original entry selector to search the net
balance correctly.
* Replaced the ``editor`` and ``editor2`` accounts with the ``admin``
and ``editor`` accounts.
* Various fixes.
Version 0.9.0 (Pre-release)
---------------------------
Released 2023/3/23
Moved the settings from the ``.env`` file to the option table in the
database that can be set and updated on the web interface. Added the
settings page to show and update the settings.
Version 0.8.0 (Pre-release)
---------------------------
Released 2023/3/22
* Added the recurring transactions to the description editor.
* Added prevention to delete database objects that are essential or
referenced by others with foreign keys.
* Various fixes on the visual layout.
Version 0.7.0 (Pre-release)
---------------------------
Released 2023/3/21
* Renamed "transaction" to "journal entry", and "journal entry" to
"journal entry line item".
* Renamed ``summary`` to ``description``.
* Updated tempus-dominus from version 6.2.10 to 6.4.3.
* Fixed titles and capitalization.
* Fixed to search case-insensitively.
* Added favicon to the test site.
* Fixed the navigation menu when there is no matching endpoint.
* Various fixes.
Version 0.6.0 (Pre-release)
---------------------------
Released 2023/3/18
* Added offset tracking to the journal entries in the payable and
receivable accounts.
* Renamed the ``is_offset_needed`` column to ``is_need_offset`` in
the ``Account`` data model.
Version 0.5.0 (Pre-release)
---------------------------
Released 2023/3/10
Added the accounting reports.
Version 0.4.0 (Pre-release)
---------------------------
Released 2023/3/1
Added the transaction summary helper.
Version 0.3.1 (Pre-release)
---------------------------
Released 2023/2/28
* Fixed the error that cannot select any account when adding new
transactions.
* Fixed the database error when adding new transactions.
* Added the button to convert a cash income or cash expense
transaction to a transfer transaction.
Version 0.3.0 (Pre-release)
---------------------------
Released 2023/2/27
Added the transaction management.
Version 0.2.0 (Pre-release)
---------------------------
Released 2023/2/7
* Added the currency management.
* Changed the ``can_edit`` permission to at least require the user to
log in first.
* Changed the type hint of the ``current_user`` pseudo property of
the ``AbstractUserUtils`` class to return ``None`` when the user
has not logged in.
Version 0.1.1 (Pre-release)
---------------------------
Released 2023/2/3
Finalized the account management, with tests and reordering.
Version 0.1.0 (Pre-release)
---------------------------
Released 2023/2/3
Added the account management, and updated the API to initialize the
accounting application.
Version 0.0.0 (Pre-release)
---------------------------
Released 2023/2/3
Initial release with main account list, localization, pagination,
query, permission, Sphinx documentation, and a test case based on a
test demonstration site.

View File

@ -6,15 +6,14 @@ import os
import sys
sys.path.insert(0, os.path.abspath('../../src/'))
import accounting
# -- Project information -----------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information
project = 'Mia! Accounting'
project = 'Mia! Accounting Flask'
copyright = '2023, imacat'
author = 'imacat'
release = accounting.VERSION
release = '0.4.0'
# -- General configuration ---------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration

View File

@ -1,61 +0,0 @@
Examples
========
.. _example-userutils:
An Example Configuration
------------------------
The following is an example configuration for *Mia! Accounting*.
::
from flask import Response, redirect
from .auth import current_user()
from .modules import User
def create_app(test_config=None) -> Flask:
app: Flask = Flask(__name__)
... (Configuration of SQLAlchemy, CSRF, Babel_JS, ... etc) ...
import accounting
class UserUtils(accounting.UserUtilityInterface[User]):
def can_view(self) -> bool:
return True
def can_edit(self) -> bool:
return "editor" in current_user().roles
def can_admin(self) -> bool:
return current_user().is_admin
def unauthorized(self) -> Response:
return redirect("/login")
@property
def cls(self) -> t.Type[User]:
return User
@property
def pk_column(self) -> Column:
return User.id
@property
def current_user(self) -> User | None:
return current_user()
def get_by_username(self, username: str) -> User | None:
return User.query.filter(User.username == username).first()
def get_pk(self, user: User) -> int:
return user.id
accounting.init_app(app, UserUtils())
... (Any other configuration) ...
return app

View File

@ -1,57 +0,0 @@
History
=======
I created my own private accounting application in Perl_/mod_perl_ in
2007, as part of my personal website. The first revision was made
using Perl/Mojolicious_ in 2019, with the aim of making it
mobile-friendly using Bootstrap_, and with modern back-end and
front-end technologies such as jQuery.
The second revision was done in Python_/Django_ in 2020, as I was
looking to change my career from PHP_/Laravel_ to Python, but lacked
experience with large Python projects. I needed something in my
portfolio and decided to work on the somewhat outdated Mojolicious
project.
Despite having no prior experience with Django, I spent two months
working late nights to create the `Mia! Accounting Django`_
application. It took me another 1.5 months to make it an independent
module, which I later released as an open source project on PyPI.
The application worked nicely for my household bookkeeping for two
years. However, new demands arose over time, especially with tracking
payables and receivables. This was critical `during the pandemic`_ as
more payments were made online with credit cards.
The biggest issue I encountered was with
`Django's MTV architectural pattern`_. Django takes over the control
flow. I had to override several parts of the `class-based views`_ for
different but yet simple control flow logic. In the end, it became
very difficult to track whether things went wrong because I overrode
something or because it just wouldn't work with the basic assumption
of the class-based views. By the time I realized it, it was too late
for me to drop Django's MTV and rewrite everything from class-based
views to function-based views.
Therefore, I decided to turn to microframeworks_ like Flask_. After
working with modularized Flask and FastAPI_ applications for two
years, I returned to the project and wrote its third revision using
Flask in 2023.
.. _Perl: https://www.perl.org
.. _mod_perl: https://perl.apache.org
.. _Mojolicious: https://mojolicious.org
.. _Bootstrap: https://getbootstrap.com
.. _jQuery: https://jquery.com
.. _Python: https://www.python.org
.. _Django: https://www.djangoproject.com
.. _PHP: https://www.php.net
.. _Laravel: https://laravel.com
.. _Mia! Accounting Django: https://github.com/imacat/mia-accounting-django
.. _during the pandemic: https://en.wikipedia.org/wiki/COVID-19_pandemic
.. _FastAPI: https://fastapi.tiangolo.com
.. _Django's MTV architectural pattern: https://docs.djangoproject.com/en/dev/faq/general/#django-appears-to-be-a-mvc-framework-but-you-call-the-controller-the-view-and-the-view-the-template-how-come-you-don-t-use-the-standard-names
.. _class-based views: https://docs.djangoproject.com/en/4.2/topics/class-based-views/
.. _microframeworks: https://en.wikipedia.org/wiki/Microframework
.. _Flask: https://flask.palletsprojects.com

View File

@ -1,21 +1,15 @@
.. Mia! Accounting documentation master file, created by
.. Mia! Accounting Flask documentation master file, created by
sphinx-quickstart on Fri Jan 27 12:20:04 2023.
You can adapt this file completely to your liking, but it should at least
contain the root `toctree` directive.
Welcome to Mia! Accounting's documentation!
===========================================
Welcome to Mia! Accounting Flask's documentation!
=================================================
.. toctree::
:maxdepth: 2
:caption: Contents:
intro
accounting
examples
history
changelog
Indices and tables

View File

@ -1,120 +0,0 @@
Introduction
============
*Mia! Accounting* is an accounting module for Flask_ applications.
It is designed both for mobile and desktop environments. It
implements `double-entry bookkeeping`_. It generates the following
accounting reports:
* Trial balance
* Income statement
* Balance sheet
In addition, *Mia! Accounting* tracks offsets for unpaid payables and
receivables.
Live Demonstration and Test Site
--------------------------------
There is a `live demonstration`_ for *Mia! Accounting*. It runs the
same code as the `test site`_ in the `source distribution`_. It is
the simplest website that works with *Mia! Accounting*. It is also
used in the automatic tests.
If you do not have a running Flask application or do not know how to
start one, you may start with the test site.
Installation
------------
Install *Mia! Accounting* with ``pip``:
::
pip install mia-accounting
You may also download from the `PyPI project page`_ or the
`release page`_ on the `Git repository`_.
Prerequisites
-------------
You need a running Flask application with database user login.
The primary key of the user data model must be integer. You also
need at least one user.
The following front-end JavaScript libraries must be loaded. You may
download it locally or use CDN_.
* Bootstrap_ 5.2.3 or above
* FontAwesome_ 6.4.0 or above
* `decimal.js`_ 10.4.3 or above, or `decimal.js-light`_ 2.5.1 or above.
* `Tempus-Dominus`_ 6.7.7 or above
Configuration
-------------
You need to pass the Flask *app* and an implementation of
:py:class:`accounting.utils.user.UserUtilityInterface` to the
:py:func:`accounting.init_app` function. ``UserUtilityInterface``
contains everything *Mia! Accounting* needs.
See an example in :ref:`example-userutils`.
Database Initialization
-----------------------
After the configuration, run the ``accounting-init-db`` console
command to initialize the accounting database. You need to specify
the username of a user as the data creator.
::
% flask --app myapp accounting-init-db -u username
Navigation Menu
---------------
Include the navigation menu in the `Bootstrap navigation bar`_ in your
base template:
::
<nav class="navbar navbar-expand-lg bg-body-tertiary bg-dark navbar-dark">
<div class="container-fluid">
...
<div id="collapsible-navbar" class="collapse navbar-collapse">
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
...
{% include "accounting/include/nav.html" %}
...
</ul>
...
</div>
</div>
</nav>
Check your Flask application and see how it works.
.. _Flask: https://flask.palletsprojects.com
.. _double-entry bookkeeping: https://en.wikipedia.org/wiki/Double-entry_bookkeeping
.. _live demonstration: https://accounting.imacat.idv.tw
.. _test site: https://github.com/imacat/mia-accounting/tree/main/tests/test_site
.. _source distribution: https://pypi.org/project/mia-accounting/#files
.. _PyPI project page: https://pypi.org/project/mia-accounting
.. _release page: https://github.com/imacat/mia-accounting/releases
.. _Git repository: https://github.com/imacat/mia-accounting
.. _CDN: https://en.wikipedia.org/wiki/Content_delivery_network
.. _Bootstrap: https://getbootstrap.com
.. _FontAwesome: https://fontawesome.com
.. _decimal.js: https://mikemcl.github.io/decimal.js
.. _decimal.js-light: https://mikemcl.github.io/decimal.js-light
.. _Tempus-Dominus: https://getdatepicker.com
.. _Bootstrap navigation bar: https://getbootstrap.com/docs/5.3/components/navbar/

View File

@ -1,7 +1,7 @@
# The Mia! Accounting Project.
# The Mia! Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2022/8/21
# Copyright (c) 2022-2024 imacat.
# Copyright (c) 2022 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@ -15,55 +15,6 @@
# See the License for the specific language governing permissions and
# limitations under the License.
[project]
name = "mia-accounting"
dynamic = ["version"]
description = "A Flask accounting module."
readme = "README.rst"
requires-python = ">=3.12"
authors = [
{name = "imacat", email = "imacat@mail.imacat.idv.tw"},
]
keywords = ["mia", "accounting", "flask"]
classifiers = [
"Programming Language :: Python :: 3",
"License :: OSI Approved :: Apache Software License",
"Operating System :: OS Independent",
"Framework :: Flask",
"Topic :: Office/Business :: Financial :: Accounting",
]
dependencies = [
"Flask",
"SQLAlchemy >= 2",
"Flask-SQLAlchemy",
"Flask-WTF",
"Flask-Babel >= 3",
"Flask-Babel-JS",
]
[project.optional-dependencies]
devel = [
"httpx",
"OpenCC",
]
[project.urls]
"Documentation" = "https://mia-accounting.readthedocs.io"
"Change Log" = "https://mia-accounting.readthedocs.io/en/latest/changelog.html"
"Repository" = "https://github.com/imacat/mia-accounting"
"Bug Tracker" = "https://github.com/imacat/mia-accounting/issues"
"Demonstration" = "https://accounting.imacat.idv.tw"
[build-system]
requires = ["setuptools>=42"]
build-backend = "setuptools.build_meta"
[tool.setuptools.dynamic]
version = {attr = "accounting.VERSION"}
[tool.setuptools.exclude-package-data]
"*" = [
"babel.cfg",
"*.pot",
"*.po",
]

56
setup.cfg Normal file
View File

@ -0,0 +1,56 @@
# The Mia! Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2022/8/21
# Copyright (c) 2022-2023 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
[metadata]
name = mia-accounting-flask
version = 0.5.0
author = imacat
author_email = imacat@mail.imacat.idv.tw
description = The Mia! Accounting Flask project.
long_description = file: README.rst
long_description_content_type = text/x-rst
url = https://github.com/imacat/mia-accounting-flask
project_urls =
Bug Tracker = https://github.com/imacat/mia-accounting-flask/issues
classifiers =
Programming Language :: Python :: 3
License :: OSI Approved :: Apache Software License
Operating System :: OS Independent
Framework :: Flask
Topic :: Office/Business :: Financial :: Accounting
[options]
package_dir =
= src
python_requires = >=3.11
install_requires =
flask
Flask-SQLAlchemy
Flask-WTF
Flask-Babel >= 3
Flask-Babel-JS
tests_require =
unittest
httpx
OpenCC
[options.package_data]
accounting =
static/**
templates/**
translations/*/LC_MESSAGES/*.mo
data/**

View File

@ -1,7 +1,7 @@
# The Mia! Accounting Project.
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/25
# Copyright (c) 2023-2024 imacat.
# Copyright (c) 2023 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@ -17,28 +17,33 @@
"""The accounting application.
"""
import typing as t
from pathlib import Path
from flask import Flask, Blueprint
from flask_sqlalchemy import SQLAlchemy
from accounting.utils.user import UserUtilityInterface
from accounting.utils.user import AbstractUserUtils
VERSION: str = "1.6.0"
"""The package version."""
db: SQLAlchemy = SQLAlchemy()
"""The database instance."""
data_dir: Path = Path(__file__).parent / "data"
"""The data directory."""
def init_app(app: Flask, user_utils: UserUtilityInterface,
url_prefix: str = "/accounting") -> None:
def init_app(app: Flask, user_utils: AbstractUserUtils,
url_prefix: str = "/accounting",
can_view_func: t.Callable[[], bool] | None = None,
can_edit_func: t.Callable[[], bool] | None = None) -> None:
"""Initialize the application.
:param app: The Flask application.
:param user_utils: The user utilities.
:param url_prefix: The URL prefix of the accounting application.
:param can_view_func: A callback that returns whether the current user can
view the accounting data.
:param can_edit_func: A callback that returns whether the current user can
edit the accounting data.
:return: None.
"""
# The database instance must be set before loading everything
@ -49,6 +54,7 @@ def init_app(app: Flask, user_utils: UserUtilityInterface,
init_user_utils(user_utils)
bp: Blueprint = Blueprint("accounting", __name__,
url_prefix=url_prefix,
template_folder="templates",
static_folder="static")
@ -63,15 +69,11 @@ def init_app(app: Flask, user_utils: UserUtilityInterface,
bp.add_app_template_global(default_currency_code,
"accounting_default_currency_code")
from .commands import init_db_command, titleize_command
app.cli.add_command(init_db_command)
app.cli.add_command(titleize_command)
from . import locale
locale.init_app(app, bp)
from .utils import permission
permission.init_app(bp, user_utils)
permission.init_app(bp, can_view_func, can_edit_func)
from .utils import next_uri
next_uri.init_app(bp)
@ -85,13 +87,10 @@ def init_app(app: Flask, user_utils: UserUtilityInterface,
from . import currency
currency.init_app(app, bp)
from . import journal_entry
journal_entry.init_app(app, bp)
from . import transaction
transaction.init_app(app, bp)
from . import report
report.init_app(app, url_prefix)
report.init_app(app, bp)
from . import option
option.init_app(bp)
app.register_blueprint(bp, url_prefix=url_prefix)
app.register_blueprint(bp)

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Project.
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/30
# Copyright (c) 2023 imacat.
@ -19,8 +19,6 @@
"""
from flask import Flask, Blueprint
from .commands import init_accounts_command
def init_app(app: Flask, bp: Blueprint) -> None:
"""Initialize the application.
@ -34,3 +32,6 @@ def init_app(app: Flask, bp: Blueprint) -> None:
from .views import bp as account_bp
bp.register_blueprint(account_bp, url_prefix="/accounts")
from .commands import init_accounts_command
app.cli.add_command(init_accounts_command)

View File

@ -1,7 +1,7 @@
# The Mia! Accounting Project.
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/30
# Copyright (c) 2023-2024 imacat.
# Copyright (c) 2023 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@ -17,21 +17,45 @@
"""The console commands for the account management.
"""
import os
import re
from secrets import randbelow
from typing import Any
import click
import sqlalchemy as sa
from flask.cli import with_appcontext
from accounting import db
from accounting.models import BaseAccount, Account, AccountL10n
from accounting.utils.user import get_user_pk
from accounting.utils.user import has_user, get_user_pk
type AccountData = tuple[int, str, int, str, str, str, bool]
AccountData = tuple[int, str, int, str, str, str, bool]
"""The format of the account data, as a list of (ID, base account code, number,
English, Traditional Chinese, Simplified Chinese, is-need-offset) tuples."""
English, Traditional Chinese, Simplified Chinese, is-offset-needed) tuples."""
def __validate_username(ctx: click.core.Context, param: click.core.Option,
value: str) -> str:
"""Validates the username for the click console command.
:param ctx: The console command context.
:param param: The console command option.
:param value: The username.
:raise click.BadParameter: When validation fails.
:return: The username.
"""
value = value.strip()
if value == "":
raise click.BadParameter("Username empty.")
if not has_user(value):
raise click.BadParameter(f"User {value} does not exist.")
return value
@click.command("accounting-init-accounts")
@click.option("-u", "--username", metavar="USERNAME", prompt=True,
help="The username.", callback=__validate_username,
default=lambda: os.getlogin())
@with_appcontext
def init_accounts_command(username: str) -> None:
"""Initializes the accounts."""
creator_pk: int = get_user_pk(username)
@ -40,6 +64,8 @@ def init_accounts_command(username: str) -> None:
.filter(db.func.length(BaseAccount.code) == 4)\
.order_by(BaseAccount.code).all()
if len(bases) == 0:
click.echo("Please initialize the base accounts with "
"\"flask accounting-init-base\" first.")
raise click.Abort
existing: list[Account] = Account.query.all()
@ -48,6 +74,7 @@ def init_accounts_command(username: str) -> None:
bases_to_add: list[BaseAccount] = [x for x in bases
if x.code not in existing_base_code]
if len(bases_to_add) == 0:
click.echo("No more account to import.")
return
existing_id: set[int] = {x.id for x in existing}
@ -63,45 +90,38 @@ def init_accounts_command(username: str) -> None:
existing_id.add(new_id)
return new_id
data: list[dict[str, Any]] = []
l10n_data: list[dict[str, Any]] = []
data: list[AccountData] = []
for base in bases_to_add:
l10n: dict[str, str] = {x.locale: x.title for x in base.l10n}
account_id: int = get_new_id()
data.append({"id": account_id,
"base_code": base.code,
"no": 1,
"title_l10n": base.title_l10n,
"is_need_offset": __is_need_offset(base.code),
"created_by_id": creator_pk,
"updated_by_id": creator_pk})
for locale in {"zh_Hant", "zh_Hans"}:
l10n_data.append({"account_id": account_id,
"locale": locale,
"title": l10n[locale]})
db.session.execute(sa.insert(Account), data)
db.session.execute(sa.insert(AccountL10n), l10n_data)
is_offset_needed: bool = True if re.match("^[12]1[34]", base.code) \
else False
data.append((get_new_id(), base.code, 1, base.title_l10n,
l10n["zh_Hant"], l10n["zh_Hans"], is_offset_needed))
__add_accounting_accounts(data, creator_pk)
click.echo(F"{len(data)} added. Accounting accounts initialized.")
def __is_need_offset(base_code: str) -> bool:
"""Checks that whether journal entry line items in the account need offset.
def __add_accounting_accounts(data: list[AccountData], creator_pk: int)\
-> None:
"""Adds the accounts.
:param base_code: The code of the base account.
:return: True if journal entry line items in the account need offset, or
False otherwise.
:param data: A list of (base code, number, title) tuples.
:param creator_pk: The primary key of the creator.
:return: None.
"""
# Assets
if base_code[0] == "1":
if base_code[:3] in {"113", "114", "118", "184", "186"}:
return True
if base_code in {"1286", "1411", "1421", "1431", "1441", "1511",
"1521", "1581", "1611", "1851"}:
return True
return False
# Liabilities
if base_code[0] == "2":
if base_code in {"2111", "2114", "2284", "2293", "2861"}:
return False
return True
# Only assets and liabilities need offset
return False
accounts: list[Account] = [Account(id=x[0],
base_code=x[1],
no=x[2],
title_l10n=x[3],
is_offset_needed=x[6],
created_by_id=creator_pk,
updated_by_id=creator_pk)
for x in data]
l10n: list[AccountL10n] = [AccountL10n(account_id=x[0],
locale=y[0],
title=y[1])
for x in data
for y in (("zh_Hant", x[4]), ("zh_Hans", x[5]))]
db.session.bulk_save_objects(accounts)
db.session.bulk_save_objects(l10n)
db.session.commit()

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Project.
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/31
# Copyright (c) 2023 imacat.

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Project.
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/1
# Copyright (c) 2023 imacat.
@ -53,20 +53,6 @@ class BaseAccountAvailable:
"The base account is not available."))
class NoOffsetNominalAccount:
"""The validator to check nominal account is not to be offset."""
def __call__(self, form: FlaskForm, field: BooleanField) -> None:
assert isinstance(form, AccountForm)
if not field.data:
return
if form.base_code.data is None:
return
if form.base_code.data[0] not in {"1", "2", "3"}:
raise ValidationError(lazy_gettext(
"A nominal account does not need offset."))
class AccountForm(FlaskForm):
"""The form to create or edit an account."""
base_code = StringField(
@ -80,9 +66,8 @@ class AccountForm(FlaskForm):
filters=[strip_text],
validators=[DataRequired(lazy_gettext("Please fill in the title"))])
"""The title."""
is_need_offset = BooleanField(
validators=[NoOffsetNominalAccount()])
"""Whether the the journal entry line items of this account need offset."""
is_offset_needed = BooleanField()
"""Whether the the entries of this account need offset."""
def populate_obj(self, obj: Account) -> None:
"""Populates the form data into an account object.
@ -102,10 +87,7 @@ class AccountForm(FlaskForm):
obj.base_code = self.base_code.data
obj.no = count + 1
obj.title = self.title.data
if self.base_code.data[0] in {"1", "2", "3"}:
obj.is_need_offset = self.is_need_offset.data
else:
obj.is_need_offset = False
obj.is_offset_needed = self.is_offset_needed.data
if is_new:
current_user_pk: int = get_current_user_pk()
obj.created_by_id = current_user_pk
@ -168,9 +150,7 @@ class AccountReorderForm:
:param base: The base account.
"""
self.base: BaseAccount = base
"""The base account."""
self.is_modified: bool = False
"""Whether the order is modified."""
def save_order(self) -> None:
"""Saves the order of the account.

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Project.
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/30
# Copyright (c) 2023 imacat.
@ -40,15 +40,15 @@ def get_account_query() -> list[Account]:
conditions: list[sa.BinaryExpression] = []
for k in keywords:
l10n: list[AccountL10n] = AccountL10n.query\
.filter(AccountL10n.title.icontains(k)).all()
.filter(AccountL10n.title.contains(k)).all()
l10n_matches: set[str] = {x.account_id for x in l10n}
sub_conditions: list[sa.BinaryExpression] \
= [Account.base_code.contains(k),
Account.title_l10n.icontains(k),
Account.title_l10n.contains(k),
code.contains(k),
Account.id.in_(l10n_matches)]
if k in gettext("Needs Offset"):
sub_conditions.append(Account.is_need_offset)
if k in gettext("Need offset"):
sub_conditions.append(Account.is_offset_needed)
conditions.append(sa.or_(*sub_conditions))
return Account.query.filter(*conditions)\

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Project.
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/30
# Copyright (c) 2023 imacat.
@ -27,7 +27,6 @@ from werkzeug.datastructures import ImmutableMultiDict
from accounting import db
from accounting.locale import lazy_gettext
from accounting.models import Account, BaseAccount
from accounting.utils.cast import s
from accounting.utils.flash_errors import flash_form_errors
from accounting.utils.next_uri import inherit_next, or_next
from accounting.utils.pagination import Pagination
@ -53,7 +52,7 @@ def list_accounts() -> str:
list=pagination.list, pagination=pagination)
@bp.get("create", endpoint="create")
@bp.get("/create", endpoint="create")
@has_permission(can_edit)
def show_add_account_form() -> str:
"""Shows the form to add an account.
@ -70,7 +69,7 @@ def show_add_account_form() -> str:
form=form)
@bp.post("store", endpoint="store")
@bp.post("/store", endpoint="store")
@has_permission(can_edit)
def add_account() -> redirect:
"""Adds an account.
@ -87,11 +86,11 @@ def add_account() -> redirect:
form.populate_obj(account)
db.session.add(account)
db.session.commit()
flash(s(lazy_gettext("The account is added successfully")), "success")
flash(lazy_gettext("The account is added successfully"), "success")
return redirect(inherit_next(__get_detail_uri(account)))
@bp.get("<account:account>", endpoint="detail")
@bp.get("/<account:account>", endpoint="detail")
@has_permission(can_view)
def show_account_detail(account: Account) -> str:
"""Shows the account detail.
@ -102,7 +101,7 @@ def show_account_detail(account: Account) -> str:
return render_template("accounting/account/detail.html", obj=account)
@bp.get("<account:account>/edit", endpoint="edit")
@bp.get("/<account:account>/edit", endpoint="edit")
@has_permission(can_edit)
def show_account_edit_form(account: Account) -> str:
"""Shows the form to edit an account.
@ -121,7 +120,7 @@ def show_account_edit_form(account: Account) -> str:
account=account, form=form)
@bp.post("<account:account>/update", endpoint="update")
@bp.post("/<account:account>/update", endpoint="update")
@has_permission(can_edit)
def update_account(account: Account) -> redirect:
"""Updates an account.
@ -139,16 +138,16 @@ def update_account(account: Account) -> redirect:
with db.session.no_autoflush:
form.populate_obj(account)
if not account.is_modified:
flash(s(lazy_gettext("The account was not modified.")), "success")
flash(lazy_gettext("The account was not modified."), "success")
return redirect(inherit_next(__get_detail_uri(account)))
account.updated_by_id = get_current_user_pk()
account.updated_at = sa.func.now()
db.session.commit()
flash(s(lazy_gettext("The account is updated successfully.")), "success")
flash(lazy_gettext("The account is updated successfully."), "success")
return redirect(inherit_next(__get_detail_uri(account)))
@bp.post("<account:account>/delete", endpoint="delete")
@bp.post("/<account:account>/delete", endpoint="delete")
@has_permission(can_edit)
def delete_account(account: Account) -> redirect:
"""Deletes an account.
@ -157,17 +156,14 @@ def delete_account(account: Account) -> redirect:
:return: The redirection to the account list on success, or the account
detail on error.
"""
if not account.can_delete:
flash(s(lazy_gettext("The account cannot be deleted.")), "error")
return redirect(inherit_next(__get_detail_uri(account)))
account.delete()
sort_accounts_in(account.base_code, account.id)
db.session.commit()
flash(s(lazy_gettext("The account is deleted successfully.")), "success")
flash(lazy_gettext("The account is deleted successfully."), "success")
return redirect(or_next(__get_list_uri()))
@bp.get("bases/<baseAccount:base>", endpoint="order")
@bp.get("/bases/<baseAccount:base>", endpoint="order")
@has_permission(can_view)
def show_account_order(base: BaseAccount) -> str:
"""Shows the order of the accounts under a same base account.
@ -178,7 +174,7 @@ def show_account_order(base: BaseAccount) -> str:
return render_template("accounting/account/order.html", base=base)
@bp.post("bases/<baseAccount:base>", endpoint="sort")
@bp.post("/bases/<baseAccount:base>", endpoint="sort")
@has_permission(can_edit)
def sort_accounts(base: BaseAccount) -> redirect:
"""Reorders the accounts under a base account.
@ -190,10 +186,10 @@ def sort_accounts(base: BaseAccount) -> redirect:
form: AccountReorderForm = AccountReorderForm(base)
form.save_order()
if not form.is_modified:
flash(s(lazy_gettext("The order was not modified.")), "success")
flash(lazy_gettext("The order was not modified."), "success")
return redirect(or_next(__get_list_uri()))
db.session.commit()
flash(s(lazy_gettext("The order is updated successfully.")), "success")
flash(lazy_gettext("The order is updated successfully."), "success")
return redirect(or_next(__get_list_uri()))

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Project.
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/25
# Copyright (c) 2023 imacat.
@ -19,8 +19,6 @@
"""
from flask import Flask, Blueprint
from .commands import init_base_accounts_command
def init_app(app: Flask, bp: Blueprint) -> None:
"""Initialize the application.
@ -34,3 +32,6 @@ def init_app(app: Flask, bp: Blueprint) -> None:
from .views import bp as base_account_bp
bp.register_blueprint(base_account_bp, url_prefix="/base-accounts")
from .commands import init_base_accounts_command
app.cli.add_command(init_base_accounts_command)

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Project.
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/25
# Copyright (c) 2023 imacat.
@ -19,28 +19,33 @@
"""
import csv
import sqlalchemy as sa
import click
from flask.cli import with_appcontext
from accounting import data_dir
from accounting import db
from accounting.models import BaseAccount, BaseAccountL10n
from accounting.utils.title_case import title_case
@click.command("accounting-init-base")
@with_appcontext
def init_base_accounts_command() -> None:
"""Initializes the base accounts."""
if BaseAccount.query.first() is not None:
return
click.echo("Base accounts already exist.")
raise click.Abort
with open(data_dir / "base_accounts.csv") as fp:
data: list[dict[str, str]] = [x for x in csv.DictReader(fp)]
account_data: list[dict[str, str]] = [{"code": x["code"],
"title_l10n": title_case(x["title"])}
"title_l10n": x["title"]}
for x in data]
locales: list[str] = [x[5:] for x in data[0] if x.startswith("l10n-")]
l10n_data: list[dict[str, str]] = [{"account_code": x["code"],
"locale": y,
"title": x[f"l10n-{y}"]}
for x in data for y in locales]
db.session.execute(sa.insert(BaseAccount), account_data)
db.session.execute(sa.insert(BaseAccountL10n), l10n_data)
db.session.bulk_insert_mappings(BaseAccount, account_data)
db.session.bulk_insert_mappings(BaseAccountL10n, l10n_data)
db.session.commit()
click.echo("Base accounts initialized.")

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Project.
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/1
# Copyright (c) 2023 imacat.

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Project.
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/26
# Copyright (c) 2023 imacat.
@ -35,10 +35,10 @@ def get_base_account_query() -> list[BaseAccount]:
conditions: list[sa.BinaryExpression] = []
for k in keywords:
l10n: list[BaseAccountL10n] = BaseAccountL10n.query\
.filter(BaseAccountL10n.title.icontains(k)).all()
.filter(BaseAccountL10n.title.contains(k)).all()
l10n_matches: set[str] = {x.account_code for x in l10n}
conditions.append(sa.or_(BaseAccount.code.contains(k),
BaseAccount.title_l10n.icontains(k),
BaseAccount.title_l10n.contains(k),
BaseAccount.code.in_(l10n_matches)))
return BaseAccount.query.filter(*conditions)\
.order_by(BaseAccount.code).all()

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Project.
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/26
# Copyright (c) 2023 imacat.
@ -22,7 +22,6 @@ from flask import Blueprint, render_template
from accounting.models import BaseAccount
from accounting.utils.pagination import Pagination
from accounting.utils.permission import has_permission, can_view
from .queries import get_base_account_query
bp: Blueprint = Blueprint("base-account", __name__)
"""The view blueprint for the base account management."""
@ -35,13 +34,14 @@ def list_accounts() -> str:
:return: The account list.
"""
from .queries import get_base_account_query
accounts: list[BaseAccount] = get_base_account_query()
pagination: Pagination = Pagination[BaseAccount](accounts)
return render_template("accounting/base-account/list.html",
list=pagination.list, pagination=pagination)
@bp.get("<baseAccount:account>", endpoint="detail")
@bp.get("/<baseAccount:account>", endpoint="detail")
@has_permission(can_view)
def show_account_detail(account: BaseAccount) -> str:
"""Shows the account detail.

View File

@ -1,94 +0,0 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/10
# Copyright (c) 2023 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""The console commands.
"""
import os
import click
from flask.cli import with_appcontext
from accounting import db
from accounting.account import init_accounts_command
from accounting.base_account import init_base_accounts_command
from accounting.currency import init_currencies_command
from accounting.models import BaseAccount, Account
from accounting.utils.title_case import title_case
from accounting.utils.user import has_user, get_user_pk
import sqlalchemy as sa
def __validate_username(ctx: click.core.Context, param: click.core.Option,
value: str) -> str:
"""Validates the username for the click console command.
:param ctx: The console command context.
:param param: The console command option.
:param value: The username.
:raise click.BadParameter: When validation fails.
:return: The username.
"""
value = value.strip()
if value == "":
raise click.BadParameter("Username empty.")
if not has_user(value):
raise click.BadParameter(f"User {value} does not exist.")
return value
@click.command("accounting-init-db")
@click.option("-u", "--username", metavar="USERNAME", prompt=True,
help="The username.", callback=__validate_username,
default=lambda: os.getlogin())
@with_appcontext
def init_db_command(username: str) -> None:
"""Initializes the accounting database."""
db.create_all()
init_base_accounts_command()
init_accounts_command(username)
init_currencies_command(username)
db.session.commit()
click.echo("Accounting database initialized.")
@click.command("accounting-titleize")
@click.option("-u", "--username", metavar="USERNAME", prompt=True,
help="The username.", callback=__validate_username,
default=lambda: os.getlogin())
@with_appcontext
def titleize_command(username: str) -> None:
"""Capitalize the account titles."""
updater_pk: int = get_user_pk(username)
updated: int = 0
for base in BaseAccount.query:
new_title: str = title_case(base.title_l10n)
if base.title_l10n != new_title:
base.title_l10n = new_title
updated = updated + 1
for account in Account.query:
if account.title_l10n.lower() == account.base.title_l10n.lower():
new_title: str = title_case(account.title_l10n)
if account.title_l10n != new_title:
account.title_l10n = new_title
account.updated_at = sa.func.now()
account.updated_by_id = updater_pk
updated = updated + 1
if updated == 0:
click.echo("All account titles were already capitalized.")
return
db.session.commit()
click.echo(f"{updated} account titles capitalized.")

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Project.
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/6
# Copyright (c) 2023 imacat.
@ -19,8 +19,6 @@
"""
from flask import Flask, Blueprint
from .commands import init_currencies_command
def init_app(app: Flask, bp: Blueprint) -> None:
"""Initialize the application.
@ -35,3 +33,6 @@ def init_app(app: Flask, bp: Blueprint) -> None:
from .views import bp as currency_bp, api_bp as currency_api_bp
bp.register_blueprint(currency_bp, url_prefix="/currencies")
bp.register_blueprint(currency_api_bp, url_prefix="/api/currencies")
from .commands import init_currencies_command
app.cli.add_command(init_currencies_command)

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Project.
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/6
# Copyright (c) 2023 imacat.
@ -18,15 +18,42 @@
"""
import csv
from typing import Any
import os
import typing as t
import sqlalchemy as sa
import click
from flask.cli import with_appcontext
from accounting import db, data_dir
from accounting.models import Currency, CurrencyL10n
from accounting.utils.user import get_user_pk
from accounting.utils.user import has_user, get_user_pk
CurrencyData = tuple[str, str, str, str]
def __validate_username(ctx: click.core.Context, param: click.core.Option,
value: str) -> str:
"""Validates the username for the click console command.
:param ctx: The console command context.
:param param: The console command option.
:param value: The username.
:raise click.BadParameter: When validation fails.
:return: The username.
"""
value = value.strip()
if value == "":
raise click.BadParameter("Username empty.")
if not has_user(value):
raise click.BadParameter(f"User {value} does not exist.")
return value
@click.command("accounting-init-currencies")
@click.option("-u", "--username", metavar="USERNAME", prompt=True,
help="The username.", callback=__validate_username,
default=lambda: os.getlogin())
@with_appcontext
def init_currencies_command(username: str) -> None:
"""Initializes the currencies."""
existing_codes: set[str] = {x.code for x in Currency.query.all()}
@ -36,10 +63,11 @@ def init_currencies_command(username: str) -> None:
to_add: list[dict[str, str]] = [x for x in data
if x["code"] not in existing_codes]
if len(to_add) == 0:
click.echo("No more currency to add.")
return
creator_pk: int = get_user_pk(username)
currency_data: list[dict[str, Any]] = [{"code": x["code"],
currency_data: list[dict[str, t.Any]] = [{"code": x["code"],
"name_l10n": x["name"],
"created_by_id": creator_pk,
"updated_by_id": creator_pk}
@ -49,5 +77,8 @@ def init_currencies_command(username: str) -> None:
"locale": y,
"name": x[f"l10n-{y}"]}
for x in to_add for y in locales]
db.session.execute(sa.insert(Currency), currency_data)
db.session.execute(sa.insert(CurrencyL10n), l10n_data)
db.session.bulk_insert_mappings(Currency, currency_data)
db.session.bulk_insert_mappings(CurrencyL10n, l10n_data)
db.session.commit()
click.echo(F"{len(to_add)} added. Currencies initialized.")

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Project.
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/6
# Copyright (c) 2023 imacat.

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Project.
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/6
# Copyright (c) 2023 imacat.
@ -17,6 +17,8 @@
"""The forms for the currency management.
"""
from __future__ import annotations
from flask_wtf import FlaskForm
from wtforms import StringField, ValidationError
from wtforms.validators import DataRequired, Regexp, NoneOf
@ -28,11 +30,14 @@ from accounting.utils.strip_text import strip_text
from accounting.utils.user import get_current_user_pk
class CurrencyForm(FlaskForm):
"""The form to create or edit a currency."""
CODE_BLOCKLIST: list[str] = ["create", "store", "exists-code"]
"""The reserved codes that are not available."""
class CodeUnique:
"""The validator to check if the code is unique."""
def __call__(self, form: FlaskForm, field: StringField) -> None:
assert isinstance(form, CurrencyForm)
def __call__(self, form: CurrencyForm, field: StringField) -> None:
if field.data == "":
return
if form.obj_code is not None and form.obj_code == field.data:
@ -41,11 +46,6 @@ class CodeUnique:
raise ValidationError(lazy_gettext(
"Code conflicts with another currency."))
class CurrencyForm(FlaskForm):
"""The form to create or edit a currency."""
CODE_BLOCKLIST: list[str] = ["create", "store", "exists-code"]
"""The reserved codes that are not available."""
code = StringField(
filters=[strip_text],
validators=[DataRequired(lazy_gettext("Please fill in the code.")),

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Project.
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/6
# Copyright (c) 2023 imacat.
@ -35,10 +35,10 @@ def get_currency_query() -> list[Currency]:
conditions: list[sa.BinaryExpression] = []
for k in keywords:
l10n: list[CurrencyL10n] = CurrencyL10n.query\
.filter(CurrencyL10n.name.icontains(k)).all()
.filter(CurrencyL10n.name.contains(k)).all()
l10n_matches: set[str] = {x.account_code for x in l10n}
conditions.append(sa.or_(Currency.code.icontains(k),
Currency.name_l10n.icontains(k),
conditions.append(sa.or_(Currency.code.contains(k),
Currency.name_l10n.contains(k),
Currency.code.in_(l10n_matches)))
return Currency.query.filter(*conditions)\
.order_by(Currency.code).all()

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Project.
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/6
# Copyright (c) 2023 imacat.
@ -27,14 +27,12 @@ from werkzeug.datastructures import ImmutableMultiDict
from accounting import db
from accounting.locale import lazy_gettext
from accounting.models import Currency
from accounting.utils.cast import s
from accounting.utils.flash_errors import flash_form_errors
from accounting.utils.next_uri import inherit_next, or_next
from accounting.utils.pagination import Pagination
from accounting.utils.permission import has_permission, can_view, can_edit
from accounting.utils.user import get_current_user_pk
from .forms import CurrencyForm
from .queries import get_currency_query
bp: Blueprint = Blueprint("currency", __name__)
"""The view blueprint for the currency management."""
@ -49,13 +47,14 @@ def list_currencies() -> str:
:return: The currency list.
"""
from .queries import get_currency_query
currencies: list[Currency] = get_currency_query()
pagination: Pagination = Pagination[Currency](currencies)
return render_template("accounting/currency/list.html",
list=pagination.list, pagination=pagination)
@bp.get("create", endpoint="create")
@bp.get("/create", endpoint="create")
@has_permission(can_edit)
def show_add_currency_form() -> str:
"""Shows the form to add a currency.
@ -72,7 +71,7 @@ def show_add_currency_form() -> str:
form=form)
@bp.post("store", endpoint="store")
@bp.post("/store", endpoint="store")
@has_permission(can_edit)
def add_currency() -> redirect:
"""Adds a currency.
@ -89,11 +88,11 @@ def add_currency() -> redirect:
form.populate_obj(currency)
db.session.add(currency)
db.session.commit()
flash(s(lazy_gettext("The currency is added successfully.")), "success")
flash(lazy_gettext("The currency is added successfully"), "success")
return redirect(inherit_next(__get_detail_uri(currency)))
@bp.get("<currency:currency>", endpoint="detail")
@bp.get("/<currency:currency>", endpoint="detail")
@has_permission(can_view)
def show_currency_detail(currency: Currency) -> str:
"""Shows the currency detail.
@ -104,7 +103,7 @@ def show_currency_detail(currency: Currency) -> str:
return render_template("accounting/currency/detail.html", obj=currency)
@bp.get("<currency:currency>/edit", endpoint="edit")
@bp.get("/<currency:currency>/edit", endpoint="edit")
@has_permission(can_edit)
def show_currency_edit_form(currency: Currency) -> str:
"""Shows the form to edit a currency.
@ -123,7 +122,7 @@ def show_currency_edit_form(currency: Currency) -> str:
currency=currency, form=form)
@bp.post("<currency:currency>/update", endpoint="update")
@bp.post("/<currency:currency>/update", endpoint="update")
@has_permission(can_edit)
def update_currency(currency: Currency) -> redirect:
"""Updates a currency.
@ -142,16 +141,16 @@ def update_currency(currency: Currency) -> redirect:
with db.session.no_autoflush:
form.populate_obj(currency)
if not currency.is_modified:
flash(s(lazy_gettext("The currency was not modified.")), "success")
flash(lazy_gettext("The currency was not modified."), "success")
return redirect(inherit_next(__get_detail_uri(currency)))
currency.updated_by_id = get_current_user_pk()
currency.updated_at = sa.func.now()
db.session.commit()
flash(s(lazy_gettext("The currency is updated successfully.")), "success")
flash(lazy_gettext("The currency is updated successfully."), "success")
return redirect(inherit_next(__get_detail_uri(currency)))
@bp.post("<currency:currency>/delete", endpoint="delete")
@bp.post("/<currency:currency>/delete", endpoint="delete")
@has_permission(can_edit)
def delete_currency(currency: Currency) -> redirect:
"""Deletes a currency.
@ -160,16 +159,13 @@ def delete_currency(currency: Currency) -> redirect:
:return: The redirection to the currency list on success, or the currency
detail on error.
"""
if not currency.can_delete:
flash(s(lazy_gettext("The currency cannot be deleted.")), "error")
return redirect(inherit_next(__get_detail_uri(currency)))
currency.delete()
db.session.commit()
flash(s(lazy_gettext("The currency is deleted successfully.")), "success")
flash(lazy_gettext("The currency is deleted successfully."), "success")
return redirect(or_next(url_for("accounting.currency.list")))
@api_bp.get("exists-code", endpoint="exists")
@api_bp.get("/exists-code", endpoint="exists")
@has_permission(can_edit)
def exists_code() -> dict[str, bool]:
"""Validates whether a currency code exists.
@ -186,3 +182,4 @@ def __get_detail_uri(currency: Currency) -> str:
:return: The detail URI of the currency.
"""
return url_for("accounting.currency.detail", currency=currency)

View File

@ -1,96 +0,0 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/22
# Copyright (c) 2023 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""The forms.
"""
import re
from flask_babel import LazyString
from flask_wtf import FlaskForm
from wtforms import StringField, ValidationError
from wtforms.validators import DataRequired
from accounting import db
from accounting.locale import lazy_gettext
from accounting.models import Currency, Account
ACCOUNT_REQUIRED: DataRequired = DataRequired(
lazy_gettext("Please select the account."))
"""The validator to check if the account code is empty."""
class CurrencyExists:
"""The validator to check if the account exists."""
def __call__(self, form: FlaskForm, field: StringField) -> None:
if field.data is None:
return
if db.session.get(Currency, field.data) is None:
raise ValidationError(lazy_gettext(
"The currency does not exist."))
class AccountExists:
"""The validator to check if the account exists."""
def __call__(self, form: FlaskForm, field: StringField) -> None:
if field.data is None:
return
if Account.find_by_code(field.data) is None:
raise ValidationError(lazy_gettext(
"The account does not exist."))
class IsDebitAccount:
"""The validator to check if the account is for debit line items."""
def __init__(self, message: str | LazyString):
"""Constructs the validator.
:param message: The error message.
"""
self.__message: str | LazyString = message
"""The error message."""
def __call__(self, form: FlaskForm, field: StringField) -> None:
if field.data is None:
return
if re.match(r"^(?:[1235689]|7[5678])", field.data) \
and not field.data.startswith("3353-"):
return
raise ValidationError(self.__message)
class IsCreditAccount:
"""The validator to check if the account is for credit line items."""
def __init__(self, message: str | LazyString):
"""Constructs the validator.
:param message: The error message.
"""
self.__message: str | LazyString = message
"""The error message."""
def __call__(self, form: FlaskForm, field: StringField) -> None:
if field.data is None:
return
if re.match(r"^(?:[123489]|7[1234])", field.data) \
and not field.data.startswith("3353-"):
return
raise ValidationError(self.__message)

View File

@ -1,102 +0,0 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/19
# Copyright (c) 2023 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""The path converters for the journal entry management.
"""
import datetime as dt
from flask import abort
from werkzeug.routing import BaseConverter
from accounting import db
from accounting.models import JournalEntry
from accounting.utils.journal_entry_types import JournalEntryType
class JournalEntryConverter(BaseConverter):
"""The journal entry converter to convert the journal entry ID from and to
the corresponding journal entry in the routes."""
def to_python(self, value: str) -> JournalEntry:
"""Converts a journal entry ID to a journal entry.
:param value: The journal entry ID.
:return: The corresponding journal entry.
"""
journal_entry: JournalEntry | None \
= db.session.get(JournalEntry, value)
if journal_entry is None:
abort(404)
return journal_entry
def to_url(self, value: JournalEntry) -> str:
"""Converts a journal entry to its ID.
:param value: The journal entry.
:return: The ID.
"""
return str(value.id)
class JournalEntryTypeConverter(BaseConverter):
"""The journal entry converter to convert the journal entry type ID from
and to the corresponding journal entry type in the routes."""
def to_python(self, value: str) -> JournalEntryType:
"""Converts a journal entry ID to a journal entry.
:param value: The journal entry ID.
:return: The corresponding journal entry type.
"""
type_dict: dict[str, JournalEntryType] \
= {x.value: x for x in JournalEntryType}
journal_entry_type: JournalEntryType | None = type_dict.get(value)
if journal_entry_type is None:
abort(404)
return journal_entry_type
def to_url(self, value: JournalEntryType) -> str:
"""Converts a journal entry type to its ID.
:param value: The journal entry type.
:return: The ID.
"""
return str(value.value)
class DateConverter(BaseConverter):
"""The date converter to convert the ISO date from and to the
corresponding date in the routes."""
def to_python(self, value: str) -> dt.date:
"""Converts an ISO date to a date.
:param value: The ISO date.
:return: The corresponding date.
"""
try:
return dt.date.fromisoformat(value)
except ValueError:
abort(404)
def to_url(self, value: dt.date) -> str:
"""Converts a date to its ISO date.
:param value: The date.
:return: The ISO date.
"""
return value.isoformat()

View File

@ -1,22 +0,0 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/10
# Copyright (c) 2023 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""The forms for the journal entry management.
"""
from .reorder import sort_journal_entries_in, JournalEntryReorderForm
from .journal_entry import JournalEntryForm, CashReceiptJournalEntryForm, \
CashDisbursementJournalEntryForm, TransferJournalEntryForm

View File

@ -1,292 +0,0 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/10
# Copyright (c) 2023 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""The currency sub-forms for the journal entry management.
"""
from decimal import Decimal
import sqlalchemy as sa
from flask_babel import LazyString
from flask_wtf import FlaskForm
from wtforms import StringField, ValidationError, FieldList, IntegerField, \
BooleanField, FormField
from wtforms.validators import DataRequired
from accounting import db
from accounting.forms import CurrencyExists
from accounting.locale import lazy_gettext
from accounting.models import JournalEntryLineItem
from accounting.utils.offset_alias import offset_alias
from accounting.utils.strip_text import strip_text
from .line_item import LineItemForm, CreditLineItemForm, DebitLineItemForm
CURRENCY_REQUIRED: DataRequired = DataRequired(
lazy_gettext("Please select the currency."))
"""The validator to check if the currency code is empty."""
class SameCurrencyAsOriginalLineItems:
"""The validator to check if the currency is the same as the
original line items."""
def __call__(self, form: FlaskForm, field: StringField) -> None:
assert isinstance(form, CurrencyForm)
if field.data is None:
return
original_line_item_id: set[int] \
= {x.original_line_item_id.data
for x in form.line_items
if x.original_line_item_id.data is not None}
if len(original_line_item_id) == 0:
return
original_line_item_currency_codes: set[str] = set(db.session.scalars(
sa.select(JournalEntryLineItem.currency_code)
.filter(JournalEntryLineItem.id.in_(original_line_item_id))).all())
for currency_code in original_line_item_currency_codes:
if field.data != currency_code:
raise ValidationError(lazy_gettext(
"The currency must be the same as the"
" original line item."))
class KeepCurrencyWhenHavingOffset:
"""The validator to check if the currency is the same when there is
offset."""
def __call__(self, form: FlaskForm, field: StringField) -> None:
assert isinstance(form, CurrencyForm)
if field.data is None:
return
offset: sa.Alias = offset_alias()
original_line_items: list[JournalEntryLineItem]\
= JournalEntryLineItem.query\
.join(offset,
JournalEntryLineItem.id == offset.c.original_line_item_id,
isouter=True)\
.filter(JournalEntryLineItem.id
.in_({x.id.data for x in form.line_items
if x.id.data is not None}))\
.group_by(JournalEntryLineItem.id,
JournalEntryLineItem.currency_code)\
.having(sa.func.count(offset.c.id) > 0).all()
for original_line_item in original_line_items:
if original_line_item.currency_code != field.data:
raise ValidationError(lazy_gettext(
"The currency must not be changed when there is offset."))
class NeedSomeLineItems:
"""The validator to check if there is any line item sub-form."""
def __call__(self, form: FlaskForm, field: FieldList) -> None:
if len(field) == 0:
raise ValidationError(lazy_gettext(
"Please add some line items."))
class IsBalanced:
"""The validator to check that the total amount of the debit and credit
line items are equal."""
def __call__(self, form: FlaskForm, field: BooleanField) -> None:
assert isinstance(form, TransferCurrencyForm)
if len(form.debit) == 0 or len(form.credit) == 0:
return
if form.debit_total != form.credit_total:
raise ValidationError(lazy_gettext(
"The totals of the debit and credit amounts do not match."))
class CurrencyForm(FlaskForm):
"""The form to create or edit a currency in a journal entry."""
no = IntegerField()
"""The order in the journal entry."""
code = StringField()
"""The currency code."""
whole_form = BooleanField()
"""The pseudo field for the whole form validators."""
@property
def line_items(self) -> list[LineItemForm]:
"""Returns the line item sub-forms.
:return: The line item sub-forms.
"""
line_item_forms: list[LineItemForm] = []
if isinstance(self, CashReceiptCurrencyForm):
line_item_forms.extend([x.form for x in self.credit])
elif isinstance(self, CashDisbursementCurrencyForm):
line_item_forms.extend([x.form for x in self.debit])
elif isinstance(self, TransferCurrencyForm):
line_item_forms.extend([x.form for x in self.debit])
line_item_forms.extend([x.form for x in self.credit])
return line_item_forms
@property
def is_code_locked(self) -> bool:
"""Returns whether the currency code should not be changed.
:return: True if the currency code should not be changed, or False
otherwise
"""
line_item_forms: list[LineItemForm] = self.line_items
original_line_item_id: set[int] \
= {x.original_line_item_id.data for x in line_item_forms
if x.original_line_item_id.data is not None}
if len(original_line_item_id) > 0:
return True
line_item_id: set[int] = {x.id.data for x in line_item_forms
if x.id.data is not None}
select: sa.Select = sa.select(sa.func.count(JournalEntryLineItem.id))\
.filter(JournalEntryLineItem.original_line_item_id
.in_(line_item_id))
return db.session.scalar(select) > 0
class CashReceiptCurrencyForm(CurrencyForm):
"""The form to create or edit a currency in a
cash receipt journal entry."""
no = IntegerField()
"""The order in the journal entry."""
code = StringField(
filters=[strip_text],
validators=[CURRENCY_REQUIRED,
CurrencyExists(),
SameCurrencyAsOriginalLineItems(),
KeepCurrencyWhenHavingOffset()])
"""The currency code."""
credit = FieldList(FormField(CreditLineItemForm),
validators=[NeedSomeLineItems()])
"""The credit line items."""
whole_form = BooleanField()
"""The pseudo field for the whole form validators."""
@property
def credit_total(self) -> Decimal:
"""Returns the total amount of the credit line items.
:return: The total amount of the credit line items.
"""
return sum([x.amount.data for x in self.credit
if x.amount.data is not None])
@property
def credit_errors(self) -> list[str | LazyString]:
"""Returns the credit line item errors, without the errors in their
sub-forms.
:return:
"""
return [x for x in self.credit.errors
if isinstance(x, str) or isinstance(x, LazyString)]
class CashDisbursementCurrencyForm(CurrencyForm):
"""The form to create or edit a currency in a
cash disbursement journal entry."""
no = IntegerField()
"""The order in the journal entry."""
code = StringField(
filters=[strip_text],
validators=[CURRENCY_REQUIRED,
CurrencyExists(),
SameCurrencyAsOriginalLineItems(),
KeepCurrencyWhenHavingOffset()])
"""The currency code."""
debit = FieldList(FormField(DebitLineItemForm),
validators=[NeedSomeLineItems()])
"""The debit line items."""
whole_form = BooleanField()
"""The pseudo field for the whole form validators."""
@property
def debit_total(self) -> Decimal:
"""Returns the total amount of the debit line items.
:return: The total amount of the debit line items.
"""
return sum([x.amount.data for x in self.debit
if x.amount.data is not None])
@property
def debit_errors(self) -> list[str | LazyString]:
"""Returns the debit line item errors, without the errors in their
sub-forms.
:return:
"""
return [x for x in self.debit.errors
if isinstance(x, str) or isinstance(x, LazyString)]
class TransferCurrencyForm(CurrencyForm):
"""The form to create or edit a currency in a transfer journal entry."""
no = IntegerField()
"""The order in the journal entry."""
code = StringField(
filters=[strip_text],
validators=[CURRENCY_REQUIRED,
CurrencyExists(),
SameCurrencyAsOriginalLineItems(),
KeepCurrencyWhenHavingOffset()])
"""The currency code."""
debit = FieldList(FormField(DebitLineItemForm),
validators=[NeedSomeLineItems()])
"""The debit line items."""
credit = FieldList(FormField(CreditLineItemForm),
validators=[NeedSomeLineItems()])
"""The credit line items."""
whole_form = BooleanField(validators=[IsBalanced()])
"""The pseudo field for the whole form validators."""
@property
def debit_total(self) -> Decimal:
"""Returns the total amount of the debit line items.
:return: The total amount of the debit line items.
"""
return sum([x.amount.data for x in self.debit
if x.amount.data is not None])
@property
def credit_total(self) -> Decimal:
"""Returns the total amount of the credit line items.
:return: The total amount of the credit line items.
"""
return sum([x.amount.data for x in self.credit
if x.amount.data is not None])
@property
def debit_errors(self) -> list[str | LazyString]:
"""Returns the debit line item errors, without the errors in their
sub-forms.
:return:
"""
return [x for x in self.debit.errors
if isinstance(x, str) or isinstance(x, LazyString)]
@property
def credit_errors(self) -> list[str | LazyString]:
"""Returns the credit line item errors, without the errors in their
sub-forms.
:return:
"""
return [x for x in self.credit.errors
if isinstance(x, str) or isinstance(x, LazyString)]

View File

@ -1,588 +0,0 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/18
# Copyright (c) 2023-2024 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""The journal entry forms for the journal entry management.
"""
import datetime as dt
from abc import ABC, abstractmethod
from typing import Type
import sqlalchemy as sa
from flask_babel import LazyString
from flask_wtf import FlaskForm
from wtforms import DateField, FieldList, FormField, TextAreaField, \
BooleanField
from wtforms.validators import DataRequired, ValidationError
from accounting import db
from accounting.journal_entry.utils.account_option import AccountOption
from accounting.journal_entry.utils.description_editor import DescriptionEditor
from accounting.journal_entry.utils.original_line_items import \
get_selectable_original_line_items
from accounting.locale import lazy_gettext
from accounting.models import JournalEntry, Account, JournalEntryLineItem, \
JournalEntryCurrency
from accounting.utils.random_id import new_id
from accounting.utils.strip_text import strip_multiline_text
from accounting.utils.user import get_current_user_pk
from .currency import CurrencyForm, CashReceiptCurrencyForm, \
CashDisbursementCurrencyForm, TransferCurrencyForm
from .line_item import LineItemForm, DebitLineItemForm, CreditLineItemForm
from .reorder import sort_journal_entries_in
DATE_REQUIRED: DataRequired = DataRequired(
lazy_gettext("Please fill in the date."))
"""The validator to check if the date is empty."""
class NotBeforeOriginalLineItems:
"""The validator to check if the date is not before the
original line items."""
def __call__(self, form: FlaskForm, field: DateField) -> None:
assert isinstance(form, JournalEntryForm)
if field.data is None:
return
min_date: dt.date | None = form.min_date
if min_date is None:
return
if field.data < min_date:
raise ValidationError(lazy_gettext(
"The date cannot be earlier than the original line items."))
class NotAfterOffsetItems:
"""The validator to check if the date is not after the offset items."""
def __call__(self, form: FlaskForm, field: DateField) -> None:
assert isinstance(form, JournalEntryForm)
if field.data is None:
return
max_date: dt.date | None = form.max_date
if max_date is None:
return
if field.data > max_date:
raise ValidationError(lazy_gettext(
"The date cannot be later than the offset items."))
class NeedSomeCurrencies:
"""The validator to check if there is any currency sub-form."""
def __call__(self, form: FlaskForm, field: FieldList) -> None:
if len(field) == 0:
raise ValidationError(lazy_gettext("Please add some currencies."))
class CannotDeleteOriginalLineItemsWithOffset:
"""The validator to check the original line items with offset."""
def __call__(self, form: FlaskForm, field: FieldList) -> None:
assert isinstance(form, JournalEntryForm)
if form.obj is None:
return
existing_matched_original_line_item_id: set[int] \
= {x.id for x in form.obj.line_items if len(x.offsets) > 0}
line_item_id_in_form: set[int] \
= {x.id.data for x in form.line_items if x.id.data is not None}
for line_item_id in existing_matched_original_line_item_id:
if line_item_id not in line_item_id_in_form:
raise ValidationError(lazy_gettext(
"Line items with offset cannot be deleted."))
class JournalEntryForm(FlaskForm):
"""The base form to create or edit a journal entry."""
date = DateField()
"""The date."""
currencies = FieldList(FormField(CurrencyForm))
"""The line items categorized by their currencies."""
note = TextAreaField()
"""The note."""
def __init__(self, *args, **kwargs):
"""Constructs a base journal entry form.
:param args: The arguments.
:param kwargs: The keyword arguments.
"""
super().__init__(*args, **kwargs)
self.is_modified: bool = False
"""Whether the journal entry is modified during populate_obj()."""
self.collector: Type[LineItemCollector] = LineItemCollector
"""The line item collector. The default is the base abstract
collector only to provide the correct type. The subclass forms should
provide their own collectors."""
self.obj: JournalEntry | None = kwargs.get("obj")
"""The journal entry, when editing an existing one."""
self._is_need_payable: bool = False
"""Whether we need the payable original line items."""
self._is_need_receivable: bool = False
"""Whether we need the receivable original line items."""
self.__original_line_item_options: list[JournalEntryLineItem] | None \
= None
"""The options of the original line items."""
self.__net_balance_exceeded: dict[int, LazyString] | None = None
"""The original line items whose net balances were exceeded by the
amounts in the line item sub-forms."""
for line_item in self.line_items:
line_item.journal_entry_form = self
def populate_obj(self, obj: JournalEntry) -> None:
"""Populates the form data into a journal entry object.
:param obj: The journal entry object.
:return: None.
"""
is_new: bool = obj.id is None
if is_new:
obj.id = new_id(JournalEntry)
self.__set_date(obj, self.date.data)
obj.note = self.note.data
collector_cls: Type[LineItemCollector] = self.collector
collector: collector_cls = collector_cls(self, obj)
collector.collect()
to_delete: set[int] = {x.id for x in obj.line_items
if x.id not in collector.to_keep}
if len(to_delete) > 0:
JournalEntryLineItem.query\
.filter(JournalEntryLineItem.id.in_(to_delete)).delete()
self.is_modified = True
if is_new or db.session.is_modified(obj):
self.is_modified = True
if is_new:
current_user_pk: int = get_current_user_pk()
obj.created_by_id = current_user_pk
obj.updated_by_id = current_user_pk
@property
def line_items(self) -> list[LineItemForm]:
"""Collects and returns the line item sub-forms.
:return: The line item sub-forms.
"""
line_items: list[LineItemForm] = []
for currency in self.currencies:
line_items.extend(currency.line_items)
return line_items
def __set_date(self, obj: JournalEntry, new_date: dt.date) -> None:
"""Sets the journal entry date and number.
:param obj: The journal entry object.
:param new_date: The new date.
:return: None.
"""
if obj.date is None or obj.date != new_date:
if obj.date is not None:
sort_journal_entries_in(obj.date, obj.id)
if self.max_date is not None and new_date == self.max_date:
db_min_no: int | None = db.session.scalar(
sa.select(sa.func.min(JournalEntry.no))
.filter(JournalEntry.date == new_date))
if db_min_no is None:
obj.date = new_date
obj.no = 1
else:
obj.date = new_date
obj.no = db_min_no - 1
sort_journal_entries_in(new_date)
else:
sort_journal_entries_in(new_date, obj.id)
count: int = JournalEntry.query\
.filter(JournalEntry.date == new_date).count()
obj.date = new_date
obj.no = count + 1
@property
def debit_account_options(self) -> list[AccountOption]:
"""The selectable debit accounts.
:return: The selectable debit accounts.
"""
accounts: list[AccountOption] \
= [AccountOption(x) for x in Account.selectable_debit()
if not (x.code[0] == "2" and x.is_need_offset)]
in_use: set[int] = set(db.session.scalars(
sa.select(JournalEntryLineItem.account_id)
.filter(JournalEntryLineItem.is_debit)
.group_by(JournalEntryLineItem.account_id)).all())
for account in accounts:
account.is_in_use = account.id in in_use
return accounts
@property
def credit_account_options(self) -> list[AccountOption]:
"""The selectable credit accounts.
:return: The selectable credit accounts.
"""
accounts: list[AccountOption] \
= [AccountOption(x) for x in Account.selectable_credit()
if not (x.code[0] == "1" and x.is_need_offset)]
in_use: set[int] = set(db.session.scalars(
sa.select(JournalEntryLineItem.account_id)
.filter(sa.not_(JournalEntryLineItem.is_debit))
.group_by(JournalEntryLineItem.account_id)).all())
for account in accounts:
account.is_in_use = account.id in in_use
return accounts
@property
def currencies_errors(self) -> list[str | LazyString]:
"""Returns the currency errors, without the errors in their sub-forms.
:return: The currency errors, without the errors in their sub-forms.
"""
return [x for x in self.currencies.errors
if isinstance(x, str) or isinstance(x, LazyString)]
@property
def description_editor(self) -> DescriptionEditor:
"""Returns the description editor.
:return: The description editor.
"""
return DescriptionEditor()
@property
def original_line_item_options(self) -> list[JournalEntryLineItem]:
"""Returns the selectable original line items.
:return: The selectable original line items.
"""
if self.__original_line_item_options is None:
self.__original_line_item_options \
= get_selectable_original_line_items(
{x.id.data for x in self.line_items
if x.id.data is not None},
self._is_need_payable, self._is_need_receivable)
return self.__original_line_item_options
@property
def min_date(self) -> dt.date | None:
"""Returns the minimal available date.
:return: The minimal available date.
"""
original_line_item_id: set[int] \
= {x.original_line_item_id.data for x in self.line_items
if x.original_line_item_id.data is not None}
if len(original_line_item_id) == 0:
return None
select: sa.Select = sa.select(sa.func.max(JournalEntry.date))\
.join(JournalEntryLineItem)\
.filter(JournalEntryLineItem.id.in_(original_line_item_id))
return db.session.scalar(select)
@property
def max_date(self) -> dt.date | None:
"""Returns the maximum available date.
:return: The maximum available date.
"""
line_item_id: set[int] = {x.id.data for x in self.line_items
if x.id.data is not None}
select: sa.Select = sa.select(sa.func.min(JournalEntry.date))\
.join(JournalEntryLineItem)\
.filter(JournalEntryLineItem.original_line_item_id
.in_(line_item_id))
return db.session.scalar(select)
class LineItemCollector[T: JournalEntryForm](ABC):
"""The line item collector."""
def __init__(self, form: T, obj: JournalEntry):
"""Constructs the line item collector.
:param form: The journal entry form.
:param obj: The journal entry.
"""
self.form: T = form
"""The journal entry form."""
self.__obj: JournalEntry = obj
"""The journal entry object."""
self.__line_items: list[JournalEntryLineItem] = list(obj.line_items)
"""The existing line items."""
self.__line_items_by_id: dict[int, JournalEntryLineItem] \
= {x.id: x for x in self.__line_items}
"""A dictionary from the line item ID to their line items."""
self.__no_by_id: dict[int, int] \
= {x.id: x.no for x in self.__line_items}
"""A dictionary from the line item number to their line items."""
self.__currencies: list[JournalEntryCurrency] = obj.currencies
"""The currencies in the journal entry."""
self._debit_no: int = 1
"""The number index for the debit line items."""
self._credit_no: int = 1
"""The number index for the credit line items."""
self.to_keep: set[int] = set()
"""The ID of the existing line items to keep."""
@abstractmethod
def collect(self) -> set[int]:
"""Collects the line items.
:return: The ID of the line items to keep.
"""
def _add_line_item(self, form: LineItemForm, currency_code: str, no: int) \
-> None:
"""Composes a line item from the form.
:param form: The line item form.
:param currency_code: The code of the currency.
:param no: The number of the line item.
:return: None.
"""
line_item: JournalEntryLineItem | None \
= self.__line_items_by_id.get(form.id.data)
if line_item is not None:
line_item.currency_code = currency_code
form.populate_obj(line_item)
line_item.no = no
if db.session.is_modified(line_item):
self.form.is_modified = True
else:
line_item = JournalEntryLineItem()
line_item.currency_code = currency_code
form.populate_obj(line_item)
line_item.no = no
self.__obj.line_items.append(line_item)
self.form.is_modified = True
self.to_keep.add(line_item.id)
def _make_cash_line_item(self, forms: list[LineItemForm], is_debit: bool,
currency_code: str, no: int) -> None:
"""Composes the cash line item at the other debit or credit of the
cash journal entry.
:param forms: The line item forms in the same currency.
:param is_debit: True for a cash receipt journal entry, or False for a
cash disbursement journal entry.
:param currency_code: The code of the currency.
:param no: The number of the line item.
:return: None.
"""
candidates: list[JournalEntryLineItem] \
= [x for x in self.__line_items
if x.is_debit == is_debit and x.currency_code == currency_code]
line_item: JournalEntryLineItem
if len(candidates) > 0:
candidates.sort(key=lambda x: x.no)
line_item = candidates[0]
line_item.account_id = Account.cash().id
line_item.description = None
line_item.amount = sum([x.amount.data for x in forms])
line_item.no = no
if db.session.is_modified(line_item):
self.form.is_modified = True
else:
line_item = JournalEntryLineItem()
line_item.id = new_id(JournalEntryLineItem)
line_item.is_debit = is_debit
line_item.currency_code = currency_code
line_item.account_id = Account.cash().id
line_item.description = None
line_item.amount = sum([x.amount.data for x in forms])
line_item.no = no
self.__obj.line_items.append(line_item)
self.form.is_modified = True
self.to_keep.add(line_item.id)
def _sort_line_item_forms(self, forms: list[LineItemForm]) -> None:
"""Sorts the line item sub-forms.
:param forms: The line item sub-forms.
:return: None.
"""
missing_no: int = 100 if len(self.__no_by_id) == 0 \
else max(self.__no_by_id.values()) + 100
ord_by_form: dict[LineItemForm, int] \
= {forms[i]: i for i in range(len(forms))}
recv_no: set[int] = {x.no.data for x in forms if x.no.data is not None}
missing_recv_no: int = 100 if len(recv_no) == 0 else max(recv_no) + 100
forms.sort(key=lambda x: (x.no.data or missing_recv_no,
missing_no if x.id.data is None else
self.__no_by_id.get(x.id.data, missing_no),
ord_by_form.get(x)))
def _sort_currency_forms(self, forms: list[CurrencyForm]) -> None:
"""Sorts the currency forms.
:param forms: The currency forms.
:return: None.
"""
missing_no: int = len(self.__currencies) + 100
no_by_code: dict[str, int] = {self.__currencies[i].code: i
for i in range(len(self.__currencies))}
ord_by_form: dict[CurrencyForm, int] \
= {forms[i]: i for i in range(len(forms))}
recv_no: set[int] = {x.no.data for x in forms if x.no.data is not None}
missing_recv_no: int = 100 if len(recv_no) == 0 else max(recv_no) + 100
forms.sort(key=lambda x: (x.no.data or missing_recv_no,
no_by_code.get(x.code.data, missing_no),
ord_by_form.get(x)))
class CashReceiptJournalEntryForm(JournalEntryForm):
"""The form to create or edit a cash receipt journal entry."""
date = DateField(
validators=[DATE_REQUIRED,
NotBeforeOriginalLineItems(),
NotAfterOffsetItems()])
"""The date."""
currencies = FieldList(FormField(CashReceiptCurrencyForm), name="currency",
validators=[NeedSomeCurrencies()])
"""The line items categorized by their currencies."""
note = TextAreaField(filters=[strip_multiline_text])
"""The note."""
whole_form = BooleanField(
validators=[CannotDeleteOriginalLineItemsWithOffset()])
"""The pseudo field for the whole form validators."""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._is_need_receivable = True
class Collector(LineItemCollector[CashReceiptJournalEntryForm]):
"""The line item collector for the cash receipt journal entries."""
def collect(self) -> None:
currencies: list[CashReceiptCurrencyForm] \
= [x.form for x in self.form.currencies]
self._sort_currency_forms(currencies)
for currency in currencies:
# The debit cash line item
self._make_cash_line_item(list(currency.credit), True,
currency.code.data,
self._debit_no)
self._debit_no = self._debit_no + 1
# The credit forms
credit_forms: list[CreditLineItemForm] \
= [x.form for x in currency.credit]
self._sort_line_item_forms(credit_forms)
for credit_form in credit_forms:
self._add_line_item(credit_form, currency.code.data,
self._credit_no)
self._credit_no = self._credit_no + 1
self.collector = Collector
class CashDisbursementJournalEntryForm(JournalEntryForm):
"""The form to create or edit a cash disbursement journal entry."""
date = DateField(
validators=[DATE_REQUIRED,
NotBeforeOriginalLineItems(),
NotAfterOffsetItems()])
"""The date."""
currencies = FieldList(FormField(CashDisbursementCurrencyForm),
name="currency",
validators=[NeedSomeCurrencies()])
"""The line items categorized by their currencies."""
note = TextAreaField(filters=[strip_multiline_text])
"""The note."""
whole_form = BooleanField(
validators=[CannotDeleteOriginalLineItemsWithOffset()])
"""The pseudo field for the whole form validators."""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._is_need_payable = True
class Collector(LineItemCollector[CashDisbursementJournalEntryForm]):
"""The line item collector for the cash disbursement journal
entries."""
def collect(self) -> None:
currencies: list[CashDisbursementCurrencyForm] \
= [x.form for x in self.form.currencies]
self._sort_currency_forms(currencies)
for currency in currencies:
# The debit forms
debit_forms: list[DebitLineItemForm] \
= [x.form for x in currency.debit]
self._sort_line_item_forms(debit_forms)
for debit_form in debit_forms:
self._add_line_item(debit_form, currency.code.data,
self._debit_no)
self._debit_no = self._debit_no + 1
# The credit forms
self._make_cash_line_item(list(currency.debit), False,
currency.code.data,
self._credit_no)
self._credit_no = self._credit_no + 1
self.collector = Collector
class TransferJournalEntryForm(JournalEntryForm):
"""The form to create or edit a transfer journal entry."""
date = DateField(
validators=[DATE_REQUIRED,
NotBeforeOriginalLineItems(),
NotAfterOffsetItems()])
"""The date."""
currencies = FieldList(FormField(TransferCurrencyForm), name="currency",
validators=[NeedSomeCurrencies()])
"""The line items categorized by their currencies."""
note = TextAreaField(filters=[strip_multiline_text])
"""The note."""
whole_form = BooleanField(
validators=[CannotDeleteOriginalLineItemsWithOffset()])
"""The pseudo field for the whole form validators."""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._is_need_payable = True
self._is_need_receivable = True
class Collector(LineItemCollector[TransferJournalEntryForm]):
"""The line item collector for the transfer journal entries."""
def collect(self) -> None:
currencies: list[TransferCurrencyForm] \
= [x.form for x in self.form.currencies]
self._sort_currency_forms(currencies)
for currency in currencies:
# The debit forms
debit_forms: list[DebitLineItemForm] \
= [x.form for x in currency.debit]
self._sort_line_item_forms(debit_forms)
for debit_form in debit_forms:
self._add_line_item(debit_form, currency.code.data,
self._debit_no)
self._debit_no = self._debit_no + 1
# The credit forms
credit_forms: list[CreditLineItemForm] \
= [x.form for x in currency.credit]
self._sort_line_item_forms(credit_forms)
for credit_form in credit_forms:
self._add_line_item(credit_form, currency.code.data,
self._credit_no)
self._credit_no = self._credit_no + 1
self.collector = Collector

View File

@ -1,507 +0,0 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/10
# Copyright (c) 2023 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""The line item sub-forms for the journal entry management.
"""
import datetime as dt
from decimal import Decimal
import sqlalchemy as sa
from flask_babel import LazyString
from flask_wtf import FlaskForm
from sqlalchemy.orm import selectinload
from wtforms import StringField, ValidationError, DecimalField, IntegerField
from wtforms.validators import Optional
from accounting import db
from accounting.forms import ACCOUNT_REQUIRED, AccountExists, IsDebitAccount, \
IsCreditAccount
from accounting.locale import lazy_gettext
from accounting.models import Account, JournalEntry, JournalEntryLineItem
from accounting.template_filters import format_amount
from accounting.utils.random_id import new_id
from accounting.utils.strip_text import strip_text
from accounting.utils.user import get_current_user_pk
class OriginalLineItemExists:
"""The validator to check if the original line item exists."""
def __call__(self, form: FlaskForm, field: IntegerField) -> None:
if field.data is None:
return
if db.session.get(JournalEntryLineItem, field.data) is None:
raise ValidationError(lazy_gettext(
"The original line item does not exist."))
class OriginalLineItemOppositeDebitCredit:
"""The validator to check if the original line item is on the opposite
debit or credit."""
def __call__(self, form: FlaskForm, field: IntegerField) -> None:
if field.data is None:
return
original_line_item: JournalEntryLineItem | None \
= db.session.get(JournalEntryLineItem, field.data)
if original_line_item is None:
return
if isinstance(form, CreditLineItemForm) \
and original_line_item.is_debit:
return
if isinstance(form, DebitLineItemForm) \
and not original_line_item.is_debit:
return
raise ValidationError(lazy_gettext(
"The original line item is on the same debit or credit."))
class OriginalLineItemNeedOffset:
"""The validator to check if the original line item needs offset."""
def __call__(self, form: FlaskForm, field: IntegerField) -> None:
if field.data is None:
return
original_line_item: JournalEntryLineItem | None \
= db.session.get(JournalEntryLineItem, field.data)
if original_line_item is None:
return
if not original_line_item.account.is_need_offset:
raise ValidationError(lazy_gettext(
"The original line item does not need offset."))
class OriginalLineItemNotOffset:
"""The validator to check if the original line item is not itself an
offset item."""
def __call__(self, form: FlaskForm, field: IntegerField) -> None:
if field.data is None:
return
original_line_item: JournalEntryLineItem | None \
= db.session.get(JournalEntryLineItem, field.data)
if original_line_item is None:
return
if original_line_item.original_line_item_id is not None:
raise ValidationError(lazy_gettext(
"The original line item cannot be an offset item."))
class SameAccountAsOriginalLineItem:
"""The validator to check if the account is the same as the
original line item."""
def __call__(self, form: FlaskForm, field: StringField) -> None:
assert isinstance(form, LineItemForm)
if field.data is None or form.original_line_item_id.data is None:
return
original_line_item: JournalEntryLineItem | None \
= db.session.get(JournalEntryLineItem,
form.original_line_item_id.data)
if original_line_item is None:
return
if field.data != original_line_item.account_code:
raise ValidationError(lazy_gettext(
"The account must be the same as the original line item."))
class KeepAccountWhenHavingOffset:
"""The validator to check if the account is the same when having offset."""
def __call__(self, form: FlaskForm, field: StringField) -> None:
assert isinstance(form, LineItemForm)
if field.data is None or form.id.data is None:
return
line_item: JournalEntryLineItem | None \
= db.session.get(JournalEntryLineItem, form.id.data)
if line_item is None or len(line_item.offsets) == 0:
return
if field.data != line_item.account_code:
raise ValidationError(lazy_gettext(
"The account must not be changed when there is offset."))
class NotStartPayableFromDebit:
"""The validator to check that a payable line item does not start from
debit."""
def __call__(self, form: FlaskForm, field: StringField) -> None:
assert isinstance(form, DebitLineItemForm)
if field.data is None \
or field.data[0] != "2" \
or form.original_line_item_id.data is not None:
return
account: Account | None = Account.find_by_code(field.data)
if account is not None and account.is_need_offset:
raise ValidationError(lazy_gettext(
"A payable line item cannot start from debit."))
class NotStartReceivableFromCredit:
"""The validator to check that a receivable line item does not start
from credit."""
def __call__(self, form: FlaskForm, field: StringField) -> None:
assert isinstance(form, CreditLineItemForm)
if field.data is None \
or field.data[0] != "1" \
or form.original_line_item_id.data is not None:
return
account: Account | None = Account.find_by_code(field.data)
if account is not None and account.is_need_offset:
raise ValidationError(lazy_gettext(
"A receivable line item cannot start from credit."))
class PositiveAmount:
"""The validator to check if the amount is positive."""
def __call__(self, form: FlaskForm, field: DecimalField) -> None:
if field.data is None:
return
if field.data <= 0:
raise ValidationError(lazy_gettext(
"Please fill in a positive amount."))
class NotExceedingOriginalLineItemNetBalance:
"""The validator to check if the amount exceeds the net balance of the
original line item."""
def __call__(self, form: FlaskForm, field: DecimalField) -> None:
assert isinstance(form, LineItemForm)
if field.data is None or form.original_line_item_id.data is None:
return
original_line_item: JournalEntryLineItem | None \
= db.session.get(JournalEntryLineItem,
form.original_line_item_id.data)
if original_line_item is None:
return
is_debit: bool = isinstance(form, DebitLineItemForm)
existing_line_item_id: set[int] = set()
if form.journal_entry_form.obj is not None:
existing_line_item_id \
= {x.id for x in form.journal_entry_form.obj.line_items}
offset_total_func: sa.Function = sa.func.sum(sa.case(
(JournalEntryLineItem.is_debit == is_debit,
JournalEntryLineItem.amount),
else_=-JournalEntryLineItem.amount))
offset_total_but_form: Decimal | None = db.session.scalar(
sa.select(offset_total_func)
.filter(JournalEntryLineItem.original_line_item_id
== original_line_item.id,
JournalEntryLineItem.id.not_in(existing_line_item_id)))
if offset_total_but_form is None:
offset_total_but_form = Decimal("0")
offset_total_on_form: Decimal = sum(
[x.amount.data for x in form.journal_entry_form.line_items
if x.original_line_item_id.data == original_line_item.id
and x.amount != field and x.amount.data is not None])
net_balance: Decimal = original_line_item.amount \
- offset_total_but_form - offset_total_on_form
if field.data > net_balance:
raise ValidationError(lazy_gettext(
"The amount must not exceed the net balance %(balance)s of the"
" original line item.", balance=format_amount(net_balance)))
class NotLessThanOffsetTotal:
"""The validator to check if the amount is less than the offset total."""
def __call__(self, form: FlaskForm, field: DecimalField) -> None:
assert isinstance(form, LineItemForm)
if field.data is None or form.id.data is None:
return
is_debit: bool = isinstance(form, DebitLineItemForm)
select_offset_total: sa.Select = sa.select(sa.func.sum(sa.case(
(JournalEntryLineItem.is_debit != is_debit,
JournalEntryLineItem.amount),
else_=-JournalEntryLineItem.amount)))\
.filter(JournalEntryLineItem.original_line_item_id == form.id.data)
offset_total: Decimal | None = db.session.scalar(select_offset_total)
if offset_total is not None and field.data < offset_total:
raise ValidationError(lazy_gettext(
"The amount must not be less than the offset total %(total)s.",
total=format_amount(offset_total)))
class LineItemForm(FlaskForm):
"""The base form to create or edit a line item."""
id = IntegerField()
"""The existing line item ID."""
no = IntegerField()
"""The order in the currency."""
original_line_item_id = IntegerField()
"""The Id of the original line item."""
account_code = StringField()
"""The account code."""
description = StringField()
"""The description."""
amount = DecimalField()
"""The amount."""
def __init__(self, *args, **kwargs):
"""Constructs a base line item form.
:param args: The arguments.
:param kwargs: The keyword arguments.
"""
super().__init__(*args, **kwargs)
from .journal_entry import JournalEntryForm
self.journal_entry_form: JournalEntryForm | None = None
"""The source journal entry form."""
@property
def account_title(self) -> str:
"""Returns the title of the account.
:return: The title of the account.
"""
if self.account_code.data is None:
return ""
account: Account | None = Account.find_by_code(self.account_code.data)
if account is None:
return ""
return account.title
@property
def account_text(self) -> str:
"""Returns the text representation of the account.
:return: The text representation of the account.
"""
if self.account_code.data is None:
return ""
account: Account | None = Account.find_by_code(self.account_code.data)
if account is None:
return ""
return str(account)
@property
def __original_line_item(self) -> JournalEntryLineItem | None:
"""Returns the original line item.
:return: The original line item.
"""
if not hasattr(self, "____original_line_item"):
def get_line_item() -> JournalEntryLineItem | None:
if self.original_line_item_id.data is None:
return None
return db.session.get(JournalEntryLineItem,
self.original_line_item_id.data)
setattr(self, "____original_line_item", get_line_item())
return getattr(self, "____original_line_item")
@property
def original_line_item_date(self) -> dt.date | None:
"""Returns the text representation of the original line item.
:return: The text representation of the original line item.
"""
return None if self.__original_line_item is None \
else self.__original_line_item.journal_entry.date
@property
def original_line_item_text(self) -> str | None:
"""Returns the text representation of the original line item.
:return: The text representation of the original line item.
"""
return None if self.__original_line_item is None \
else str(self.__original_line_item)
@property
def is_need_offset(self) -> bool:
"""Returns whether the line item needs offset.
:return: True if the line item needs offset, or False otherwise.
"""
if self.account_code.data is None:
return False
if self.account_code.data[0] == "1":
if isinstance(self, CreditLineItemForm):
return False
elif self.account_code.data[0] == "2":
if isinstance(self, DebitLineItemForm):
return False
else:
return False
account: Account | None = Account.find_by_code(self.account_code.data)
return account is not None and account.is_need_offset
@property
def offsets(self) -> list[JournalEntryLineItem]:
"""Returns the offsets.
:return: The offsets.
"""
if not hasattr(self, "__offsets"):
def get_offsets() -> list[JournalEntryLineItem]:
if not self.is_need_offset or self.id.data is None:
return []
return JournalEntryLineItem.query.join(JournalEntry)\
.filter(JournalEntryLineItem.original_line_item_id
== self.id.data)\
.order_by(JournalEntry.date, JournalEntry.no,
JournalEntryLineItem.no)\
.options(selectinload(JournalEntryLineItem.journal_entry),
selectinload(JournalEntryLineItem.account)).all()
setattr(self, "__offsets", get_offsets())
return getattr(self, "__offsets")
@property
def offset_total(self) -> Decimal | None:
"""Returns the total amount of the offsets.
:return: The total amount of the offsets.
"""
if not hasattr(self, "__offset_total"):
def get_offset_total():
if not self.is_need_offset or self.id.data is None:
return None
is_debit: bool = isinstance(self, DebitLineItemForm)
return sum([x.amount if x.is_debit != is_debit else -x.amount
for x in self.offsets])
setattr(self, "__offset_total", get_offset_total())
return getattr(self, "__offset_total")
@property
def net_balance(self) -> Decimal | None:
"""Returns the net balance.
:return: The net balance.
"""
if not self.is_need_offset or self.id.data is None \
or self.amount.data is None:
return None
return self.amount.data - self.offset_total
@property
def all_errors(self) -> list[str | LazyString]:
"""Returns all the errors of the form.
:return: All the errors of the form.
"""
all_errors: list[str | LazyString] = []
for key in self.errors:
if key != "csrf_token":
all_errors.extend(self.errors[key])
return all_errors
class DebitLineItemForm(LineItemForm):
"""The form to create or edit a debit line item."""
id = IntegerField()
"""The existing line item ID."""
no = IntegerField()
"""The order in the currency."""
original_line_item_id = IntegerField(
validators=[Optional(),
OriginalLineItemExists(),
OriginalLineItemOppositeDebitCredit(),
OriginalLineItemNeedOffset(),
OriginalLineItemNotOffset()])
"""The ID of the original line item."""
account_code = StringField(
filters=[strip_text],
validators=[
ACCOUNT_REQUIRED,
AccountExists(),
IsDebitAccount(lazy_gettext(
"This account is not for debit line items.")),
SameAccountAsOriginalLineItem(),
KeepAccountWhenHavingOffset(),
NotStartPayableFromDebit()])
"""The account code."""
description = StringField(filters=[strip_text])
"""The description."""
amount = DecimalField(
validators=[PositiveAmount(),
NotExceedingOriginalLineItemNetBalance(),
NotLessThanOffsetTotal()])
"""The amount."""
def populate_obj(self, obj: JournalEntryLineItem) -> None:
"""Populates the form data into a line item object.
:param obj: The line item object.
:return: None.
"""
is_new: bool = obj.id is None
if is_new:
obj.id = new_id(JournalEntryLineItem)
obj.original_line_item_id = self.original_line_item_id.data
obj.account_id = Account.find_by_code(self.account_code.data).id
obj.description = self.description.data
obj.is_debit = True
obj.amount = self.amount.data
if is_new:
current_user_pk: int = get_current_user_pk()
obj.created_by_id = current_user_pk
obj.updated_by_id = current_user_pk
class CreditLineItemForm(LineItemForm):
"""The form to create or edit a credit line item."""
id = IntegerField()
"""The existing line item ID."""
no = IntegerField()
"""The order in the currency."""
original_line_item_id = IntegerField(
validators=[Optional(),
OriginalLineItemExists(),
OriginalLineItemOppositeDebitCredit(),
OriginalLineItemNeedOffset(),
OriginalLineItemNotOffset()])
"""The ID of the original line item."""
account_code = StringField(
filters=[strip_text],
validators=[
ACCOUNT_REQUIRED,
AccountExists(),
IsCreditAccount(lazy_gettext(
"This account is not for credit line items.")),
SameAccountAsOriginalLineItem(),
KeepAccountWhenHavingOffset(),
NotStartReceivableFromCredit()])
"""The account code."""
description = StringField(filters=[strip_text])
"""The description."""
amount = DecimalField(
validators=[PositiveAmount(),
NotExceedingOriginalLineItemNetBalance(),
NotLessThanOffsetTotal()])
"""The amount."""
def populate_obj(self, obj: JournalEntryLineItem) -> None:
"""Populates the form data into a line item object.
:param obj: The line item object.
:return: None.
"""
is_new: bool = obj.id is None
if is_new:
obj.id = new_id(JournalEntryLineItem)
obj.original_line_item_id = self.original_line_item_id.data
obj.account_id = Account.find_by_code(self.account_code.data).id
obj.description = self.description.data
obj.is_debit = False
obj.amount = self.amount.data
if is_new:
current_user_pk: int = get_current_user_pk()
obj.created_by_id = current_user_pk
obj.updated_by_id = current_user_pk

View File

@ -1,95 +0,0 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/10
# Copyright (c) 2023 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""The reorder forms for the journal entry management.
"""
import datetime as dt
import sqlalchemy as sa
from flask import request
from accounting import db
from accounting.models import JournalEntry
def sort_journal_entries_in(date: dt.date, exclude: int | None = None) -> None:
"""Sorts the journal entries under a date after changing the date or
deleting a journal entry.
:param date: The date of the journal entry.
:param exclude: The journal entry ID to exclude.
:return: None.
"""
conditions: list[sa.BinaryExpression] = [JournalEntry.date == date]
if exclude is not None:
conditions.append(JournalEntry.id != exclude)
journal_entries: list[JournalEntry] = JournalEntry.query\
.filter(*conditions)\
.order_by(JournalEntry.no).all()
for i in range(len(journal_entries)):
if journal_entries[i].no != i + 1:
journal_entries[i].no = i + 1
class JournalEntryReorderForm:
"""The form to reorder the journal entries."""
def __init__(self, date: dt.date):
"""Constructs the form to reorder the journal entries in a day.
:param date: The date.
"""
self.date: dt.date = date
"""The date."""
self.is_modified: bool = False
"""Whether the order is modified."""
def save_order(self) -> None:
"""Saves the order of the account.
:return:
"""
journal_entries: list[JournalEntry] = JournalEntry.query\
.filter(JournalEntry.date == self.date).all()
# Collects the specified order.
orders: dict[JournalEntry, int] = {}
for journal_entry in journal_entries:
if f"{journal_entry.id}-no" in request.form:
try:
orders[journal_entry] \
= int(request.form[f"{journal_entry.id}-no"])
except ValueError:
pass
# Missing and invalid orders are appended to the end.
missing: list[JournalEntry] \
= [x for x in journal_entries if x not in orders]
if len(missing) > 0:
next_no: int = 1 if len(orders) == 0 else max(orders.values()) + 1
for journal_entry in missing:
orders[journal_entry] = next_no
# Sort by the specified order first, and their original order.
journal_entries.sort(key=lambda x: (orders[x], x.no))
# Update the orders.
with db.session.no_autoflush:
for i in range(len(journal_entries)):
if journal_entries[i].no != i + 1:
journal_entries[i].no = i + 1
self.is_modified = True

View File

@ -1,19 +0,0 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/10
# Copyright (c) 2023 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""The utilities for the journal entry management.
"""

View File

@ -1,51 +0,0 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/10
# Copyright (c) 2023 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""The account option for the journal entry management.
"""
from accounting.models import Account
class AccountOption:
"""An account option."""
def __init__(self, account: Account):
"""Constructs an account option.
:param account: The account.
"""
self.id: str = account.id
"""The account ID."""
self.code: str = account.code
"""The account code."""
self.title: str = account.title
"""The account title."""
self.query_values: list[str] = account.query_values
"""The values to be queried."""
self.__str: str = str(account)
"""The string representation of the account option."""
self.is_in_use: bool = False
"""True if this account is in use, or False otherwise."""
self.is_need_offset: bool = account.is_need_offset
"""True if this account needs offset, or False otherwise."""
def __str__(self) -> str:
"""Returns the string representation of the account option.
:return: The string representation of the account option.
"""
return self.__str

View File

@ -1,357 +0,0 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/27
# Copyright (c) 2023 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""The description editor.
"""
import re
from typing import Literal
import sqlalchemy as sa
from accounting import db
from accounting.models import Account, JournalEntryLineItem
from accounting.utils.options import options, Recurring
class DescriptionAccount:
"""An account for a description tag."""
def __init__(self, account: Account, freq: int):
"""Constructs an account for a description tag.
:param account: The account.
:param freq: The frequency of the tag with the account.
"""
self.__account: Account = account
"""The account."""
self.id: int = account.id
"""The account ID."""
self.code: str = account.code
"""The account code."""
self.is_need_offset: bool = account.is_need_offset
"""Whether the journal entry line items of this account need offset."""
self.freq: int = freq
"""The frequency of the tag with the account."""
def __str__(self) -> str:
"""Returns the string representation of the account.
:return: The string representation of the account.
"""
return str(self.__account)
@property
def title(self) -> str:
"""Returns the account title.
:return: The account title.
"""
return self.__account.title
def add_freq(self, freq: int) -> None:
"""Adds the frequency of an account.
:param freq: The frequency of the tag name with the account.
:return: None.
"""
self.freq = self.freq + freq
class DescriptionTag:
"""A description tag."""
def __init__(self, name: str):
"""Constructs a description tag.
:param name: The tag name.
"""
self.name: str = name
"""The tag name."""
self.__account_dict: dict[int, DescriptionAccount] = {}
"""The accounts that come with the tag, in the order of their
frequency."""
self.freq: int = 0
"""The frequency of the tag."""
def __str__(self) -> str:
"""Returns the string representation of the tag.
:return: The string representation of the tag.
"""
return self.name
def add_account(self, account: Account, freq: int):
"""Adds an account.
:param account: The associated account.
:param freq: The frequency of the tag name with the account.
:return: None.
"""
self.__account_dict[account.id] = DescriptionAccount(account, freq)
self.freq = self.freq + freq
@property
def accounts(self) -> list[DescriptionAccount]:
"""Returns the accounts by the order of their frequencies.
:return: The accounts by the order of their frequencies.
"""
return sorted(self.__account_dict.values(), key=lambda x: -x.freq)
@property
def account_codes(self) -> list[str]:
"""Returns the account codes by the order of their frequencies.
:return: The account codes by the order of their frequencies.
"""
return [x.code for x in self.accounts]
class DescriptionType:
"""A description type"""
def __init__(self, type_id: Literal["general", "travel", "bus"]):
"""Constructs a description type.
:param type_id: The type ID, either "general", "travel", or "bus".
"""
self.id: Literal["general", "travel", "bus"] = type_id
"""The type ID."""
self.__tag_dict: dict[str, DescriptionTag] = {}
"""A dictionary from the tag name to their corresponding tag."""
def add_tag(self, name: str, account: Account, freq: int) -> None:
"""Adds a tag.
:param name: The tag name.
:param account: The associated account.
:param freq: The frequency of the tag name with the account.
:return: None.
"""
if name not in self.__tag_dict:
self.__tag_dict[name] = DescriptionTag(name)
self.__tag_dict[name].add_account(account, freq)
@property
def tags(self) -> list[DescriptionTag]:
"""Returns the tags by the order of their frequencies.
:return: The tags by the order of their frequencies.
"""
return sorted(self.__tag_dict.values(), key=lambda x: -x.freq)
class DescriptionRecurring:
"""A recurring transaction."""
def __init__(self, name: str, account: Account, description_template: str):
"""Constructs a recurring transaction.
:param name: The name.
:param description_template: The description template.
:param account: The account.
"""
self.name: str = name
"""The name."""
self.account: DescriptionAccount = DescriptionAccount(account, 0)
"""The account."""
self.description_template: str = description_template
"""The description template."""
@property
def account_codes(self) -> list[str]:
"""Returns the account codes by the order of their frequencies.
:return: The account codes by the order of their frequencies.
"""
return [self.account.code]
class DescriptionDebitCredit:
"""The description on debit or credit."""
def __init__(self, debit_credit: Literal["debit", "credit"]):
"""Constructs the description on debit or credit.
:param debit_credit: Either "debit" or "credit".
"""
self.debit_credit: Literal["debit", "credit"] = debit_credit
"""Either debit or credit."""
self.general: DescriptionType = DescriptionType("general")
"""The general tags."""
self.travel: DescriptionType = DescriptionType("travel")
"""The travel tags."""
self.bus: DescriptionType = DescriptionType("bus")
"""The bus tags."""
self.__type_dict: dict[Literal["general", "travel", "bus"],
DescriptionType] \
= {x.id: x for x in {self.general, self.travel, self.bus}}
"""A dictionary from the type ID to the corresponding tags."""
self.recurring: list[DescriptionRecurring] = []
"""The recurring transactions."""
def add_tag(self, tag_type: Literal["general", "travel", "bus"],
name: str, account: Account, freq: int) -> None:
"""Adds a tag.
:param tag_type: The tag type, either "general", "travel", or "bus".
:param name: The name.
:param account: The associated account.
:param freq: The frequency of the tag name with the account.
:return: None.
"""
self.__type_dict[tag_type].add_tag(name, account, freq)
@property
def accounts(self) -> list[DescriptionAccount]:
"""Returns the suggested accounts of all tags in the description editor
in debit or credit, in their frequency order.
:return: The suggested accounts of all tags, in their frequency order.
"""
accounts: dict[int, DescriptionAccount] = {}
freq: dict[int, int] = {}
for tag_type in self.__type_dict.values():
for tag in tag_type.tags:
for account in tag.accounts:
accounts[account.id] = account
if account.id not in freq:
freq[account.id] = 0
freq[account.id] \
= freq[account.id] + account.freq
for recurring in self.recurring:
accounts[recurring.account.id] = recurring.account
if recurring.account.id not in freq:
freq[recurring.account.id] = 0
return [accounts[y] for y in sorted(freq.keys(),
key=lambda x: -freq[x])]
class DescriptionEditor:
"""The description editor."""
def __init__(self):
"""Constructs the description editor."""
self.debit: DescriptionDebitCredit = DescriptionDebitCredit("debit")
"""The debit tags."""
self.credit: DescriptionDebitCredit = DescriptionDebitCredit("credit")
"""The credit tags."""
self.__init_tags()
self.__init_recurring()
def __init_tags(self):
"""Initializes the tags.
:return: None.
"""
debit_credit: sa.Label = sa.case(
(JournalEntryLineItem.is_debit, "debit"),
else_="credit").label("debit_credit")
tag_type: sa.Label = sa.case(
(JournalEntryLineItem.description.like("_%—_%—_%→_%"), "bus"),
(sa.or_(JournalEntryLineItem.description.like("_%—_%→_%"),
JournalEntryLineItem.description.like("_%—_%↔_%")),
"travel"),
else_="general").label("tag_type")
tag: sa.Label = get_prefix(JournalEntryLineItem.description, "")\
.label("tag")
select: sa.Select = sa.Select(debit_credit, tag_type, tag,
JournalEntryLineItem.account_id,
sa.func.count().label("freq"))\
.filter(JournalEntryLineItem.description.is_not(None),
JournalEntryLineItem.description.like("_%—_%"),
JournalEntryLineItem.original_line_item_id.is_(None))\
.group_by(debit_credit, tag_type, tag,
JournalEntryLineItem.account_id)
result: list[sa.Row] = db.session.execute(select).all()
accounts: dict[int, Account] \
= {x.id: x for x in Account.query
.filter(Account.id.in_({x.account_id for x in result})).all()}
debit_credit_dict: dict[Literal["debit", "credit"],
DescriptionDebitCredit] \
= {x.debit_credit: x for x in {self.debit, self.credit}}
for row in result:
debit_credit_dict[row.debit_credit].add_tag(
row.tag_type, row.tag, accounts[row.account_id], row.freq)
def __init_recurring(self) -> None:
"""Initializes the recurring transactions.
:return: None.
"""
recurring: Recurring = options.recurring
accounts: dict[str, Account] \
= self.__get_accounts(recurring.codes)
self.debit.recurring \
= [DescriptionRecurring(x.name, accounts[x.account_code],
x.description_template)
for x in recurring.expenses]
self.credit.recurring \
= [DescriptionRecurring(x.name, accounts[x.account_code],
x.description_template)
for x in recurring.incomes]
@staticmethod
def __get_accounts(codes: set[str]) -> dict[str, Account]:
"""Finds and returns the accounts by codes.
:param codes: The account codes.
:return: The account.
"""
if len(codes) == 0:
return {}
def get_condition(code0: str) -> sa.BinaryExpression:
m: re.Match = re.match(r"^(\d{4})-(\d{3})$", code0)
assert m is not None,\
f"Malformed account code \"{code0}\" for regular transactions."
return sa.and_(Account.base_code == m.group(1),
Account.no == int(m.group(2)))
conditions: list[sa.BinaryExpression] \
= [get_condition(x) for x in codes]
accounts: dict[str, Account] \
= {x.code: x for x in
Account.query.filter(sa.or_(*conditions)).all()}
for code in codes:
assert code in accounts,\
f"Unknown account \"{code}\" for regular transactions."
return accounts
def get_prefix(string: str | sa.Column, separator: str | sa.Column) \
-> sa.Function:
"""Returns the SQL function to find the prefix of a string.
:param string: The string.
:param separator: The separator.
:return: The position of the substring, starting from 1.
"""
return sa.func.substr(string, 0, get_position(string, separator))
def get_position(string: str | sa.Column, substring: str | sa.Column) \
-> sa.Function:
"""Returns the SQL function to find the position of a substring.
:param string: The string.
:param substring: The substring.
:return: The position of the substring, starting from 1.
"""
if db.engine.name == "postgresql":
return sa.func.strpos(string, substring)
return sa.func.instr(string, substring)

View File

@ -1,336 +0,0 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/19
# Copyright (c) 2023 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""The operators for different journal entry types.
"""
from abc import ABC, abstractmethod
from typing import Type
from flask import render_template, request, abort
from flask_wtf import FlaskForm
from accounting.journal_entry.forms import JournalEntryForm, \
CashReceiptJournalEntryForm, CashDisbursementJournalEntryForm, \
TransferJournalEntryForm
from accounting.journal_entry.forms.line_item import LineItemForm
from accounting.models import JournalEntry
from accounting.template_globals import default_currency_code
from accounting.utils.journal_entry_types import JournalEntryType
class JournalEntryOperator(ABC):
"""The base journal entry operator."""
CHECK_ORDER: int = -1
"""The order when checking the journal entry operator."""
@property
@abstractmethod
def form(self) -> Type[JournalEntryForm]:
"""Returns the form class.
:return: The form class.
"""
@abstractmethod
def render_create_template(self, form: FlaskForm) -> str:
"""Renders the template for the form to create a journal entry.
:param form: The journal entry form.
:return: the form to create a journal entry.
"""
@abstractmethod
def render_detail_template(self, journal_entry: JournalEntry) -> str:
"""Renders the template for the detail page.
:param journal_entry: The journal entry.
:return: the detail page.
"""
@abstractmethod
def render_edit_template(self, journal_entry: JournalEntry,
form: FlaskForm) -> str:
"""Renders the template for the form to edit a journal entry.
:param journal_entry: The journal entry.
:param form: The form.
:return: the form to edit a journal entry.
"""
@abstractmethod
def is_my_type(self, journal_entry: JournalEntry) -> bool:
"""Checks and returns whether the journal entry belongs to the type.
:param journal_entry: The journal entry.
:return: True if the journal entry belongs to the type, or False
otherwise.
"""
@property
def _line_item_template(self) -> str:
"""Renders and returns the template for the line item sub-form.
:return: The template for the line item sub-form.
"""
return render_template(
"accounting/journal-entry/include/form-line-item.html",
currency_index="CURRENCY_INDEX",
debit_credit="DEBIT_CREDIT",
line_item_index="LINE_ITEM_INDEX",
form=LineItemForm())
class CashReceiptJournalEntry(JournalEntryOperator):
"""A cash receipt journal entry."""
CHECK_ORDER: int = 2
"""The order when checking the journal entry operator."""
@property
def form(self) -> Type[JournalEntryForm]:
"""Returns the form class.
:return: The form class.
"""
return CashReceiptJournalEntryForm
def render_create_template(self, form: CashReceiptJournalEntryForm) -> str:
"""Renders the template for the form to create a journal entry.
:param form: The journal entry form.
:return: the form to create a journal entry.
"""
return render_template(
"accounting/journal-entry/receipt/create.html",
form=form,
journal_entry_type=JournalEntryType.CASH_RECEIPT,
currency_template=self.__currency_template,
line_item_template=self._line_item_template)
def render_detail_template(self, journal_entry: JournalEntry) -> str:
"""Renders the template for the detail page.
:param journal_entry: The journal entry.
:return: the detail page.
"""
return render_template("accounting/journal-entry/receipt/detail.html",
obj=journal_entry)
def render_edit_template(self, journal_entry: JournalEntry,
form: CashReceiptJournalEntryForm) -> str:
"""Renders the template for the form to edit a journal entry.
:param journal_entry: The journal entry.
:param form: The form.
:return: the form to edit a journal entry.
"""
return render_template("accounting/journal-entry/receipt/edit.html",
journal_entry=journal_entry, form=form,
currency_template=self.__currency_template,
line_item_template=self._line_item_template)
def is_my_type(self, journal_entry: JournalEntry) -> bool:
"""Checks and returns whether the journal entry belongs to the type.
:param journal_entry: The journal entry.
:return: True if the journal entry belongs to the type, or False
otherwise.
"""
return journal_entry.is_cash_receipt
@property
def __currency_template(self) -> str:
"""Renders and returns the template for the currency sub-form.
:return: The template for the currency sub-form.
"""
return render_template(
"accounting/journal-entry/receipt/include/form-currency.html",
currency_index="CURRENCY_INDEX",
currency_code_data=default_currency_code(),
credit_total="-")
class CashDisbursementJournalEntry(JournalEntryOperator):
"""A cash disbursement journal entry."""
CHECK_ORDER: int = 1
"""The order when checking the journal entry operator."""
@property
def form(self) -> Type[JournalEntryForm]:
"""Returns the form class.
:return: The form class.
"""
return CashDisbursementJournalEntryForm
def render_create_template(self, form: CashDisbursementJournalEntryForm) \
-> str:
"""Renders the template for the form to create a journal entry.
:param form: The journal entry form.
:return: the form to create a journal entry.
"""
return render_template(
"accounting/journal-entry/disbursement/create.html",
form=form,
journal_entry_type=JournalEntryType.CASH_DISBURSEMENT,
currency_template=self.__currency_template,
line_item_template=self._line_item_template)
def render_detail_template(self, journal_entry: JournalEntry) -> str:
"""Renders the template for the detail page.
:param journal_entry: The journal entry.
:return: the detail page.
"""
return render_template(
"accounting/journal-entry/disbursement/detail.html",
obj=journal_entry)
def render_edit_template(self, journal_entry: JournalEntry,
form: CashDisbursementJournalEntryForm) -> str:
"""Renders the template for the form to edit a journal entry.
:param journal_entry: The journal entry.
:param form: The form.
:return: the form to edit a journal entry.
"""
return render_template(
"accounting/journal-entry/disbursement/edit.html",
journal_entry=journal_entry, form=form,
currency_template=self.__currency_template,
line_item_template=self._line_item_template)
def is_my_type(self, journal_entry: JournalEntry) -> bool:
"""Checks and returns whether the journal entry belongs to the type.
:param journal_entry: The journal entry.
:return: True if the journal entry belongs to the type, or False
otherwise.
"""
return journal_entry.is_cash_disbursement
@property
def __currency_template(self) -> str:
"""Renders and returns the template for the currency sub-form.
:return: The template for the currency sub-form.
"""
return render_template(
"accounting/journal-entry/disbursement/include/form-currency.html",
currency_index="CURRENCY_INDEX",
currency_code_data=default_currency_code(),
debit_total="-")
class TransferJournalEntry(JournalEntryOperator):
"""A transfer journal entry."""
CHECK_ORDER: int = 3
"""The order when checking the journal entry operator."""
@property
def form(self) -> Type[JournalEntryForm]:
"""Returns the form class.
:return: The form class.
"""
return TransferJournalEntryForm
def render_create_template(self, form: TransferJournalEntryForm) -> str:
"""Renders the template for the form to create a journal entry.
:param form: The journal entry form.
:return: the form to create a journal entry.
"""
return render_template(
"accounting/journal-entry/transfer/create.html",
form=form,
journal_entry_type=JournalEntryType.TRANSFER,
currency_template=self.__currency_template,
line_item_template=self._line_item_template)
def render_detail_template(self, journal_entry: JournalEntry) -> str:
"""Renders the template for the detail page.
:param journal_entry: The journal entry.
:return: the detail page.
"""
return render_template("accounting/journal-entry/transfer/detail.html",
obj=journal_entry)
def render_edit_template(self, journal_entry: JournalEntry,
form: TransferJournalEntryForm) -> str:
"""Renders the template for the form to edit a journal entry.
:param journal_entry: The journal entry.
:param form: The form.
:return: the form to edit a journal entry.
"""
return render_template("accounting/journal-entry/transfer/edit.html",
journal_entry=journal_entry, form=form,
currency_template=self.__currency_template,
line_item_template=self._line_item_template)
def is_my_type(self, journal_entry: JournalEntry) -> bool:
"""Checks and returns whether the journal entry belongs to the type.
:param journal_entry: The journal entry.
:return: True if the journal entry belongs to the type, or False
otherwise.
"""
return True
@property
def __currency_template(self) -> str:
"""Renders and returns the template for the currency sub-form.
:return: The template for the currency sub-form.
"""
return render_template(
"accounting/journal-entry/transfer/include/form-currency.html",
currency_index="CURRENCY_INDEX",
currency_code_data=default_currency_code(),
debit_total="-", credit_total="-")
JOURNAL_ENTRY_TYPE_TO_OP: dict[JournalEntryType, JournalEntryOperator] \
= {JournalEntryType.CASH_RECEIPT: CashReceiptJournalEntry(),
JournalEntryType.CASH_DISBURSEMENT: CashDisbursementJournalEntry(),
JournalEntryType.TRANSFER: TransferJournalEntry()}
"""The map from the journal entry types to their operators."""
def get_journal_entry_op(journal_entry: JournalEntry,
is_check_as: bool = False) -> JournalEntryOperator:
"""Returns the journal entry operator that may be specified in the "as"
query parameter. If it is not specified, check the journal entry type from
the journal entry.
:param journal_entry: The journal entry.
:param is_check_as: True to check the "as" parameter, or False otherwise.
:return: None.
"""
if is_check_as and "as" in request.args:
type_dict: dict[str, JournalEntryType] \
= {x.value: x for x in JournalEntryType}
if request.args["as"] not in type_dict:
abort(404)
return JOURNAL_ENTRY_TYPE_TO_OP[type_dict[request.args["as"]]]
for journal_entry_type in sorted(JOURNAL_ENTRY_TYPE_TO_OP.values(),
key=lambda x: x.CHECK_ORDER):
if journal_entry_type.is_my_type(journal_entry):
return journal_entry_type

View File

@ -1,83 +0,0 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/10
# Copyright (c) 2023 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""The selectable original line items.
"""
from decimal import Decimal
import sqlalchemy as sa
from sqlalchemy.orm import selectinload
from accounting import db
from accounting.models import Account, JournalEntry, JournalEntryLineItem
from accounting.utils.offset_alias import offset_alias
def get_selectable_original_line_items(
line_item_id_on_form: set[int], is_payable: bool,
is_receivable: bool) -> list[JournalEntryLineItem]:
"""Queries and returns the selectable original line items, with their net
balances. The offset amounts of the form is excluded.
:param line_item_id_on_form: The ID of the line items on the form.
:param is_payable: True to check the payable original line items, or False
otherwise.
:param is_receivable: True to check the receivable original line items, or
False otherwise.
:return: The selectable original line items, with their net balances.
"""
assert is_payable or is_receivable
offset: sa.Alias = offset_alias()
net_balance: sa.Label = (JournalEntryLineItem.amount + sa.func.sum(sa.case(
(offset.c.id.in_(line_item_id_on_form), 0),
(offset.c.is_debit == JournalEntryLineItem.is_debit, offset.c.amount),
else_=-offset.c.amount))).label("net_balance")
conditions: list[sa.BinaryExpression] = [Account.is_need_offset]
sub_conditions: list[sa.BinaryExpression] = []
if is_payable:
sub_conditions.append(sa.and_(Account.base_code.startswith("2"),
sa.not_(JournalEntryLineItem.is_debit)))
if is_receivable:
sub_conditions.append(sa.and_(Account.base_code.startswith("1"),
JournalEntryLineItem.is_debit))
conditions.append(sa.or_(*sub_conditions))
select_net_balances: sa.Select \
= sa.select(JournalEntryLineItem.id, net_balance)\
.join(Account)\
.join(offset,
JournalEntryLineItem.id == offset.c.original_line_item_id,
isouter=True)\
.filter(*conditions)\
.group_by(JournalEntryLineItem.id)\
.having(sa.or_(sa.func.count(offset.c.id) == 0, net_balance != 0))
net_balances: dict[int, Decimal] \
= {x.id: x.net_balance
for x in db.session.execute(select_net_balances).all()}
line_items: list[JournalEntryLineItem] = JournalEntryLineItem.query\
.filter(JournalEntryLineItem.id.in_({x for x in net_balances}))\
.join(JournalEntry)\
.order_by(JournalEntry.date, JournalEntry.no,
JournalEntryLineItem.is_debit, JournalEntryLineItem.no)\
.options(selectinload(JournalEntryLineItem.currency),
selectinload(JournalEntryLineItem.account),
selectinload(JournalEntryLineItem.journal_entry)).all()
line_items.reverse()
for line_item in line_items:
line_item.net_balance = line_item.amount \
if net_balances[line_item.id] is None \
else net_balances[line_item.id]
return line_items

View File

@ -1,239 +0,0 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/18
# Copyright (c) 2023-2024 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""The views for the journal entry management.
"""
import datetime as dt
from urllib.parse import parse_qsl, urlencode
import sqlalchemy as sa
from flask import Blueprint, render_template, session, redirect, request, \
flash, url_for
from werkzeug.datastructures import ImmutableMultiDict
from accounting import db
from accounting.locale import lazy_gettext
from accounting.models import JournalEntry
from accounting.utils.cast import s
from accounting.utils.flash_errors import flash_form_errors
from accounting.utils.journal_entry_types import JournalEntryType
from accounting.utils.next_uri import inherit_next, or_next
from accounting.utils.permission import has_permission, can_view, can_edit
from accounting.utils.timezone import get_tz_today
from accounting.utils.user import get_current_user_pk
from .forms import sort_journal_entries_in, JournalEntryReorderForm
from .template_filters import with_type, to_transfer, format_amount_input, \
text2html
from .utils.operators import JournalEntryOperator, JOURNAL_ENTRY_TYPE_TO_OP, \
get_journal_entry_op
bp: Blueprint = Blueprint("journal-entry", __name__)
"""The view blueprint for the journal entry management."""
bp.add_app_template_filter(with_type, "accounting_journal_entry_with_type")
bp.add_app_template_filter(to_transfer, "accounting_journal_entry_to_transfer")
bp.add_app_template_filter(format_amount_input,
"accounting_journal_entry_format_amount_input")
bp.add_app_template_filter(text2html, "accounting_journal_entry_text2html")
@bp.get("create/<journalEntryType:journal_entry_type>", endpoint="create")
@has_permission(can_edit)
def show_add_journal_entry_form(journal_entry_type: JournalEntryType) -> str:
"""Shows the form to add a journal entry.
:param journal_entry_type: The journal entry type.
:return: The form to add a journal entry.
"""
journal_entry_op: JournalEntryOperator \
= JOURNAL_ENTRY_TYPE_TO_OP[journal_entry_type]
form: journal_entry_op.form
if "form" in session:
form = journal_entry_op.form(
ImmutableMultiDict(parse_qsl(session["form"])))
del session["form"]
form.validate()
else:
form = journal_entry_op.form()
form.date.data = get_tz_today()
return journal_entry_op.render_create_template(form)
@bp.post("store/<journalEntryType:journal_entry_type>", endpoint="store")
@has_permission(can_edit)
def add_journal_entry(journal_entry_type: JournalEntryType) -> redirect:
"""Adds a journal entry.
:param journal_entry_type: The journal entry type.
:return: The redirection to the journal entry detail on success, or the
journal entry creation form on error.
"""
journal_entry_op: JournalEntryOperator \
= JOURNAL_ENTRY_TYPE_TO_OP[journal_entry_type]
form: journal_entry_op.form = journal_entry_op.form(request.form)
if not form.validate():
flash_form_errors(form)
session["form"] = urlencode(list(request.form.items()))
return redirect(inherit_next(with_type(
url_for("accounting.journal-entry.create",
journal_entry_type=journal_entry_type))))
journal_entry: JournalEntry = JournalEntry()
form.populate_obj(journal_entry)
db.session.add(journal_entry)
db.session.commit()
flash(s(lazy_gettext("The journal entry is added successfully.")),
"success")
return redirect(inherit_next(__get_detail_uri(journal_entry)))
@bp.get("<journalEntry:journal_entry>", endpoint="detail")
@has_permission(can_view)
def show_journal_entry_detail(journal_entry: JournalEntry) -> str:
"""Shows the journal entry detail.
:param journal_entry: The journal entry.
:return: The detail.
"""
journal_entry_op: JournalEntryOperator \
= get_journal_entry_op(journal_entry)
return journal_entry_op.render_detail_template(journal_entry)
@bp.get("<journalEntry:journal_entry>/edit", endpoint="edit")
@has_permission(can_edit)
def show_journal_entry_edit_form(journal_entry: JournalEntry) -> str:
"""Shows the form to edit a journal entry.
:param journal_entry: The journal entry.
:return: The form to edit the journal entry.
"""
journal_entry_op: JournalEntryOperator \
= get_journal_entry_op(journal_entry, is_check_as=True)
form: journal_entry_op.form
if "form" in session:
form = journal_entry_op.form(
ImmutableMultiDict(parse_qsl(session["form"])))
del session["form"]
form.obj = journal_entry
form.validate()
else:
form = journal_entry_op.form(obj=journal_entry)
return journal_entry_op.render_edit_template(journal_entry, form)
@bp.post("<journalEntry:journal_entry>/update", endpoint="update")
@has_permission(can_edit)
def update_journal_entry(journal_entry: JournalEntry) -> redirect:
"""Updates a journal entry.
:param journal_entry: The journal entry.
:return: The redirection to the journal entry detail on success, or the
journal entry edit form on error.
"""
journal_entry_op: JournalEntryOperator \
= get_journal_entry_op(journal_entry, is_check_as=True)
form: journal_entry_op.form = journal_entry_op.form(request.form)
form.obj = journal_entry
if not form.validate():
flash_form_errors(form)
session["form"] = urlencode(list(request.form.items()))
return redirect(inherit_next(with_type(
url_for("accounting.journal-entry.edit",
journal_entry=journal_entry))))
with db.session.no_autoflush:
form.populate_obj(journal_entry)
if not form.is_modified:
flash(s(lazy_gettext("The journal entry was not modified.")),
"success")
return redirect(inherit_next(__get_detail_uri(journal_entry)))
journal_entry.updated_by_id = get_current_user_pk()
journal_entry.updated_at = sa.func.now()
db.session.commit()
flash(s(lazy_gettext("The journal entry is updated successfully.")),
"success")
return redirect(inherit_next(__get_detail_uri(journal_entry)))
@bp.post("<journalEntry:journal_entry>/delete", endpoint="delete")
@has_permission(can_edit)
def delete_journal_entry(journal_entry: JournalEntry) -> redirect:
"""Deletes a journal entry.
:param journal_entry: The journal entry.
:return: The redirection to the journal entry list on success, or the
journal entry detail on error.
"""
if not journal_entry.can_delete:
flash(s(lazy_gettext("The journal entry cannot be deleted.")), "error")
return redirect(inherit_next(__get_detail_uri(journal_entry)))
journal_entry.delete()
sort_journal_entries_in(journal_entry.date, journal_entry.id)
db.session.commit()
flash(s(lazy_gettext("The journal entry is deleted successfully.")),
"success")
return redirect(or_next(__get_default_page_uri()))
@bp.get("dates/<date:date>", endpoint="order")
@has_permission(can_view)
def show_journal_entry_order(date: dt.date) -> str:
"""Shows the order of the journal entries in a same date.
:param date: The date.
:return: The order of the journal entries in the date.
"""
journal_entries: list[JournalEntry] = JournalEntry.query \
.filter(JournalEntry.date == date) \
.order_by(JournalEntry.no).all()
return render_template("accounting/journal-entry/order.html",
date=date, list=journal_entries)
@bp.post("dates/<date:date>", endpoint="sort")
@has_permission(can_edit)
def sort_journal_entries(date: dt.date) -> redirect:
"""Reorders the journal entries in a date.
:param date: The date.
:return: The redirection to the incoming account or the account list. The
reordering operation does not fail.
"""
form: JournalEntryReorderForm = JournalEntryReorderForm(date)
form.save_order()
if not form.is_modified:
flash(s(lazy_gettext("The order was not modified.")), "success")
return redirect(or_next(__get_default_page_uri()))
db.session.commit()
flash(s(lazy_gettext("The order is updated successfully.")), "success")
return redirect(or_next(__get_default_page_uri()))
def __get_detail_uri(journal_entry: JournalEntry) -> str:
"""Returns the detail URI of a journal entry.
:param journal_entry: The journal entry.
:return: The detail URI of the journal entry.
"""
return url_for("accounting.journal-entry.detail",
journal_entry=journal_entry)
def __get_default_page_uri() -> str:
"""Returns the URI for the default page.
:return: The URI for the default page.
"""
return url_for("accounting-report.default")

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Project.
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/25
# Copyright (c) 2023 imacat.
@ -25,10 +25,8 @@ from flask_babel import LazyString, Domain
from flask_babel_js import JAVASCRIPT, c2js
translation_dir: Path = Path(__file__).parent / "translations"
"""The directory of the translation files."""
domain: Domain = Domain(translation_directories=[translation_dir],
domain="accounting")
"""The message domain."""
def gettext(string, **variables) -> str:
@ -122,5 +120,6 @@ def init_app(app: Flask, bp: Blueprint) -> None:
:param bp: The blueprint of the accounting application.
:return: None.
"""
bp.add_url_rule("/_jstrans.js", "babel_catalog", __babel_js_catalog_view)
bp.add_url_rule("/_jstrans.js", "babel_catalog",
__babel_js_catalog_view)
app.jinja_env.globals["A_"] = domain.gettext

File diff suppressed because it is too large Load Diff

View File

@ -1,30 +0,0 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/22
# Copyright (c) 2023 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""The option management.
"""
from flask import Blueprint
def init_app(bp: Blueprint) -> None:
"""Initialize the application.
:param bp: The blueprint of the accounting application.
:return: None.
"""
from .views import bp as option_bp
bp.register_blueprint(option_bp, url_prefix="/options")

View File

@ -1,269 +0,0 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/22
# Copyright (c) 2023 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""The forms for the option management.
"""
from flask import render_template
from flask_babel import LazyString
from flask_wtf import FlaskForm
from wtforms import StringField, FieldList, FormField, IntegerField
from wtforms.validators import DataRequired, ValidationError
from accounting.forms import ACCOUNT_REQUIRED, CurrencyExists, AccountExists, \
IsDebitAccount, IsCreditAccount
from accounting.locale import lazy_gettext
from accounting.models import Account
from accounting.utils.current_account import CurrentAccount
from accounting.utils.options import Options
from accounting.utils.strip_text import strip_text
class CurrentAccountExists:
"""The validator to check that the current account exists."""
def __call__(self, form: FlaskForm, field: StringField) -> None:
if field.data is None or field.data == CurrentAccount.CURRENT_AL_CODE:
return
if Account.find_by_code(field.data) is None:
raise ValidationError(lazy_gettext(
"The account does not exist."))
class AccountNotCurrent:
"""The validator to check that the account is a current account."""
def __call__(self, form: FlaskForm, field: StringField) -> None:
if field.data is None or field.data == CurrentAccount.CURRENT_AL_CODE:
return
if field.data[:2] not in {"11", "12", "21", "22"}:
raise ValidationError(lazy_gettext(
"This is not a current account."))
class NotStartPayableFromExpense:
"""The validator to check that a payable line item does not start from
expense."""
def __call__(self, form: FlaskForm, field: StringField) -> None:
if field.data is None or field.data[0] != "2":
return
account: Account | None = Account.find_by_code(field.data)
if account is not None and account.is_need_offset:
raise ValidationError(lazy_gettext(
"You cannot select a payable account as expense."))
class NotStartReceivableFromIncome:
"""The validator to check that a receivable line item does not start
from income."""
def __call__(self, form: FlaskForm, field: StringField) -> None:
if field.data is None or field.data[0] != "1":
return
account: Account | None = Account.find_by_code(field.data)
if account is not None and account.is_need_offset:
raise ValidationError(lazy_gettext(
"You cannot select a receivable account as income."))
class RecurringItemForm(FlaskForm):
"""The base sub-form to add or update the recurring item."""
no = IntegerField()
"""The order number of this recurring item."""
name = StringField()
"""The name of the recurring item."""
account_code = StringField()
"""The account code."""
description_template = StringField()
"""The description template."""
@property
def account_text(self) -> str | None:
"""Returns the account text.
:return: The account text.
"""
if self.account_code.data is None:
return None
account: Account | None = Account.find_by_code(self.account_code.data)
return None if account is None else str(account)
@property
def all_errors(self) -> list[str | LazyString]:
"""Returns all the errors of the form.
:return: All the errors of the form.
"""
all_errors: list[str | LazyString] = []
for key in self.errors:
if key != "csrf_token":
all_errors.extend(self.errors[key])
return all_errors
class RecurringExpenseForm(RecurringItemForm):
"""The sub-form to add or update the recurring expenses."""
no = IntegerField()
"""The order number of this recurring item."""
name = StringField(
filters=[strip_text],
validators=[DataRequired(lazy_gettext("Please fill in the name."))])
"""The name of the recurring item."""
account_code = StringField(
filters=[strip_text],
validators=[
ACCOUNT_REQUIRED,
AccountExists(),
IsDebitAccount(lazy_gettext("This account is not for expense.")),
NotStartPayableFromExpense()])
"""The account code."""
description_template = StringField(
filters=[strip_text],
validators=[
DataRequired(lazy_gettext(
"Please fill in the description template."))])
"""The template for the line item description."""
class RecurringIncomeForm(RecurringItemForm):
"""The sub-form to add or update the recurring incomes."""
no = IntegerField()
"""The order number of this recurring item."""
name = StringField(
filters=[strip_text],
validators=[DataRequired(lazy_gettext("Please fill in the name."))])
"""The name of the recurring item."""
account_code = StringField(
filters=[strip_text],
validators=[
ACCOUNT_REQUIRED,
AccountExists(),
IsCreditAccount(lazy_gettext("This account is not for income.")),
NotStartReceivableFromIncome()])
"""The account code."""
description_template = StringField(
filters=[strip_text],
validators=[
DataRequired(lazy_gettext(
"Please fill in the description template."))])
"""The description template."""
class RecurringForm(RecurringItemForm):
"""The sub-form for the recurring expenses and incomes."""
expenses = FieldList(FormField(RecurringExpenseForm), name="expense")
"""The recurring expenses."""
incomes = FieldList(FormField(RecurringIncomeForm), name="income")
"""The recurring incomes."""
@property
def item_template(self) -> str:
"""Returns the template of a recurring item.
:return: The template of a recurring item.
"""
return render_template(
"accounting/option/include/form-recurring-item.html",
expense_income="EXPENSE_INCOME",
item_index="ITEM_INDEX",
form=RecurringItemForm())
@property
def expense_accounts(self) -> list[Account]:
"""The expense accounts.
:return: None.
"""
return Account.selectable_debit()
@property
def income_accounts(self) -> list[Account]:
"""The income accounts.
:return: None.
"""
return Account.selectable_credit()
@property
def as_data(self) -> dict[str, list[tuple[str, str, str]]]:
"""Returns the form data.
:return: The form data.
"""
def as_tuple(item: RecurringItemForm) -> tuple[str, str, str]:
return (item.name.data, item.account_code.data,
item.description_template.data)
expenses: list[RecurringItemForm] = [x.form for x in self.expenses]
self.__sort_item_forms(expenses)
incomes: list[RecurringItemForm] = [x.form for x in self.incomes]
self.__sort_item_forms(incomes)
return {"expense": [as_tuple(x) for x in expenses],
"income": [as_tuple(x) for x in incomes]}
@staticmethod
def __sort_item_forms(forms: list[RecurringItemForm]) -> None:
"""Sorts the recurring item sub-forms.
:param forms: The recurring item sub-forms.
:return: None.
"""
ord_by_form: dict[RecurringItemForm, int] \
= {forms[i]: i for i in range(len(forms))}
recv_no: set[int] = {x.no.data for x in forms if x.no.data is not None}
missing_recv_no: int = 100 if len(recv_no) == 0 else max(recv_no) + 100
forms.sort(key=lambda x: (x.no.data or missing_recv_no,
ord_by_form.get(x)))
class OptionForm(FlaskForm):
"""The form to update the options."""
default_currency_code = StringField(
filters=[strip_text],
validators=[
DataRequired(lazy_gettext("Please select the default currency.")),
CurrencyExists()])
"""The default currency code."""
default_ie_account_code = StringField(
filters=[strip_text],
validators=[
DataRequired(lazy_gettext(
"Please select the default account"
" for the income and expenses log.")),
CurrentAccountExists(),
AccountNotCurrent()])
"""The default account code for the income and expenses log."""
recurring = FormField(RecurringForm)
"""The recurring expenses and incomes."""
def populate_obj(self, obj: Options) -> None:
"""Populates the form data into a currency object.
:param obj: The currency object.
:return: None.
"""
obj.default_currency_code = self.default_currency_code.data
obj.default_ie_account_code = self.default_ie_account_code.data
obj.recurring_data = self.recurring.form.as_data
@property
def current_accounts(self) -> list[CurrentAccount]:
"""Returns the current accounts.
:return: The current accounts.
"""
return CurrentAccount.accounts()

View File

@ -1,83 +0,0 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/22
# Copyright (c) 2023 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""The views for the option management.
"""
from urllib.parse import parse_qsl, urlencode
from flask import Blueprint, render_template, redirect, session, request, \
flash, url_for
from werkzeug.datastructures import ImmutableMultiDict
from accounting.locale import lazy_gettext
from accounting.utils.cast import s
from accounting.utils.flash_errors import flash_form_errors
from accounting.utils.next_uri import inherit_next
from accounting.utils.options import options
from accounting.utils.permission import has_permission, can_admin
from .forms import OptionForm
bp: Blueprint = Blueprint("option", __name__)
"""The view blueprint for the currency management."""
@bp.get("", endpoint="detail")
@has_permission(can_admin)
def show_options() -> str:
"""Shows the options.
:return: The options.
"""
return render_template("accounting/option/detail.html", obj=options)
@bp.get("edit", endpoint="edit")
@has_permission(can_admin)
def show_option_form() -> str:
"""Shows the option form.
:return: The option form.
"""
form: OptionForm
if "form" in session:
form = OptionForm(ImmutableMultiDict(parse_qsl(session["form"])))
del session["form"]
form.validate()
else:
form = OptionForm(obj=options)
return render_template("accounting/option/form.html", form=form)
@bp.post("update", endpoint="update")
@has_permission(can_admin)
def update_options() -> redirect:
"""Updates the options.
:return: The redirection to the option form.
"""
form = OptionForm(request.form)
if not form.validate():
flash_form_errors(form)
session["form"] = urlencode(list(request.form.items()))
return redirect(inherit_next(url_for("accounting.option.edit")))
form.populate_obj(options)
if not options.is_modified:
flash(s(lazy_gettext("The settings were not modified.")), "success")
return redirect(inherit_next(url_for("accounting.option.detail")))
options.commit()
flash(s(lazy_gettext("The settings are saved successfully.")), "success")
return redirect(inherit_next(url_for("accounting.option.detail")))

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Project.
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/3
# Copyright (c) 2023 imacat.
@ -17,21 +17,19 @@
"""The report management.
"""
from flask import Flask
from flask import Flask, Blueprint
def init_app(app: Flask, url_prefix: str) -> None:
def init_app(app: Flask, bp: Blueprint) -> None:
"""Initialize the application.
:param app: The Flask application.
:param url_prefix: The URL prefix of the accounting application.
:param bp: The blueprint of the accounting application.
:return: None.
"""
from .converters import PeriodConverter, CurrentAccountConverter, \
NeedOffsetAccountConverter
from .converters import PeriodConverter, IncomeExpensesAccountConverter
app.url_map.converters["period"] = PeriodConverter
app.url_map.converters["currentAccount"] = CurrentAccountConverter
app.url_map.converters["needOffsetAccount"] = NeedOffsetAccountConverter
app.url_map.converters["ieAccount"] = IncomeExpensesAccountConverter
from .views import bp as report_bp
app.register_blueprint(report_bp, url_prefix=url_prefix)
bp.register_blueprint(report_bp, url_prefix="/reports")

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Project.
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/3
# Copyright (c) 2023 imacat.
@ -23,13 +23,13 @@ from flask import abort
from werkzeug.routing import BaseConverter
from accounting.models import Account
from accounting.utils.current_account import CurrentAccount
from .period import Period, get_period
from .utils.ie_account import IncomeExpensesAccount
class PeriodConverter(BaseConverter):
"""The converter to convert the period specification from and to the
corresponding period in the routes."""
"""The supplier converter to convert the period specification from and to
the corresponding period in the routes."""
def to_python(self, value: str) -> Period:
"""Converts a period specification to a period.
@ -51,52 +51,26 @@ class PeriodConverter(BaseConverter):
return value.spec
class CurrentAccountConverter(BaseConverter):
"""The converter to convert the current account code from and to the
corresponding account in the routes."""
class IncomeExpensesAccountConverter(BaseConverter):
"""The supplier converter to convert the income and expenses log pseudo
account code from and to the corresponding pseudo account in the routes."""
def to_python(self, value: str) -> CurrentAccount:
def to_python(self, value: str) -> IncomeExpensesAccount:
"""Converts an account code to an account.
:param value: The account code.
:return: The corresponding account.
"""
if value == CurrentAccount.CURRENT_AL_CODE:
return CurrentAccount.current_assets_and_liabilities()
if value == IncomeExpensesAccount.CURRENT_AL_CODE:
return IncomeExpensesAccount.current_assets_and_liabilities()
if not re.match("^[12][12]", value):
abort(404)
account: Account | None = Account.find_by_code(value)
if account is None:
abort(404)
return CurrentAccount(account)
return IncomeExpensesAccount(account)
def to_url(self, value: CurrentAccount) -> str:
"""Converts an account to account code.
:param value: The account.
:return: Its code.
"""
return value.code
class NeedOffsetAccountConverter(BaseConverter):
"""The converter to convert the unapplied original line item account code
from and to the corresponding account in the routes."""
def to_python(self, value: str) -> Account:
"""Converts an account code to an account.
:param value: The account code.
:return: The corresponding account.
"""
account: Account | None = Account.find_by_code(value)
if account is None:
abort(404)
if not account.is_need_offset:
abort(404)
return account
def to_url(self, value: Account) -> str:
def to_url(self, value: IncomeExpensesAccount) -> str:
"""Converts an account to account code.
:param value: The account.

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Project.
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/9
# Copyright (c) 2023 imacat.

View File

@ -1,7 +1,7 @@
# The Mia! Accounting Project.
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4
# Copyright (c) 2023-2024 imacat.
# Copyright (c) 2023 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@ -20,11 +20,10 @@ This file is largely taken from the NanoParma ERP project, first written in
2021/9/16 by imacat (imacat@nanoparma.com).
"""
import datetime as dt
from collections.abc import Callable
import typing as t
from datetime import date
from accounting.models import JournalEntry
from accounting.utils.timezone import get_tz_today
from accounting.models import Transaction
from .period import Period
from .shortcuts import ThisMonth, LastMonth, SinceLastMonth, ThisYear, \
LastYear, Today, Yesterday, AllTime, TemplatePeriod, YearPeriod
@ -33,13 +32,13 @@ from .shortcuts import ThisMonth, LastMonth, SinceLastMonth, ThisYear, \
class PeriodChooser:
"""The period chooser."""
def __init__(self, get_url: Callable[[Period], str]):
def __init__(self, get_url: t.Callable[[Period], str]):
"""Constructs a period chooser.
:param get_url: The callback to return the URL of the current report in
a period.
"""
self.__get_url: Callable[[Period], str] = get_url
self.__get_url: t.Callable[[Period], str] = get_url
"""The callback to return the URL of the current report in a period."""
# Shortcut periods
@ -62,12 +61,12 @@ class PeriodChooser:
self.url_template: str = get_url(TemplatePeriod())
"""The URL template."""
first: JournalEntry | None \
= JournalEntry.query.order_by(JournalEntry.date).first()
start: dt.date | None = None if first is None else first.date
first: Transaction | None \
= Transaction.query.order_by(Transaction.date).first()
start: date | None = None if first is None else first.date
# Attributes
self.data_start: dt.date | None = start
self.data_start: date | None = start
"""The start of the data."""
self.has_data: bool = start is not None
"""Whether there is any data."""
@ -81,8 +80,8 @@ class PeriodChooser:
"""The available years."""
if self.has_data:
today: dt.date = get_tz_today()
self.has_last_month = start < dt.date(today.year, today.month, 1)
today: date = date.today()
self.has_last_month = start < date(today.year, today.month, 1)
self.has_last_year = start.year < today.year
self.has_yesterday = start < today
if start.year < today.year - 1:

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Project.
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4
# Copyright (c) 2023 imacat.
@ -17,12 +17,12 @@
"""The period description composer.
"""
import datetime as dt
from datetime import date, timedelta
from accounting.locale import gettext
def get_desc(start: dt.date | None, end: dt.date | None) -> str:
def get_desc(start: date | None, end: date | None) -> str:
"""Returns the period description.
:param start: The start of the period.
@ -46,7 +46,7 @@ def get_desc(start: dt.date | None, end: dt.date | None) -> str:
return __get_day_desc(start, end)
def __get_since_desc(start: dt.date) -> str:
def __get_since_desc(start: date) -> str:
"""Returns the description without the end day.
:param start: The start of the period.
@ -67,7 +67,7 @@ def __get_since_desc(start: dt.date) -> str:
return gettext("since %(start)s", start=get_start_desc())
def __get_until_desc(end: dt.date) -> str:
def __get_until_desc(end: date) -> str:
"""Returns the description without the start day.
:param end: The end of the period.
@ -81,14 +81,14 @@ def __get_until_desc(end: dt.date) -> str:
"""
if end.month == 12 and end.day == 31:
return str(end.year)
if (end + dt.timedelta(days=1)).day == 1:
if (end + timedelta(days=1)).day == 1:
return __format_month(end)
return __format_day(end)
return gettext("until %(end)s", end=get_end_desc())
def __get_year_desc(start: dt.date, end: dt.date) -> str:
def __get_year_desc(start: date, end: date) -> str:
"""Returns the description as a year range.
:param start: The start of the period.
@ -105,7 +105,7 @@ def __get_year_desc(start: dt.date, end: dt.date) -> str:
return __get_from_to_desc(start_text, str(end.year))
def __get_month_desc(start: dt.date, end: dt.date) -> str:
def __get_month_desc(start: date, end: date) -> str:
"""Returns the description as a month range.
:param start: The start of the period.
@ -113,7 +113,7 @@ def __get_month_desc(start: dt.date, end: dt.date) -> str:
:return: The description as a month range.
:raise ValueError: The period is not a month range.
"""
if start.day != 1 or (end + dt.timedelta(days=1)).day != 1:
if start.day != 1 or (end + timedelta(days=1)).day != 1:
raise ValueError
start_text: str = __format_month(start)
if start.year == end.year and start.month == end.month:
@ -123,7 +123,7 @@ def __get_month_desc(start: dt.date, end: dt.date) -> str:
return __get_from_to_desc(start_text, __format_month(end))
def __get_day_desc(start: dt.date, end: dt.date) -> str:
def __get_day_desc(start: date, end: date) -> str:
"""Returns the description as a day range.
:param start: The start of the period.
@ -142,7 +142,7 @@ def __get_day_desc(start: dt.date, end: dt.date) -> str:
return __get_from_to_desc(start_text, __format_day(end))
def __format_month(month: dt.date) -> str:
def __format_month(month: date) -> str:
"""Formats a month.
:param month: The month.
@ -151,7 +151,7 @@ def __format_month(month: dt.date) -> str:
return f"{month.year}/{month.month}"
def __format_day(day: dt.date) -> str:
def __format_day(day: date) -> str:
"""Formats a day.
:param day: The day.

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Project.
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4
# Copyright (c) 2023 imacat.
@ -18,14 +18,14 @@
"""
import calendar
import datetime as dt
from datetime import date
def month_end(day: dt.date) -> dt.date:
def month_end(day: date) -> date:
"""Returns the end day of month for a date.
:param day: The date.
:return: The end day of the month of that day.
"""
last_day: int = calendar.monthrange(day.year, day.month)[1]
return dt.date(day.year, day.month, last_day)
return date(day.year, day.month, last_day)

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Project.
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4
# Copyright (c) 2023 imacat.
@ -18,10 +18,9 @@
"""
import calendar
import datetime as dt
import re
from collections.abc import Callable
from typing import Type
import typing as t
from datetime import date
from .period import Period
from .shortcuts import ThisMonth, LastMonth, SinceLastMonth, ThisYear, \
@ -40,7 +39,7 @@ def get_period(spec: str | None = None) -> Period:
"""
if spec is None:
return ThisMonth()
named_periods: dict[str, Type[Callable[[], Period]]] = {
named_periods: dict[str, t.Type[t.Callable[[], Period]]] = {
"this-month": lambda: ThisMonth(),
"last-month": lambda: LastMonth(),
"since-last-month": lambda: SinceLastMonth(),
@ -58,7 +57,7 @@ def get_period(spec: str | None = None) -> Period:
return Period(start, end)
def __parse_spec(text: str) -> tuple[dt.date | None, dt.date | None]:
def __parse_spec(text: str) -> tuple[date | None, date | None]:
"""Parses the period specification.
:param text: The period specification.
@ -85,7 +84,7 @@ def __parse_spec(text: str) -> tuple[dt.date | None, dt.date | None]:
raise ValueError
def __get_start(year: str, month: str | None, day: str | None) -> dt.date:
def __get_start(year: str, month: str | None, day: str | None) -> date:
"""Returns the start of the period from the date representation.
:param year: The year.
@ -95,13 +94,13 @@ def __get_start(year: str, month: str | None, day: str | None) -> dt.date:
:raise ValueError: When the date is invalid.
"""
if day is not None:
return dt.date(int(year), int(month), int(day))
return date(int(year), int(month), int(day))
if month is not None:
return dt.date(int(year), int(month), 1)
return dt.date(int(year), 1, 1)
return date(int(year), int(month), 1)
return date(int(year), 1, 1)
def __get_end(year: str, month: str | None, day: str | None) -> dt.date:
def __get_end(year: str, month: str | None, day: str | None) -> date:
"""Returns the end of the period from the date representation.
:param year: The year.
@ -111,10 +110,10 @@ def __get_end(year: str, month: str | None, day: str | None) -> dt.date:
:raise ValueError: When the date is invalid.
"""
if day is not None:
return dt.date(int(year), int(month), int(day))
return date(int(year), int(month), int(day))
if month is not None:
year_n: int = int(year)
month_n: int = int(month)
day_n: int = calendar.monthrange(year_n, month_n)[1]
return dt.date(year_n, month_n, day_n)
return dt.date(int(year), 12, 31)
return date(year_n, month_n, day_n)
return date(int(year), 12, 31)

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Project.
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4
# Copyright (c) 2023 imacat.
@ -20,8 +20,8 @@ This file is largely taken from the NanoParma ERP project, first written in
2021/9/16 by imacat (imacat@nanoparma.com).
"""
import datetime as dt
from typing import Self
import typing as t
from datetime import date, timedelta
from .description import get_desc
from .month_end import month_end
@ -31,18 +31,18 @@ from .specification import get_spec
class Period:
"""A date period."""
def __init__(self, start: dt.date | None, end: dt.date | None):
def __init__(self, start: date | None, end: date | None):
"""Constructs a new date period.
:param start: The start date, or None from the very beginning.
:param end: The end date, or None till no end.
"""
self.start: dt.date | None = start
self.start: date | None = start
"""The start of the period."""
self.end: dt.date | None = end
self.end: date | None = end
"""The end of the period."""
self.is_default: bool = False
"""Whether this is the default period."""
"""Whether the is the default period."""
self.is_this_month: bool = False
"""Whether the period is this month."""
self.is_last_month: bool = False
@ -95,8 +95,8 @@ class Period:
self.is_a_month = self.start.day == 1 \
and self.end == month_end(self.start)
self.is_type_month = self.is_a_month
self.is_a_year = self.start == dt.date(self.start.year, 1, 1) \
and self.end == dt.date(self.start.year, 12, 31)
self.is_a_year = self.start == date(self.start.year, 1, 1) \
and self.end == date(self.start.year, 12, 31)
self.is_a_day = self.start == self.end
def is_year(self, year: int) -> bool:
@ -119,11 +119,11 @@ class Period:
and not self.is_a_day
@property
def before(self) -> Self | None:
def before(self) -> t.Self | None:
"""Returns the period before this period.
:return: The period before this period.
"""
if self.start is None:
return None
return Period(None, self.start - dt.timedelta(days=1))
return Period(None, self.start - timedelta(days=1))

View File

@ -1,7 +1,7 @@
# The Mia! Accounting Project.
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4
# Copyright (c) 2023-2024 imacat.
# Copyright (c) 2023 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@ -17,10 +17,9 @@
"""The named shortcut periods.
"""
import datetime as dt
from datetime import date, timedelta
from accounting.locale import gettext
from accounting.utils.timezone import get_tz_today
from .month_end import month_end
from .period import Period
@ -28,15 +27,15 @@ from .period import Period
class ThisMonth(Period):
"""The period of this month."""
def __init__(self):
today: dt.date = get_tz_today()
this_month_start: dt.date = dt.date(today.year, today.month, 1)
today: date = date.today()
this_month_start: date = date(today.year, today.month, 1)
super().__init__(this_month_start, month_end(today))
self.is_default = True
self.is_this_month = True
def _set_properties(self) -> None:
self.spec = "this-month"
self.desc = gettext("This Month")
self.desc = gettext("This month")
self.is_a_month = True
self.is_type_month = True
@ -44,19 +43,19 @@ class ThisMonth(Period):
class LastMonth(Period):
"""The period of this month."""
def __init__(self):
today: dt.date = get_tz_today()
today: date = date.today()
year: int = today.year
month: int = today.month - 1
if month < 1:
year = year - 1
month = 12
start: dt.date = dt.date(year, month, 1)
start: date = date(year, month, 1)
super().__init__(start, month_end(start))
self.is_last_month = True
def _set_properties(self) -> None:
self.spec = "last-month"
self.desc = gettext("Last Month")
self.desc = gettext("Last month")
self.is_a_month = True
self.is_type_month = True
@ -64,56 +63,56 @@ class LastMonth(Period):
class SinceLastMonth(Period):
"""The period of this month."""
def __init__(self):
today: dt.date = get_tz_today()
today: date = date.today()
year: int = today.year
month: int = today.month - 1
if month < 1:
year = year - 1
month = 12
start: dt.date = dt.date(year, month, 1)
start: date = date(year, month, 1)
super().__init__(start, None)
self.is_since_last_month = True
def _set_properties(self) -> None:
self.spec = "since-last-month"
self.desc = gettext("Since Last Month")
self.desc = gettext("Since last month")
self.is_type_month = True
class ThisYear(Period):
"""The period of this year."""
def __init__(self):
year: int = get_tz_today().year
start: dt.date = dt.date(year, 1, 1)
end: dt.date = dt.date(year, 12, 31)
year: int = date.today().year
start: date = date(year, 1, 1)
end: date = date(year, 12, 31)
super().__init__(start, end)
self.is_this_year = True
def _set_properties(self) -> None:
self.spec = "this-year"
self.desc = gettext("This Year")
self.desc = gettext("This year")
self.is_a_year = True
class LastYear(Period):
"""The period of last year."""
def __init__(self):
year: int = get_tz_today().year
start: dt.date = dt.date(year - 1, 1, 1)
end: dt.date = dt.date(year - 1, 12, 31)
year: int = date.today().year
start: date = date(year - 1, 1, 1)
end: date = date(year - 1, 12, 31)
super().__init__(start, end)
self.is_last_year = True
def _set_properties(self) -> None:
self.spec = "last-year"
self.desc = gettext("Last Year")
self.desc = gettext("Last year")
self.is_a_year = True
class Today(Period):
"""The period of today."""
def __init__(self):
today: dt.date = get_tz_today()
today: date = date.today()
super().__init__(today, today)
self.is_today = True
@ -126,7 +125,7 @@ class Today(Period):
class Yesterday(Period):
"""The period of yesterday."""
def __init__(self):
yesterday: dt.date = get_tz_today() - dt.timedelta(days=1)
yesterday: date = date.today() - timedelta(days=1)
super().__init__(yesterday, yesterday)
self.is_yesterday = True
@ -164,6 +163,6 @@ class YearPeriod(Period):
:param year: The year.
"""
start: dt.date = dt.date(year, 1, 1)
end: dt.date = dt.date(year, 12, 31)
start: date = date(year, 1, 1)
end: date = date(year, 12, 31)
super().__init__(start, end)

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Project.
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4
# Copyright (c) 2023 imacat.
@ -17,10 +17,10 @@
"""The period specification composer.
"""
import datetime as dt
from datetime import date, timedelta
def get_spec(start: dt.date | None, end: dt.date | None) -> str:
def get_spec(start: date | None, end: date | None) -> str:
"""Returns the period specification.
:param start: The start of the period.
@ -44,7 +44,7 @@ def get_spec(start: dt.date | None, end: dt.date | None) -> str:
return __get_day_spec(start, end)
def __get_since_spec(start: dt.date) -> str:
def __get_since_spec(start: date) -> str:
"""Returns the period specification without the end day.
:param start: The start of the period.
@ -57,7 +57,7 @@ def __get_since_spec(start: dt.date) -> str:
return start.strftime("%Y-%m-%d-")
def __get_until_spec(end: dt.date) -> str:
def __get_until_spec(end: date) -> str:
"""Returns the period specification without the start day.
:param end: The end of the period.
@ -65,12 +65,12 @@ def __get_until_spec(end: dt.date) -> str:
"""
if end.month == 12 and end.day == 31:
return end.strftime("-%Y")
if (end + dt.timedelta(days=1)).day == 1:
if (end + timedelta(days=1)).day == 1:
return end.strftime("-%Y-%m")
return end.strftime("-%Y-%m-%d")
def __get_year_spec(start: dt.date, end: dt.date) -> str:
def __get_year_spec(start: date, end: date) -> str:
"""Returns the period specification as a year range.
:param start: The start of the period.
@ -88,7 +88,7 @@ def __get_year_spec(start: dt.date, end: dt.date) -> str:
return f"{start_spec}-{end_spec}"
def __get_month_spec(start: dt.date, end: dt.date) -> str:
def __get_month_spec(start: date, end: date) -> str:
"""Returns the period specification as a month range.
:param start: The start of the period.
@ -96,7 +96,7 @@ def __get_month_spec(start: dt.date, end: dt.date) -> str:
:return: The period specification as a month range.
:raise ValueError: The period is not a month range.
"""
if start.day != 1 or (end + dt.timedelta(days=1)).day != 1:
if start.day != 1 or (end + timedelta(days=1)).day != 1:
raise ValueError
start_spec: str = start.strftime("%Y-%m")
if start.year == end.year and start.month == end.month:
@ -105,7 +105,7 @@ def __get_month_spec(start: dt.date, end: dt.date) -> str:
return f"{start_spec}-{end_spec}"
def __get_day_spec(start: dt.date, end: dt.date) -> str:
def __get_day_spec(start: date, end: date) -> str:
"""Returns the period specification as a day range.
:param start: The start of the period.

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Project.
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7
# Copyright (c) 2023 imacat.

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Project.
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7
# Copyright (c) 2023 imacat.
@ -24,8 +24,8 @@ from flask import render_template, Response
from accounting import db
from accounting.locale import gettext
from accounting.models import Currency, BaseAccount, Account, JournalEntry, \
JournalEntryLineItem
from accounting.models import Currency, BaseAccount, Account, Transaction, \
JournalEntry
from accounting.report.period import Period, PeriodChooser
from accounting.report.utils.base_page_params import BasePageParams
from accounting.report.utils.base_report import BaseReport
@ -124,20 +124,19 @@ class AccountCollector:
sub_conditions: list[sa.BinaryExpression] \
= [Account.base_code.startswith(x) for x in {"1", "2", "3"}]
conditions: list[sa.BinaryExpression] \
= [JournalEntryLineItem.currency_code == self.__currency.code,
= [JournalEntry.currency_code == self.__currency.code,
sa.or_(*sub_conditions)]
if self.__period.end is not None:
conditions.append(JournalEntry.date <= self.__period.end)
conditions.append(Transaction.date <= self.__period.end)
balance_func: sa.Function = sa.func.sum(sa.case(
(JournalEntryLineItem.is_debit, JournalEntryLineItem.amount),
else_=-JournalEntryLineItem.amount)).label("balance")
(JournalEntry.is_debit, JournalEntry.amount),
else_=-JournalEntry.amount)).label("balance")
select_balance: sa.Select \
= sa.select(Account.id, Account.base_code, Account.no,
balance_func)\
.join(JournalEntry).join(Account)\
.join(Transaction).join(Account)\
.filter(*conditions)\
.group_by(Account.id, Account.base_code, Account.no)\
.having(balance_func != 0)\
.order_by(Account.base_code, Account.no)
account_balances: list[sa.Row] \
= db.session.execute(select_balance).all()
@ -145,7 +144,6 @@ class AccountCollector:
.filter(sa.or_(Account.id.in_({x.id for x in account_balances}),
Account.base_code == "3351",
Account.base_code == "3353")).all()
"""The accounts."""
account_by_id: dict[int, Account] \
= {x.id: x for x in self.__all_accounts}
self.accounts: list[ReportAccount] \
@ -155,7 +153,6 @@ class AccountCollector:
account_by_id[x.id],
self.__period))
for x in account_balances]
"""The accounts on the balance sheet."""
self.__add_accumulated()
self.__add_current_period()
self.accounts.sort(key=lambda x: (x.account.base_code, x.account.no))
@ -181,8 +178,8 @@ class AccountCollector:
if self.__period.start is None:
return None
conditions: list[sa.BinaryExpression] \
= [JournalEntryLineItem.currency_code == self.__currency.code,
JournalEntry.date < self.__period.start]
= [JournalEntry.currency_code == self.__currency.code,
Transaction.date < self.__period.start]
return self.__query_balance(conditions)
def __add_current_period(self) -> None:
@ -191,20 +188,20 @@ class AccountCollector:
:return: None.
"""
self.__add_owner_s_equity(Account.NET_CHANGE_CODE,
self.__query_current_period(),
self.__query_currency_period(),
self.__period)
def __query_current_period(self) -> Decimal | None:
def __query_currency_period(self) -> Decimal | None:
"""Queries and returns the net income or loss for current period.
:return: The net income or loss for current period.
"""
conditions: list[sa.BinaryExpression] \
= [JournalEntryLineItem.currency_code == self.__currency.code]
= [JournalEntry.currency_code == self.__currency.code]
if self.__period.start is not None:
conditions.append(JournalEntry.date >= self.__period.start)
conditions.append(Transaction.date >= self.__period.start)
if self.__period.end is not None:
conditions.append(JournalEntry.date <= self.__period.end)
conditions.append(Transaction.date <= self.__period.end)
return self.__query_balance(conditions)
@staticmethod
@ -216,12 +213,12 @@ class AccountCollector:
:return: The balance.
"""
conditions.extend([sa.not_(Account.base_code.startswith(x))
for x in {"1", "2", "3"}])
for x in {"1", "2"}])
balance_func: sa.Function = sa.func.sum(sa.case(
(JournalEntryLineItem.is_debit, JournalEntryLineItem.amount),
else_=-JournalEntryLineItem.amount))
(JournalEntry.is_debit, JournalEntry.amount),
else_=-JournalEntry.amount))
select_balance: sa.Select = sa.select(balance_func)\
.join(JournalEntry).join(Account).filter(*conditions)
.join(Transaction).join(Account).filter(*conditions)
return db.session.scalar(select_balance)
def __add_owner_s_equity(self, code: str, amount: Decimal | None,
@ -454,11 +451,11 @@ class BalanceSheet(BaseReport):
:return: The CSV rows for the section.
"""
rows: list[CSVHalfRow] \
= [CSVHalfRow(section.title.title, None)]
= [CSVHalfRow(section.title.title.title(), None)]
for subsection in section.subsections:
rows.append(CSVHalfRow(f" {subsection.title.title}", None))
rows.append(CSVHalfRow(f" {subsection.title.title.title()}", None))
for account in subsection.accounts:
rows.append(CSVHalfRow(f" {str(account.account)}",
rows.append(CSVHalfRow(f" {str(account.account).title()}",
account.amount))
return rows

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Project.
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7
# Copyright (c) 2023 imacat.
@ -17,7 +17,7 @@
"""The income and expenses log.
"""
import datetime as dt
from datetime import date
from decimal import Decimal
import sqlalchemy as sa
@ -26,39 +26,38 @@ from sqlalchemy.orm import selectinload
from accounting import db
from accounting.locale import gettext
from accounting.models import Currency, Account, JournalEntry, \
JournalEntryLineItem
from accounting.models import Currency, Account, Transaction, JournalEntry
from accounting.report.period import Period, PeriodChooser
from accounting.report.utils.base_page_params import BasePageParams
from accounting.report.utils.base_report import BaseReport
from accounting.report.utils.csv_export import BaseCSVRow, csv_download, \
period_spec
from accounting.report.utils.ie_account import IncomeExpensesAccount
from accounting.report.utils.option_link import OptionLink
from accounting.report.utils.report_chooser import ReportChooser
from accounting.report.utils.report_type import ReportType
from accounting.report.utils.urls import income_expenses_url
from accounting.utils.current_account import CurrentAccount
from accounting.utils.pagination import Pagination
class ReportLineItem:
"""A line item in the report."""
class ReportEntry:
"""An entry in the report."""
def __init__(self, line_item: JournalEntryLineItem | None = None):
"""Constructs the line item in the report.
def __init__(self, entry: JournalEntry | None = None):
"""Constructs the entry in the report.
:param line_item: The journal entry line item.
:param entry: The journal entry.
"""
self.is_brought_forward: bool = False
"""Whether this is the brought-forward line item."""
"""Whether this is the brought-forward entry."""
self.is_total: bool = False
"""Whether this is the total line item."""
self.date: dt.date | None = None
"""Whether this is the total entry."""
self.date: date | None = None
"""The date."""
self.account: Account | None = None
"""The account."""
self.description: str | None = None
"""The description."""
self.summary: str | None = None
"""The summary."""
self.income: Decimal | None = None
"""The income amount."""
self.expense: Decimal | None = None
@ -68,24 +67,24 @@ class ReportLineItem:
self.note: str | None = None
"""The note."""
self.url: str | None = None
"""The URL to the journal entry line item."""
if line_item is not None:
self.date = line_item.journal_entry.date
self.account = line_item.account
self.description = line_item.description
self.income = None if line_item.is_debit else line_item.amount
self.expense = line_item.amount if line_item.is_debit else None
self.note = line_item.journal_entry.note
self.url = url_for("accounting.journal-entry.detail",
journal_entry=line_item.journal_entry)
"""The URL to the journal entry."""
if entry is not None:
self.date = entry.transaction.date
self.account = entry.account
self.summary = entry.summary
self.income = None if entry.is_debit else entry.amount
self.expense = entry.amount if entry.is_debit else None
self.note = entry.transaction.note
self.url = url_for("accounting.transaction.detail",
txn=entry.transaction)
class LineItemCollector:
"""The line item collector."""
class EntryCollector:
"""The report entry collector."""
def __init__(self, currency: Currency, account: CurrentAccount,
def __init__(self, currency: Currency, account: IncomeExpensesAccount,
period: Period):
"""Constructs the line item collector.
"""Constructs the report entry collector.
:param currency: The currency.
:param account: The account.
@ -93,149 +92,147 @@ class LineItemCollector:
"""
self.__currency: Currency = currency
"""The currency."""
self.__account: CurrentAccount = account
self.__account: IncomeExpensesAccount = account
"""The account."""
self.__period: Period = period
"""The period"""
self.brought_forward: ReportLineItem | None
"""The brought-forward line item."""
self.line_items: list[ReportLineItem]
"""The line items."""
self.total: ReportLineItem | None
"""The total line item."""
self.brought_forward = self.__get_brought_forward()
self.line_items = self.__query_line_items()
self.total = self.__get_total()
self.brought_forward: ReportEntry | None
"""The brought-forward entry."""
self.entries: list[ReportEntry]
"""The log entries."""
self.total: ReportEntry | None
"""The total entry."""
self.brought_forward = self.__get_brought_forward_entry()
self.entries = self.__query_entries()
self.total = self.__get_total_entry()
self.__populate_balance()
def __get_brought_forward(self) -> ReportLineItem | None:
"""Queries, composes and returns the brought-forward line item.
def __get_brought_forward_entry(self) -> ReportEntry | None:
"""Queries, composes and returns the brought-forward entry.
:return: The brought-forward line item, or None if the period starts
from the beginning.
:return: The brought-forward entry, or None if the period starts from
the beginning.
"""
if self.__period.start is None:
return None
balance_func: sa.Function = sa.func.sum(sa.case(
(JournalEntryLineItem.is_debit, JournalEntryLineItem.amount),
else_=-JournalEntryLineItem.amount))
(JournalEntry.is_debit, JournalEntry.amount),
else_=-JournalEntry.amount))
select: sa.Select = sa.Select(balance_func)\
.join(JournalEntry).join(Account)\
.filter(JournalEntryLineItem.currency_code == self.__currency.code,
.join(Transaction).join(Account)\
.filter(JournalEntry.currency_code == self.__currency.code,
self.__account_condition,
JournalEntry.date < self.__period.start)
Transaction.date < self.__period.start)
balance: int | None = db.session.scalar(select)
if balance is None:
return None
line_item: ReportLineItem = ReportLineItem()
line_item.is_brought_forward = True
line_item.date = self.__period.start
line_item.account = Account.accumulated_change()
line_item.description = gettext("Brought forward")
entry: ReportEntry = ReportEntry()
entry.is_brought_forward = True
entry.date = self.__period.start
entry.account = Account.accumulated_change()
entry.summary = gettext("Brought forward")
if balance > 0:
line_item.income = balance
entry.income = balance
elif balance < 0:
line_item.expense = -balance
line_item.balance = balance
return line_item
entry.expense = -balance
entry.balance = balance
return entry
def __query_line_items(self) -> list[ReportLineItem]:
"""Queries and returns the line items.
def __query_entries(self) -> list[ReportEntry]:
"""Queries and returns the log entries.
:return: The line items.
:return: The log entries.
"""
conditions: list[sa.BinaryExpression] \
= [JournalEntryLineItem.currency_code == self.__currency.code,
= [JournalEntry.currency_code == self.__currency.code,
self.__account_condition]
if self.__period.start is not None:
conditions.append(JournalEntry.date >= self.__period.start)
conditions.append(Transaction.date >= self.__period.start)
if self.__period.end is not None:
conditions.append(JournalEntry.date <= self.__period.end)
journal_entry_with_account: sa.Select = sa.Select(JournalEntry.id).\
join(JournalEntryLineItem).join(Account).filter(*conditions)
conditions.append(Transaction.date <= self.__period.end)
txn_with_account: sa.Select = sa.Select(Transaction.id).\
join(JournalEntry).join(Account).filter(*conditions)
return [ReportLineItem(x)
for x in JournalEntryLineItem.query
.join(JournalEntry).join(Account)
.filter(JournalEntryLineItem.journal_entry_id
.in_(journal_entry_with_account),
JournalEntryLineItem.currency_code
== self.__currency.code,
return [ReportEntry(x)
for x in JournalEntry.query.join(Transaction).join(Account)
.filter(JournalEntry.transaction_id.in_(txn_with_account),
JournalEntry.currency_code == self.__currency.code,
sa.not_(self.__account_condition))
.order_by(JournalEntry.date,
JournalEntry.no,
JournalEntryLineItem.is_debit,
JournalEntryLineItem.no)
.options(selectinload(JournalEntryLineItem.account),
selectinload(JournalEntryLineItem.journal_entry))]
.order_by(Transaction.date,
JournalEntry.is_debit,
JournalEntry.no)
.options(selectinload(JournalEntry.account),
selectinload(JournalEntry.transaction))]
@property
def __account_condition(self) -> sa.BinaryExpression:
if self.__account.code == CurrentAccount.CURRENT_AL_CODE:
return CurrentAccount.sql_condition()
if self.__account.code == IncomeExpensesAccount.CURRENT_AL_CODE:
return sa.or_(Account.base_code.startswith("11"),
Account.base_code.startswith("12"),
Account.base_code.startswith("21"),
Account.base_code.startswith("22"))
return Account.id == self.__account.id
def __get_total(self) -> ReportLineItem | None:
"""Composes the total line item.
def __get_total_entry(self) -> ReportEntry | None:
"""Composes the total entry.
:return: The total line item, or None if there is no data.
:return: The total entry, or None if there is no data.
"""
if self.brought_forward is None and len(self.line_items) == 0:
if self.brought_forward is None and len(self.entries) == 0:
return None
line_item: ReportLineItem = ReportLineItem()
line_item.is_total = True
line_item.description = gettext("Total")
line_item.income = sum([x.income for x in self.line_items
entry: ReportEntry = ReportEntry()
entry.is_total = True
entry.summary = gettext("Total")
entry.income = sum([x.income for x in self.entries
if x.income is not None])
line_item.expense = sum([x.expense for x in self.line_items
entry.expense = sum([x.expense for x in self.entries
if x.expense is not None])
line_item.balance = line_item.income - line_item.expense
entry.balance = entry.income - entry.expense
if self.brought_forward is not None:
line_item.balance \
= self.brought_forward.balance + line_item.balance
return line_item
entry.balance = self.brought_forward.balance + entry.balance
return entry
def __populate_balance(self) -> None:
"""Populates the balance of the line items.
"""Populates the balance of the entries.
:return: None.
"""
balance: Decimal = 0 if self.brought_forward is None \
else self.brought_forward.balance
for line_item in self.line_items:
if line_item.income is not None:
balance = balance + line_item.income
if line_item.expense is not None:
balance = balance - line_item.expense
line_item.balance = balance
for entry in self.entries:
if entry.income is not None:
balance = balance + entry.income
if entry.expense is not None:
balance = balance - entry.expense
entry.balance = balance
class CSVRow(BaseCSVRow):
"""A row in the CSV."""
def __init__(self, date: dt.date | str | None,
def __init__(self, txn_date: date | str | None,
account: str | None,
description: str | None,
summary: str | None,
income: str | Decimal | None,
expense: str | Decimal | None,
balance: str | Decimal | None,
note: str | None):
"""Constructs a row in the CSV.
:param date: The journal entry date.
:param txn_date: The transaction date.
:param account: The account.
:param description: The description.
:param summary: The summary.
:param income: The income.
:param expense: The expense.
:param balance: The balance.
:param note: The note.
"""
self.date: dt.date | str | None = date
self.date: date | str | None = txn_date
"""The date."""
self.account: str | None = account
"""The account."""
self.description: str | None = description
"""The description."""
self.summary: str | None = summary
"""The summary."""
self.income: str | Decimal | None = income
"""The income."""
self.expense: str | Decimal | None = expense
@ -251,7 +248,7 @@ class CSVRow(BaseCSVRow):
:return: The values of the row.
"""
return [self.date, self.account, self.description,
return [self.date, self.account, self.summary,
self.income, self.expense, self.balance, self.note]
@ -259,39 +256,39 @@ class PageParams(BasePageParams):
"""The HTML page parameters."""
def __init__(self, currency: Currency,
account: CurrentAccount,
account: IncomeExpensesAccount,
period: Period,
has_data: bool,
pagination: Pagination[ReportLineItem],
brought_forward: ReportLineItem | None,
line_items: list[ReportLineItem],
total: ReportLineItem | None):
pagination: Pagination[ReportEntry],
brought_forward: ReportEntry | None,
entries: list[ReportEntry],
total: ReportEntry | None):
"""Constructs the HTML page parameters.
:param currency: The currency.
:param account: The account.
:param period: The period.
:param has_data: True if there is any data, or False otherwise.
:param brought_forward: The brought-forward line item.
:param line_items: The line items.
:param total: The total line item.
:param brought_forward: The brought-forward entry.
:param entries: The log entries.
:param total: The total entry.
"""
self.currency: Currency = currency
"""The currency."""
self.account: CurrentAccount = account
self.account: IncomeExpensesAccount = account
"""The account."""
self.period: Period = period
"""The period."""
self.__has_data: bool = has_data
"""True if there is any data, or False otherwise."""
self.pagination: Pagination[ReportLineItem] = pagination
self.pagination: Pagination[ReportEntry] = pagination
"""The pagination."""
self.brought_forward: ReportLineItem | None = brought_forward
"""The brought-forward line item."""
self.line_items: list[ReportLineItem] = line_items
"""The line items."""
self.total: ReportLineItem | None = total
"""The total line item."""
self.brought_forward: ReportEntry | None = brought_forward
"""The brought-forward entry."""
self.entries: list[ReportEntry] = entries
"""The report entries."""
self.total: ReportEntry | None = total
"""The total entry."""
self.period_chooser: PeriodChooser = PeriodChooser(
lambda x: income_expenses_url(currency, account, x))
"""The period chooser."""
@ -336,22 +333,25 @@ class PageParams(BasePageParams):
:return: The account options.
"""
current_al: CurrentAccount \
= CurrentAccount.current_assets_and_liabilities()
current_al: IncomeExpensesAccount \
= IncomeExpensesAccount.current_assets_and_liabilities()
options: list[OptionLink] \
= [OptionLink(str(current_al),
income_expenses_url(self.currency, current_al,
self.period),
self.account.id == 0)]
in_use: sa.Select = sa.Select(JournalEntryLineItem.account_id)\
in_use: sa.Select = sa.Select(JournalEntry.account_id)\
.join(Account)\
.filter(JournalEntryLineItem.currency_code == self.currency.code,
CurrentAccount.sql_condition())\
.group_by(JournalEntryLineItem.account_id)
.filter(JournalEntry.currency_code == self.currency.code,
sa.or_(Account.base_code.startswith("11"),
Account.base_code.startswith("12"),
Account.base_code.startswith("21"),
Account.base_code.startswith("22")))\
.group_by(JournalEntry.account_id)
options.extend([OptionLink(str(x),
income_expenses_url(
self.currency,
CurrentAccount(x),
IncomeExpensesAccount(x),
self.period),
x.id == self.account.id)
for x in Account.query.filter(Account.id.in_(in_use))
@ -362,7 +362,7 @@ class PageParams(BasePageParams):
class IncomeExpenses(BaseReport):
"""The income and expenses log."""
def __init__(self, currency: Currency, account: CurrentAccount,
def __init__(self, currency: Currency, account: IncomeExpensesAccount,
period: Period):
"""Constructs an income and expenses log.
@ -372,19 +372,18 @@ class IncomeExpenses(BaseReport):
"""
self.__currency: Currency = currency
"""The currency."""
self.__account: CurrentAccount = account
self.__account: IncomeExpensesAccount = account
"""The account."""
self.__period: Period = period
"""The period."""
collector: LineItemCollector = LineItemCollector(
collector: EntryCollector = EntryCollector(
self.__currency, self.__account, self.__period)
self.__brought_forward: ReportLineItem | None \
= collector.brought_forward
"""The brought-forward line item."""
self.__line_items: list[ReportLineItem] = collector.line_items
"""The line items."""
self.__total: ReportLineItem | None = collector.total
"""The total line item."""
self.__brought_forward: ReportEntry | None = collector.brought_forward
"""The brought-forward entry."""
self.__entries: list[ReportEntry] = collector.entries
"""The report entries."""
self.__total: ReportEntry | None = collector.total
"""The total entry."""
def csv(self) -> Response:
"""Returns the report as CSV for download.
@ -402,20 +401,20 @@ class IncomeExpenses(BaseReport):
:return: The CSV rows.
"""
rows: list[CSVRow] = [CSVRow(gettext("Date"), gettext("Account"),
gettext("Description"), gettext("Income"),
gettext("Summary"), gettext("Income"),
gettext("Expense"), gettext("Balance"),
gettext("Note"))]
if self.__brought_forward is not None:
rows.append(CSVRow(self.__brought_forward.date,
str(self.__brought_forward.account),
self.__brought_forward.description,
str(self.__brought_forward.account).title(),
self.__brought_forward.summary,
self.__brought_forward.income,
self.__brought_forward.expense,
self.__brought_forward.balance,
None))
rows.extend([CSVRow(x.date, str(x.account), x.description,
rows.extend([CSVRow(x.date, str(x.account).title(), x.summary,
x.income, x.expense, x.balance, x.note)
for x in self.__line_items])
for x in self.__entries])
if self.__total is not None:
rows.append(CSVRow(gettext("Total"), None, None,
self.__total.income, self.__total.expense,
@ -427,31 +426,31 @@ class IncomeExpenses(BaseReport):
:return: The report as HTML.
"""
all_line_items: list[ReportLineItem] = []
all_entries: list[ReportEntry] = []
if self.__brought_forward is not None:
all_line_items.append(self.__brought_forward)
all_line_items.extend(self.__line_items)
all_entries.append(self.__brought_forward)
all_entries.extend(self.__entries)
if self.__total is not None:
all_line_items.append(self.__total)
pagination: Pagination[ReportLineItem] \
= Pagination[ReportLineItem](all_line_items, is_reversed=True)
page_line_items: list[ReportLineItem] = pagination.list
has_data: bool = len(page_line_items) > 0
brought_forward: ReportLineItem | None = None
if len(page_line_items) > 0 and page_line_items[0].is_brought_forward:
brought_forward = page_line_items[0]
page_line_items = page_line_items[1:]
total: ReportLineItem | None = None
if len(page_line_items) > 0 and page_line_items[-1].is_total:
total = page_line_items[-1]
page_line_items = page_line_items[:-1]
all_entries.append(self.__total)
pagination: Pagination[ReportEntry] \
= Pagination[ReportEntry](all_entries)
page_entries: list[ReportEntry] = pagination.list
has_data: bool = len(page_entries) > 0
brought_forward: ReportEntry | None = None
if len(page_entries) > 0 and page_entries[0].is_brought_forward:
brought_forward = page_entries[0]
page_entries = page_entries[1:]
total: ReportEntry | None = None
if len(page_entries) > 0 and page_entries[-1].is_total:
total = page_entries[-1]
page_entries = page_entries[:-1]
params: PageParams = PageParams(currency=self.__currency,
account=self.__account,
period=self.__period,
has_data=has_data,
pagination=pagination,
brought_forward=brought_forward,
line_items=page_line_items,
entries=page_entries,
total=total)
return render_template("accounting/report/income-expenses.html",
report=params)

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Project.
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7
# Copyright (c) 2023 imacat.
@ -24,8 +24,8 @@ from flask import render_template, Response
from accounting import db
from accounting.locale import gettext
from accounting.models import Currency, BaseAccount, Account, JournalEntry, \
JournalEntryLineItem
from accounting.models import Currency, BaseAccount, Account, Transaction, \
JournalEntry
from accounting.report.period import Period, PeriodChooser
from accounting.report.utils.base_page_params import BasePageParams
from accounting.report.utils.base_report import BaseReport
@ -106,7 +106,6 @@ class Section:
"""The subsections in the section."""
self.accumulated: AccumulatedTotal \
= AccumulatedTotal(accumulated_title)
"""The accumulated total."""
@property
def total(self) -> Decimal:
@ -226,12 +225,12 @@ class IncomeStatement(BaseReport):
for x in balances})).all()
total_titles: dict[str, str] \
= {"4": gettext("Total Operating Revenue"),
"5": gettext("Gross Income"),
"6": gettext("Operating Income"),
"7": gettext("Before Tax Income"),
"8": gettext("After Tax Income"),
"9": gettext("Net Income or Loss for Current Period")}
= {"4": gettext("total operating revenue"),
"5": gettext("gross income"),
"6": gettext("operating income"),
"7": gettext("before tax income"),
"8": gettext("after tax income"),
"9": gettext("net income or loss for current period")}
sections: dict[str, Section] \
= {x.code: Section(x, total_titles[x.code]) for x in titles}
@ -257,20 +256,19 @@ class IncomeStatement(BaseReport):
sub_conditions: list[sa.BinaryExpression] \
= [Account.base_code.startswith(str(x)) for x in range(4, 10)]
conditions: list[sa.BinaryExpression] \
= [JournalEntryLineItem.currency_code == self.__currency.code,
= [JournalEntry.currency_code == self.__currency.code,
sa.or_(*sub_conditions)]
if self.__period.start is not None:
conditions.append(JournalEntry.date >= self.__period.start)
conditions.append(Transaction.date >= self.__period.start)
if self.__period.end is not None:
conditions.append(JournalEntry.date <= self.__period.end)
conditions.append(Transaction.date <= self.__period.end)
balance_func: sa.Function = sa.func.sum(sa.case(
(JournalEntryLineItem.is_debit, -JournalEntryLineItem.amount),
else_=JournalEntryLineItem.amount)).label("balance")
(JournalEntry.is_debit, -JournalEntry.amount),
else_=JournalEntry.amount)).label("balance")
select_balances: sa.Select = sa.select(Account.id, balance_func)\
.join(JournalEntry).join(Account)\
.join(Transaction).join(Account)\
.filter(*conditions)\
.group_by(Account.id)\
.having(balance_func != 0)\
.order_by(Account.base_code, Account.no)
balances: list[sa.Row] = db.session.execute(select_balances).all()
accounts: dict[int, Account] \
@ -301,14 +299,14 @@ class IncomeStatement(BaseReport):
total_str: str = gettext("Total")
rows: list[CSVRow] = [CSVRow(None, gettext("Amount"))]
for section in self.__sections:
rows.append(CSVRow(str(section.title), None))
rows.append(CSVRow(str(section.title).title(), None))
for subsection in section.subsections:
rows.append(CSVRow(f" {str(subsection.title)}", None))
rows.append(CSVRow(f" {str(subsection.title).title()}", None))
for account in subsection.accounts:
rows.append(CSVRow(f" {str(account.account)}",
rows.append(CSVRow(f" {str(account.account).title()}",
account.amount))
rows.append(CSVRow(f" {total_str}", subsection.total))
rows.append(CSVRow(section.accumulated.title,
rows.append(CSVRow(section.accumulated.title.title(),
section.accumulated.amount))
rows.append(CSVRow(None, None))
rows = rows[:-1]

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Project.
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7
# Copyright (c) 2023 imacat.
@ -17,7 +17,7 @@
"""The journal.
"""
import datetime as dt
from datetime import date
from decimal import Decimal
import sqlalchemy as sa
@ -25,8 +25,7 @@ from flask import render_template, Response
from sqlalchemy.orm import selectinload
from accounting.locale import gettext
from accounting.models import Currency, Account, JournalEntry, \
JournalEntryLineItem
from accounting.models import Currency, Account, Transaction, JournalEntry
from accounting.report.period import Period, PeriodChooser
from accounting.report.utils.base_page_params import BasePageParams
from accounting.report.utils.base_report import BaseReport
@ -38,60 +37,58 @@ from accounting.report.utils.urls import journal_url
from accounting.utils.pagination import Pagination
class ReportLineItem:
"""A line item in the report."""
class ReportEntry:
"""An entry in the report."""
def __init__(self, line_item: JournalEntryLineItem):
"""Constructs the line item in the report.
def __init__(self, entry: JournalEntry):
"""Constructs the entry in the report.
:param line_item: The journal entry line item.
:param entry: The journal entry.
"""
self.line_item: JournalEntryLineItem = line_item
"""The journal entry line item."""
self.journal_entry: JournalEntry = line_item.journal_entry
self.entry: JournalEntry = entry
"""The journal entry."""
self.currency: Currency = line_item.currency
self.transaction: Transaction = entry.transaction
"""The transaction."""
self.currency: Currency = entry.currency
"""The account."""
self.account: Account = line_item.account
self.account: Account = entry.account
"""The account."""
self.description: str | None = line_item.description
"""The description."""
self.debit: Decimal | None = line_item.debit
self.summary: str | None = entry.summary
"""The summary."""
self.debit: Decimal | None = entry.debit
"""The debit amount."""
self.credit: Decimal | None = line_item.credit
self.credit: Decimal | None = entry.credit
"""The credit amount."""
self.amount: Decimal = line_item.amount
self.amount: Decimal = entry.amount
"""The amount."""
class CSVRow(BaseCSVRow):
"""A row in the CSV."""
def __init__(self, journal_entry_date: str | dt.date,
def __init__(self, txn_date: str | date,
currency: str,
account: str,
description: str | None,
summary: str | None,
debit: str | Decimal | None,
credit: str | Decimal | None,
note: str | None):
"""Constructs a row in the CSV.
:param journal_entry_date: The journal entry date.
:param currency: The currency.
:param account: The account.
:param description: The description.
:param txn_date: The transaction date.
:param summary: The summary.
:param debit: The debit amount.
:param credit: The credit amount.
:param note: The note.
"""
self.date: str | dt.date = journal_entry_date
self.date: str | date = txn_date
"""The date."""
self.currency: str = currency
"""The currency."""
self.account: str = account
"""The account."""
self.description: str | None = description
"""The description."""
self.summary: str | None = summary
"""The summary."""
self.debit: str | Decimal | None = debit
"""The debit amount."""
self.credit: str | Decimal | None = credit
@ -105,7 +102,7 @@ class CSVRow(BaseCSVRow):
:return: The values of the row.
"""
return [self.date, self.currency, self.account, self.description,
return [self.date, self.currency, self.account, self.summary,
self.debit, self.credit, self.note]
@ -113,20 +110,19 @@ class PageParams(BasePageParams):
"""The HTML page parameters."""
def __init__(self, period: Period,
pagination: Pagination[JournalEntryLineItem],
line_items: list[JournalEntryLineItem]):
pagination: Pagination[JournalEntry],
entries: list[JournalEntry]):
"""Constructs the HTML page parameters.
:param period: The period.
:param pagination: The pagination.
:param line_items: The line items.
:param entries: The journal entries.
"""
self.period: Period = period
"""The period."""
self.pagination: Pagination[JournalEntryLineItem] = pagination
self.pagination: Pagination[JournalEntry] = pagination
"""The pagination."""
self.line_items: list[JournalEntryLineItem] = line_items
"""The line items."""
self.entries: list[JournalEntry] = entries
"""The entries."""
self.period_chooser: PeriodChooser = PeriodChooser(
lambda x: journal_url(x))
"""The period chooser."""
@ -137,7 +133,7 @@ class PageParams(BasePageParams):
:return: True if there is any data, or False otherwise.
"""
return len(self.line_items) > 0
return len(self.entries) > 0
@property
def report_chooser(self) -> ReportChooser:
@ -149,20 +145,20 @@ class PageParams(BasePageParams):
period=self.period)
def get_csv_rows(line_items: list[JournalEntryLineItem]) -> list[CSVRow]:
"""Composes and returns the CSV rows from the line items.
def get_csv_rows(entries: list[JournalEntry]) -> list[CSVRow]:
"""Composes and returns the CSV rows from the report entries.
:param line_items: The line items.
:param entries: The report entries.
:return: The CSV rows.
"""
rows: list[CSVRow] = [CSVRow(gettext("Date"), gettext("Currency"),
gettext("Account"), gettext("Description"),
gettext("Account"), gettext("Summary"),
gettext("Debit"), gettext("Credit"),
gettext("Note"))]
rows.extend([CSVRow(x.journal_entry.date, x.currency.code,
str(x.account), x.description,
x.debit, x.credit, x.journal_entry.note)
for x in line_items])
rows.extend([CSVRow(x.transaction.date, x.currency.code,
str(x.account).title(), x.summary,
x.debit, x.credit, x.transaction.note)
for x in entries])
return rows
@ -176,29 +172,27 @@ class Journal(BaseReport):
"""
self.__period: Period = period
"""The period."""
self.__line_items: list[JournalEntryLineItem] \
= self.__query_line_items()
"""The line items."""
self.__entries: list[JournalEntry] = self.__query_entries()
"""The journal entries."""
def __query_line_items(self) -> list[JournalEntryLineItem]:
"""Queries and returns the line items.
def __query_entries(self) -> list[JournalEntry]:
"""Queries and returns the journal entries.
:return: The line items.
:return: The journal entries.
"""
conditions: list[sa.BinaryExpression] = []
if self.__period.start is not None:
conditions.append(JournalEntry.date >= self.__period.start)
conditions.append(Transaction.date >= self.__period.start)
if self.__period.end is not None:
conditions.append(JournalEntry.date <= self.__period.end)
return JournalEntryLineItem.query.join(JournalEntry)\
conditions.append(Transaction.date <= self.__period.end)
return JournalEntry.query.join(Transaction)\
.filter(*conditions)\
.order_by(JournalEntry.date,
JournalEntry.no,
JournalEntryLineItem.is_debit.desc(),
JournalEntryLineItem.no)\
.options(selectinload(JournalEntryLineItem.account),
selectinload(JournalEntryLineItem.currency),
selectinload(JournalEntryLineItem.journal_entry)).all()
.order_by(Transaction.date,
JournalEntry.is_debit.desc(),
JournalEntry.no)\
.options(selectinload(JournalEntry.account),
selectinload(JournalEntry.currency),
selectinload(JournalEntry.transaction)).all()
def csv(self) -> Response:
"""Returns the report as CSV for download.
@ -206,18 +200,17 @@ class Journal(BaseReport):
:return: The response of the report for download.
"""
filename: str = f"journal-{period_spec(self.__period)}.csv"
return csv_download(filename, get_csv_rows(self.__line_items))
return csv_download(filename, get_csv_rows(self.__entries))
def html(self) -> str:
"""Composes and returns the report as HTML.
:return: The report as HTML.
"""
pagination: Pagination[JournalEntryLineItem] \
= Pagination[JournalEntryLineItem](self.__line_items,
is_reversed=True)
pagination: Pagination[JournalEntry] \
= Pagination[JournalEntry](self.__entries)
params: PageParams = PageParams(period=self.__period,
pagination=pagination,
line_items=pagination.list)
entries=pagination.list)
return render_template("accounting/report/journal.html",
report=params)

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Project.
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7
# Copyright (c) 2023 imacat.
@ -17,7 +17,7 @@
"""The ledger.
"""
import datetime as dt
from datetime import date
from decimal import Decimal
import sqlalchemy as sa
@ -26,8 +26,7 @@ from sqlalchemy.orm import selectinload
from accounting import db
from accounting.locale import gettext
from accounting.models import Currency, Account, JournalEntry, \
JournalEntryLineItem
from accounting.models import Currency, Account, Transaction, JournalEntry
from accounting.report.period import Period, PeriodChooser
from accounting.report.utils.base_page_params import BasePageParams
from accounting.report.utils.base_report import BaseReport
@ -40,22 +39,22 @@ from accounting.report.utils.urls import ledger_url
from accounting.utils.pagination import Pagination
class ReportLineItem:
"""A line item in the report."""
class ReportEntry:
"""An entry in the report."""
def __init__(self, line_item: JournalEntryLineItem | None = None):
"""Constructs the line item in the report.
def __init__(self, entry: JournalEntry | None = None):
"""Constructs the entry in the report.
:param line_item: The journal entry line item.
:param entry: The journal entry.
"""
self.is_brought_forward: bool = False
"""Whether this is the brought-forward line item."""
"""Whether this is the brought-forward entry."""
self.is_total: bool = False
"""Whether this is the total line item."""
self.date: dt.date | None = None
"""Whether this is the total entry."""
self.date: date | None = None
"""The date."""
self.description: str | None = None
"""The description."""
self.summary: str | None = None
"""The summary."""
self.debit: Decimal | None = None
"""The debit amount."""
self.credit: Decimal | None = None
@ -65,22 +64,22 @@ class ReportLineItem:
self.note: str | None = None
"""The note."""
self.url: str | None = None
"""The URL to the journal entry line item."""
if line_item is not None:
self.date = line_item.journal_entry.date
self.description = line_item.description
self.debit = line_item.amount if line_item.is_debit else None
self.credit = None if line_item.is_debit else line_item.amount
self.note = line_item.journal_entry.note
self.url = url_for("accounting.journal-entry.detail",
journal_entry=line_item.journal_entry)
"""The URL to the journal entry."""
if entry is not None:
self.date = entry.transaction.date
self.summary = entry.summary
self.debit = entry.amount if entry.is_debit else None
self.credit = None if entry.is_debit else entry.amount
self.note = entry.transaction.note
self.url = url_for("accounting.transaction.detail",
txn=entry.transaction)
class LineItemCollector:
"""The line item collector."""
class EntryCollector:
"""The report entry collector."""
def __init__(self, currency: Currency, account: Account, period: Period):
"""Constructs the line item collector.
"""Constructs the report entry collector.
:param currency: The currency.
:param account: The account.
@ -92,129 +91,123 @@ class LineItemCollector:
"""The account."""
self.__period: Period = period
"""The period"""
self.brought_forward: ReportLineItem | None
"""The brought-forward line item."""
self.line_items: list[ReportLineItem]
"""The line items."""
self.total: ReportLineItem | None
"""The total line item."""
self.brought_forward = self.__get_brought_forward()
self.line_items = self.__query_line_items()
self.total = self.__get_total()
self.brought_forward: ReportEntry | None
"""The brought-forward entry."""
self.entries: list[ReportEntry]
"""The report entries."""
self.total: ReportEntry | None
"""The total entry."""
self.brought_forward = self.__get_brought_forward_entry()
self.entries = self.__query_entries()
self.total = self.__get_total_entry()
self.__populate_balance()
def __get_brought_forward(self) -> ReportLineItem | None:
"""Queries, composes and returns the brought-forward line item.
def __get_brought_forward_entry(self) -> ReportEntry | None:
"""Queries, composes and returns the brought-forward entry.
:return: The brought-forward line item, or None if the report starts
from the beginning.
:return: The brought-forward entry, or None if the report starts from
the beginning.
"""
if self.__period.start is None:
return None
if self.__account.is_nominal:
if self.__account.base_code[0] not in {"1", "2", "3"}:
return None
balance_func: sa.Function = sa.func.sum(sa.case(
(JournalEntryLineItem.is_debit, JournalEntryLineItem.amount),
else_=-JournalEntryLineItem.amount))
select: sa.Select = sa.Select(balance_func).join(JournalEntry)\
.filter(JournalEntryLineItem.currency_code == self.__currency.code,
JournalEntryLineItem.account_id == self.__account.id,
JournalEntry.date < self.__period.start)
(JournalEntry.is_debit, JournalEntry.amount),
else_=-JournalEntry.amount))
select: sa.Select = sa.Select(balance_func).join(Transaction)\
.filter(JournalEntry.currency_code == self.__currency.code,
JournalEntry.account_id == self.__account.id,
Transaction.date < self.__period.start)
balance: int | None = db.session.scalar(select)
if balance is None:
return None
line_item: ReportLineItem = ReportLineItem()
line_item.is_brought_forward = True
line_item.date = self.__period.start
line_item.description = gettext("Brought forward")
entry: ReportEntry = ReportEntry()
entry.is_brought_forward = True
entry.date = self.__period.start
entry.summary = gettext("Brought forward")
if balance > 0:
line_item.debit = balance
entry.debit = balance
elif balance < 0:
line_item.credit = -balance
line_item.balance = balance
return line_item
entry.credit = -balance
entry.balance = balance
return entry
def __query_line_items(self) -> list[ReportLineItem]:
"""Queries and returns the line items.
def __query_entries(self) -> list[ReportEntry]:
"""Queries and returns the report entries.
:return: The line items.
:return: The report entries.
"""
conditions: list[sa.BinaryExpression] \
= [JournalEntryLineItem.currency_code == self.__currency.code,
JournalEntryLineItem.account_id == self.__account.id]
= [JournalEntry.currency_code == self.__currency.code,
JournalEntry.account_id == self.__account.id]
if self.__period.start is not None:
conditions.append(JournalEntry.date >= self.__period.start)
conditions.append(Transaction.date >= self.__period.start)
if self.__period.end is not None:
conditions.append(JournalEntry.date <= self.__period.end)
return [ReportLineItem(x) for x in JournalEntryLineItem.query
.join(JournalEntry)
conditions.append(Transaction.date <= self.__period.end)
return [ReportEntry(x) for x in JournalEntry.query.join(Transaction)
.filter(*conditions)
.order_by(JournalEntry.date,
JournalEntry.no,
JournalEntryLineItem.is_debit.desc(),
JournalEntryLineItem.no)
.options(selectinload(JournalEntryLineItem.journal_entry))
.all()]
.order_by(Transaction.date,
JournalEntry.is_debit.desc(),
JournalEntry.no)
.options(selectinload(JournalEntry.transaction)).all()]
def __get_total(self) -> ReportLineItem | None:
"""Composes the total line item.
def __get_total_entry(self) -> ReportEntry | None:
"""Composes the total entry.
:return: The total line item, or None if there is no data.
:return: The total entry, or None if there is no data.
"""
if self.brought_forward is None and len(self.line_items) == 0:
if self.brought_forward is None and len(self.entries) == 0:
return None
line_item: ReportLineItem = ReportLineItem()
line_item.is_total = True
line_item.description = gettext("Total")
line_item.debit = sum([x.debit for x in self.line_items
entry: ReportEntry = ReportEntry()
entry.is_total = True
entry.summary = gettext("Total")
entry.debit = sum([x.debit for x in self.entries
if x.debit is not None])
line_item.credit = sum([x.credit for x in self.line_items
entry.credit = sum([x.credit for x in self.entries
if x.credit is not None])
line_item.balance = line_item.debit - line_item.credit
entry.balance = entry.debit - entry.credit
if self.brought_forward is not None:
line_item.balance \
= self.brought_forward.balance + line_item.balance
return line_item
entry.balance = self.brought_forward.balance + entry.balance
return entry
def __populate_balance(self) -> None:
"""Populates the balance of the line items.
"""Populates the balance of the entries.
:return: None.
"""
if self.__account.is_nominal:
return None
balance: Decimal = 0 if self.brought_forward is None \
else self.brought_forward.balance
for line_item in self.line_items:
if line_item.debit is not None:
balance = balance + line_item.debit
if line_item.credit is not None:
balance = balance - line_item.credit
line_item.balance = balance
for entry in self.entries:
if entry.debit is not None:
balance = balance + entry.debit
if entry.credit is not None:
balance = balance - entry.credit
entry.balance = balance
class CSVRow(BaseCSVRow):
"""A row in the CSV."""
def __init__(self, date: dt.date | str | None,
description: str | None,
def __init__(self, txn_date: date | str | None,
summary: str | None,
debit: str | Decimal | None,
credit: str | Decimal | None,
balance: str | Decimal | None,
note: str | None):
"""Constructs a row in the CSV.
:param date: The journal entry date.
:param description: The description.
:param txn_date: The transaction date.
:param summary: The summary.
:param debit: The debit amount.
:param credit: The credit amount.
:param balance: The balance.
:param note: The note.
"""
self.date: dt.date | str | None = date
self.date: date | str | None = txn_date
"""The date."""
self.description: str | None = description
"""The description."""
self.summary: str | None = summary
"""The summary."""
self.debit: str | Decimal | None = debit
"""The debit amount."""
self.credit: str | Decimal | None = credit
@ -230,7 +223,7 @@ class CSVRow(BaseCSVRow):
:return: The values of the row.
"""
return [self.date, self.description,
return [self.date, self.summary,
self.debit, self.credit, self.balance, self.note]
@ -241,19 +234,19 @@ class PageParams(BasePageParams):
account: Account,
period: Period,
has_data: bool,
pagination: Pagination[ReportLineItem],
brought_forward: ReportLineItem | None,
line_items: list[ReportLineItem],
total: ReportLineItem | None):
pagination: Pagination[ReportEntry],
brought_forward: ReportEntry | None,
entries: list[ReportEntry],
total: ReportEntry | None):
"""Constructs the HTML page parameters.
:param currency: The currency.
:param account: The account.
:param period: The period.
:param has_data: True if there is any data, or False otherwise.
:param brought_forward: The brought-forward line item.
:param line_items: The line items.
:param total: The total line item.
:param brought_forward: The brought-forward entry.
:param entries: The report entries.
:param total: The total entry.
"""
self.currency: Currency = currency
"""The currency."""
@ -263,14 +256,14 @@ class PageParams(BasePageParams):
"""The period."""
self.__has_data: bool = has_data
"""True if there is any data, or False otherwise."""
self.pagination: Pagination[ReportLineItem] = pagination
self.pagination: Pagination[ReportEntry] = pagination
"""The pagination."""
self.brought_forward: ReportLineItem | None = brought_forward
"""The brought-forward line item."""
self.line_items: list[ReportLineItem] = line_items
"""The line items."""
self.total: ReportLineItem | None = total
"""The total line item."""
self.brought_forward: ReportEntry | None = brought_forward
"""The brought-forward entry."""
self.entries: list[ReportEntry] = entries
"""The entries."""
self.total: ReportEntry | None = total
"""The total entry."""
self.period_chooser: PeriodChooser = PeriodChooser(
lambda x: ledger_url(currency, account, x))
"""The period chooser."""
@ -309,9 +302,9 @@ class PageParams(BasePageParams):
:return: The account options.
"""
in_use: sa.Select = sa.Select(JournalEntryLineItem.account_id)\
.filter(JournalEntryLineItem.currency_code == self.currency.code)\
.group_by(JournalEntryLineItem.account_id)
in_use: sa.Select = sa.Select(JournalEntry.account_id)\
.filter(JournalEntry.currency_code == self.currency.code)\
.group_by(JournalEntry.account_id)
return [OptionLink(str(x), ledger_url(self.currency, x, self.period),
x.id == self.account.id)
for x in Account.query.filter(Account.id.in_(in_use))
@ -334,15 +327,14 @@ class Ledger(BaseReport):
"""The account."""
self.__period: Period = period
"""The period."""
collector: LineItemCollector = LineItemCollector(
collector: EntryCollector = EntryCollector(
self.__currency, self.__account, self.__period)
self.__brought_forward: ReportLineItem | None \
= collector.brought_forward
"""The brought-forward line item."""
self.__line_items: list[ReportLineItem] = collector.line_items
"""The line items."""
self.__total: ReportLineItem | None = collector.total
"""The total line item."""
self.__brought_forward: ReportEntry | None = collector.brought_forward
"""The brought-forward entry."""
self.__entries: list[ReportEntry] = collector.entries
"""The report entries."""
self.__total: ReportEntry | None = collector.total
"""The total entry."""
def csv(self) -> Response:
"""Returns the report as CSV for download.
@ -359,19 +351,19 @@ class Ledger(BaseReport):
:return: The CSV rows.
"""
rows: list[CSVRow] = [CSVRow(gettext("Date"), gettext("Description"),
rows: list[CSVRow] = [CSVRow(gettext("Date"), gettext("Summary"),
gettext("Debit"), gettext("Credit"),
gettext("Balance"), gettext("Note"))]
if self.__brought_forward is not None:
rows.append(CSVRow(self.__brought_forward.date,
self.__brought_forward.description,
self.__brought_forward.summary,
self.__brought_forward.debit,
self.__brought_forward.credit,
self.__brought_forward.balance,
None))
rows.extend([CSVRow(x.date, x.description,
rows.extend([CSVRow(x.date, x.summary,
x.debit, x.credit, x.balance, x.note)
for x in self.__line_items])
for x in self.__entries])
if self.__total is not None:
rows.append(CSVRow(gettext("Total"), None,
self.__total.debit, self.__total.credit,
@ -383,31 +375,31 @@ class Ledger(BaseReport):
:return: The report as HTML.
"""
all_line_items: list[ReportLineItem] = []
all_entries: list[ReportEntry] = []
if self.__brought_forward is not None:
all_line_items.append(self.__brought_forward)
all_line_items.extend(self.__line_items)
all_entries.append(self.__brought_forward)
all_entries.extend(self.__entries)
if self.__total is not None:
all_line_items.append(self.__total)
pagination: Pagination[ReportLineItem] \
= Pagination[ReportLineItem](all_line_items, is_reversed=True)
page_line_items: list[ReportLineItem] = pagination.list
has_data: bool = len(page_line_items) > 0
brought_forward: ReportLineItem | None = None
if len(page_line_items) > 0 and page_line_items[0].is_brought_forward:
brought_forward = page_line_items[0]
page_line_items = page_line_items[1:]
total: ReportLineItem | None = None
if len(page_line_items) > 0 and page_line_items[-1].is_total:
total = page_line_items[-1]
page_line_items = page_line_items[:-1]
all_entries.append(self.__total)
pagination: Pagination[ReportEntry] \
= Pagination[ReportEntry](all_entries)
page_entries: list[ReportEntry] = pagination.list
has_data: bool = len(page_entries) > 0
brought_forward: ReportEntry | None = None
if len(page_entries) > 0 and page_entries[0].is_brought_forward:
brought_forward = page_entries[0]
page_entries = page_entries[1:]
total: ReportEntry | None = None
if len(page_entries) > 0 and page_entries[-1].is_total:
total = page_entries[-1]
page_entries = page_entries[:-1]
params: PageParams = PageParams(currency=self.__currency,
account=self.__account,
period=self.__period,
has_data=has_data,
pagination=pagination,
brought_forward=brought_forward,
line_items=page_line_items,
entries=page_entries,
total=total)
return render_template("accounting/report/ledger.html",
report=params)

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Project.
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/8
# Copyright (c) 2023 imacat.
@ -17,7 +17,7 @@
"""The search.
"""
import datetime as dt
from datetime import datetime
from decimal import Decimal
import sqlalchemy as sa
@ -26,7 +26,7 @@ from sqlalchemy.orm import selectinload
from accounting.locale import gettext
from accounting.models import Currency, CurrencyL10n, Account, AccountL10n, \
JournalEntry, JournalEntryLineItem
Transaction, JournalEntry
from accounting.report.utils.base_page_params import BasePageParams
from accounting.report.utils.base_report import BaseReport
from accounting.report.utils.csv_export import csv_download
@ -37,18 +37,18 @@ from accounting.utils.query import parse_query_keywords
from .journal import get_csv_rows
class LineItemCollector:
"""The line item collector."""
class EntryCollector:
"""The report entry collector."""
def __init__(self):
"""Constructs the line item collector."""
self.line_items: list[JournalEntryLineItem] = self.__query_line_items()
"""The line items."""
"""Constructs the report entry collector."""
self.entries: list[JournalEntry] = self.__query_entries()
"""The report entries."""
def __query_line_items(self) -> list[JournalEntryLineItem]:
"""Queries and returns the line items.
def __query_entries(self) -> list[JournalEntry]:
"""Queries and returns the journal entries.
:return: The line items.
:return: The journal entries.
"""
keywords: list[str] = parse_query_keywords(request.args.get("q"))
if len(keywords) == 0:
@ -56,28 +56,22 @@ class LineItemCollector:
conditions: list[sa.BinaryExpression] = []
for k in keywords:
sub_conditions: list[sa.BinaryExpression] \
= [JournalEntryLineItem.description.icontains(k),
JournalEntryLineItem.account_id.in_(
= [JournalEntry.summary.contains(k),
JournalEntry.account_id.in_(
self.__get_account_condition(k)),
JournalEntryLineItem.currency_code.in_(
JournalEntry.currency_code.in_(
self.__get_currency_condition(k)),
JournalEntryLineItem.journal_entry_id.in_(
self.__get_journal_entry_condition(k))]
JournalEntry.transaction_id.in_(
self.__get_transaction_condition(k))]
try:
sub_conditions.append(
JournalEntryLineItem.amount == Decimal(k))
sub_conditions.append(JournalEntry.amount == Decimal(k))
except ArithmeticError:
pass
conditions.append(sa.or_(*sub_conditions))
return JournalEntryLineItem.query.join(JournalEntry)\
.filter(*conditions)\
.order_by(JournalEntry.date,
JournalEntry.no,
JournalEntryLineItem.is_debit,
JournalEntryLineItem.no)\
.options(selectinload(JournalEntryLineItem.account),
selectinload(JournalEntryLineItem.currency),
selectinload(JournalEntryLineItem.journal_entry)).all()
return JournalEntry.query.filter(*conditions)\
.options(selectinload(JournalEntry.account),
selectinload(JournalEntry.currency),
selectinload(JournalEntry.transaction)).all()
@staticmethod
def __get_account_condition(k: str) -> sa.Select:
@ -91,14 +85,14 @@ class LineItemCollector:
sa.func.char_length(sa.cast(Account.no,
sa.String)) + 1)
select_l10n: sa.Select = sa.select(AccountL10n.account_id)\
.filter(AccountL10n.title.icontains(k))
.filter(AccountL10n.title.contains(k))
conditions: list[sa.BinaryExpression] \
= [Account.base_code.contains(k),
Account.title_l10n.icontains(k),
Account.title_l10n.contains(k),
code.contains(k),
Account.id.in_(select_l10n)]
if k in gettext("Needs Offset"):
conditions.append(Account.is_need_offset)
if k in gettext("Need offset"):
conditions.append(Account.is_offset_needed)
return sa.select(Account.id).filter(sa.or_(*conditions))
@staticmethod
@ -109,66 +103,57 @@ class LineItemCollector:
:return: The condition to filter the currency.
"""
select_l10n: sa.Select = sa.select(CurrencyL10n.currency_code)\
.filter(CurrencyL10n.name.icontains(k))
.filter(CurrencyL10n.name.contains(k))
return sa.select(Currency.code).filter(
sa.or_(Currency.code.icontains(k),
Currency.name_l10n.icontains(k),
sa.or_(Currency.code.contains(k),
Currency.name_l10n.contains(k),
Currency.code.in_(select_l10n)))
@staticmethod
def __get_journal_entry_condition(k: str) -> sa.Select:
"""Composes and returns the condition to filter the journal entry.
def __get_transaction_condition(k: str) -> sa.Select:
"""Composes and returns the condition to filter the transaction.
:param k: The keyword.
:return: The condition to filter the journal entry.
:return: The condition to filter the transaction.
"""
conditions: list[sa.BinaryExpression] \
= [JournalEntry.note.icontains(k)]
date: dt.datetime
conditions: list[sa.BinaryExpression] = [Transaction.note.contains(k)]
txn_date: datetime
try:
date = dt.datetime.strptime(k, "%Y")
txn_date = datetime.strptime(k, "%Y")
conditions.append(
sa.extract("year", JournalEntry.date) == date.year)
sa.extract("year", Transaction.date) == txn_date.year)
except ValueError:
pass
try:
date = dt.datetime.strptime(k, "%Y/%m")
txn_date = datetime.strptime(k, "%Y/%m")
conditions.append(sa.and_(
sa.extract("year", JournalEntry.date) == date.year,
sa.extract("month", JournalEntry.date) == date.month))
sa.extract("year", Transaction.date) == txn_date.year,
sa.extract("month", Transaction.date) == txn_date.month))
except ValueError:
pass
try:
date = dt.datetime.strptime(f"2000/{k}", "%Y/%m/%d")
txn_date = datetime.strptime(f"2000/{k}", "%Y/%m/%d")
conditions.append(sa.and_(
sa.extract("month", JournalEntry.date) == date.month,
sa.extract("day", JournalEntry.date) == date.day))
sa.extract("month", Transaction.date) == txn_date.month,
sa.extract("day", Transaction.date) == txn_date.day))
except ValueError:
pass
try:
date = dt.datetime.strptime(k, "%Y/%m/%d")
conditions.append(sa.and_(
sa.extract("year", JournalEntry.date) == date.year,
sa.extract("month", JournalEntry.date) == date.month,
sa.extract("day", JournalEntry.date) == date.day))
except ValueError:
pass
return sa.select(JournalEntry.id).filter(sa.or_(*conditions))
return sa.select(Transaction.id).filter(sa.or_(*conditions))
class PageParams(BasePageParams):
"""The HTML page parameters."""
def __init__(self, pagination: Pagination[JournalEntryLineItem],
line_items: list[JournalEntryLineItem]):
def __init__(self, pagination: Pagination[JournalEntry],
entries: list[JournalEntry]):
"""Constructs the HTML page parameters.
:param line_items: The search result line items.
:param entries: The search result entries.
"""
self.pagination: Pagination[JournalEntryLineItem] = pagination
self.pagination: Pagination[JournalEntry] = pagination
"""The pagination."""
self.line_items: list[JournalEntryLineItem] = line_items
"""The line items."""
self.entries: list[JournalEntry] = entries
"""The entries."""
@property
def has_data(self) -> bool:
@ -176,7 +161,7 @@ class PageParams(BasePageParams):
:return: True if there is any data, or False otherwise.
"""
return len(self.line_items) > 0
return len(self.entries) > 0
@property
def report_chooser(self) -> ReportChooser:
@ -192,9 +177,8 @@ class Search(BaseReport):
def __init__(self):
"""Constructs a search."""
self.__line_items: list[JournalEntryLineItem] \
= LineItemCollector().line_items
"""The line items."""
self.__entries: list[JournalEntry] = EntryCollector().entries
"""The journal entries."""
def csv(self) -> Response:
"""Returns the report as CSV for download.
@ -202,17 +186,16 @@ class Search(BaseReport):
:return: The response of the report for download.
"""
filename: str = "search-{q}.csv".format(q=request.args["q"])
return csv_download(filename, get_csv_rows(self.__line_items))
return csv_download(filename, get_csv_rows(self.__entries))
def html(self) -> str:
"""Composes and returns the report as HTML.
:return: The report as HTML.
"""
pagination: Pagination[JournalEntryLineItem] \
= Pagination[JournalEntryLineItem](self.__line_items,
is_reversed=True)
pagination: Pagination[JournalEntry] \
= Pagination[JournalEntry](self.__entries)
params: PageParams = PageParams(pagination=pagination,
line_items=pagination.list)
entries=pagination.list)
return render_template("accounting/report/search.html",
report=params)

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Project.
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7
# Copyright (c) 2023 imacat.
@ -24,8 +24,7 @@ from flask import Response, render_template
from accounting import db
from accounting.locale import gettext
from accounting.models import Currency, Account, JournalEntry, \
JournalEntryLineItem
from accounting.models import Currency, Account, Transaction, JournalEntry
from accounting.report.period import Period, PeriodChooser
from accounting.report.utils.base_page_params import BasePageParams
from accounting.report.utils.base_report import BaseReport
@ -179,19 +178,18 @@ class TrialBalance(BaseReport):
:return: None.
"""
conditions: list[sa.BinaryExpression] \
= [JournalEntryLineItem.currency_code == self.__currency.code]
= [JournalEntry.currency_code == self.__currency.code]
if self.__period.start is not None:
conditions.append(JournalEntry.date >= self.__period.start)
conditions.append(Transaction.date >= self.__period.start)
if self.__period.end is not None:
conditions.append(JournalEntry.date <= self.__period.end)
conditions.append(Transaction.date <= self.__period.end)
balance_func: sa.Function = sa.func.sum(sa.case(
(JournalEntryLineItem.is_debit, JournalEntryLineItem.amount),
else_=-JournalEntryLineItem.amount)).label("balance")
(JournalEntry.is_debit, JournalEntry.amount),
else_=-JournalEntry.amount)).label("balance")
select_balances: sa.Select = sa.select(Account.id, balance_func)\
.join(JournalEntry).join(Account)\
.join(Transaction).join(Account)\
.filter(*conditions)\
.group_by(Account.id)\
.having(balance_func != 0)\
.order_by(Account.base_code, Account.no)
balances: list[sa.Row] = db.session.execute(select_balances).all()
accounts: dict[int, Account] \
@ -224,7 +222,7 @@ class TrialBalance(BaseReport):
"""
rows: list[CSVRow] = [CSVRow(gettext("Account"), gettext("Debit"),
gettext("Credit"))]
rows.extend([CSVRow(str(x.account), x.debit, x.credit)
rows.extend([CSVRow(str(x.account).title(), x.debit, x.credit)
for x in self.__accounts])
rows.append(CSVRow(gettext("Total"), self.__total.debit,
self.__total.credit))

View File

@ -1,216 +0,0 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/7
# Copyright (c) 2023 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""The unapplied original line items.
"""
import datetime as dt
from decimal import Decimal
from flask import render_template, Response
from sqlalchemy.orm import selectinload
from accounting.locale import gettext
from accounting.models import Currency, Account, JournalEntry, \
JournalEntryLineItem
from accounting.report.utils.base_page_params import BasePageParams
from accounting.report.utils.base_report import BaseReport
from accounting.report.utils.csv_export import BaseCSVRow, csv_download
from accounting.report.utils.option_link import OptionLink
from accounting.report.utils.report_chooser import ReportChooser
from accounting.report.utils.report_type import ReportType
from accounting.report.utils.unapplied import get_accounts_with_unapplied, \
get_net_balances
from accounting.report.utils.urls import unapplied_url
from accounting.utils.pagination import Pagination
class CSVRow(BaseCSVRow):
"""A row in the CSV."""
def __init__(self, journal_entry_date: str | dt.date, currency: str,
description: str | None, amount: str | Decimal,
net_balance: str | Decimal):
"""Constructs a row in the CSV.
:param journal_entry_date: The journal entry date.
:param currency: The currency.
:param description: The description.
:param amount: The amount.
:param net_balance: The net balance.
"""
self.date: str | dt.date = journal_entry_date
"""The date."""
self.currency: str = currency
"""The currency."""
self.description: str | None = description
"""The description."""
self.amount: str | Decimal = amount
"""The amount."""
self.net_balance: str | Decimal = net_balance
"""The net balance."""
@property
def values(self) -> list[str | dt.date | Decimal | None]:
"""Returns the values of the row.
:return: The values of the row.
"""
return [self.date, self.currency, self.description, self.amount,
self.net_balance]
class PageParams(BasePageParams):
"""The HTML page parameters."""
def __init__(self, currency: Currency,
account: Account,
pagination: Pagination[JournalEntryLineItem],
line_items: list[JournalEntryLineItem]):
"""Constructs the HTML page parameters.
:param currency: The currency.
:param account: The account.
:param pagination: The pagination.
:param line_items: The line items.
"""
self.currency: Currency = currency
"""The currency."""
self.account: Account = account
"""The account."""
self.pagination: Pagination[JournalEntryLineItem] = pagination
"""The pagination."""
self.line_items: list[JournalEntryLineItem] = line_items
"""The line items."""
@property
def has_data(self) -> bool:
"""Returns whether there is any data on the page.
:return: True if there is any data, or False otherwise.
"""
return len(self.line_items) > 0
@property
def report_chooser(self) -> ReportChooser:
"""Returns the report chooser.
:return: The report chooser.
"""
return ReportChooser(ReportType.UNAPPLIED, currency=self.currency,
account=self.account)
@property
def currency_options(self) -> list[OptionLink]:
"""Returns the currency options.
:return: The currency options.
"""
return self._get_currency_options(
lambda x: unapplied_url(x, self.account), self.currency)
@property
def account_options(self) -> list[OptionLink]:
"""Returns the account options.
:return: The account options.
"""
options: list[OptionLink] \
= [OptionLink(gettext("Accounts"),
unapplied_url(self.currency, None),
False)]
options.extend(
[OptionLink(str(x), unapplied_url(self.currency, x),
x.id == self.account.id)
for x in get_accounts_with_unapplied(self.currency)])
return options
def get_csv_rows(line_items: list[JournalEntryLineItem]) -> list[CSVRow]:
"""Composes and returns the CSV rows from the line items.
:param line_items: The line items.
:return: The CSV rows.
"""
rows: list[CSVRow] = [CSVRow(gettext("Date"), gettext("Currency"),
gettext("Description"), gettext("Amount"),
gettext("Net Balance"))]
rows.extend([CSVRow(x.journal_entry.date, x.currency.code,
x.description, x.amount, x.net_balance)
for x in line_items])
return rows
class UnappliedOriginalLineItems(BaseReport):
"""The unapplied original line items."""
def __init__(self, currency: Currency, account: Account):
"""Constructs the unapplied original line items.
:param currency: The currency.
:param account: The account.
"""
self.__currency: Currency = currency
"""The currency."""
self.__account: Account = account
"""The account."""
self.__line_items: list[JournalEntryLineItem] \
= self.__query_line_items()
"""The line items."""
def __query_line_items(self) -> list[JournalEntryLineItem]:
"""Queries and returns the line items.
:return: The line items.
"""
net_balances: dict[int, Decimal | None] \
= get_net_balances(self.__currency, self.__account)
line_items: list[JournalEntryLineItem] = JournalEntryLineItem.query \
.join(Account).join(JournalEntry) \
.filter(JournalEntryLineItem.id.in_(net_balances)) \
.order_by(JournalEntry.date, JournalEntry.no,
JournalEntryLineItem.is_debit, JournalEntryLineItem.no) \
.options(selectinload(JournalEntryLineItem.currency),
selectinload(JournalEntryLineItem.journal_entry)).all()
for line_item in line_items:
line_item.net_balance = line_item.amount \
if net_balances[line_item.id] is None \
else net_balances[line_item.id]
return line_items
def csv(self) -> Response:
"""Returns the report as CSV for download.
:return: The response of the report for download.
"""
filename: str = "unapplied-{currency}-{account}.csv"\
.format(currency=self.__currency.code, account=self.__account.code)
return csv_download(filename, get_csv_rows(self.__line_items))
def html(self) -> str:
"""Composes and returns the report as HTML.
:return: The report as HTML.
"""
pagination: Pagination[JournalEntryLineItem] \
= Pagination[JournalEntryLineItem](self.__line_items,
is_reversed=True)
params: PageParams = PageParams(currency=self.__currency,
account=self.__account,
pagination=pagination,
line_items=pagination.list)
return render_template("accounting/report/unapplied.html",
report=params)

View File

@ -1,155 +0,0 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/7
# Copyright (c) 2023 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""The accounts with unapplied original line items.
"""
import datetime as dt
from decimal import Decimal
from flask import render_template, Response
from accounting.locale import gettext
from accounting.models import Currency, Account
from accounting.report.utils.base_page_params import BasePageParams
from accounting.report.utils.base_report import BaseReport
from accounting.report.utils.csv_export import BaseCSVRow, csv_download
from accounting.report.utils.option_link import OptionLink
from accounting.report.utils.report_chooser import ReportChooser
from accounting.report.utils.report_type import ReportType
from accounting.report.utils.unapplied import get_accounts_with_unapplied
from accounting.report.utils.urls import unapplied_url
class CSVRow(BaseCSVRow):
"""A row in the CSV."""
def __init__(self, account: str, count: int | str):
"""Constructs a row in the CSV.
:param account: The account.
:param count: The number of unapplied original line items.
"""
self.account: str = account
"""The currency."""
self.count: int | str = count
"""The number of unapplied original line items."""
@property
def values(self) -> list[str | dt.date | Decimal | None]:
"""Returns the values of the row.
:return: The values of the row.
"""
return [self.account, self.count]
class PageParams(BasePageParams):
"""The HTML page parameters."""
def __init__(self, currency: Currency, accounts: list[Account]):
"""Constructs the HTML page parameters.
:param currency: The currency.
:param accounts: The accounts.
"""
self.currency: Currency = currency
"""The currency."""
self.accounts: list[Account] = accounts
"""The accounts."""
@property
def has_data(self) -> bool:
"""Returns whether there is any data on the page.
:return: True if there is any data, or False otherwise.
"""
return len(self.accounts) > 0
@property
def report_chooser(self) -> ReportChooser:
"""Returns the report chooser.
:return: The report chooser.
"""
return ReportChooser(ReportType.UNAPPLIED, currency=self.currency,
account=None)
@property
def currency_options(self) -> list[OptionLink]:
"""Returns the currency options.
:return: The currency options.
"""
return self._get_currency_options(lambda x: unapplied_url(x, None),
self.currency)
@property
def account_options(self) -> list[OptionLink]:
"""Returns the account options.
:return: The account options.
"""
options: list[OptionLink] \
= [OptionLink(gettext("Accounts"),
unapplied_url(self.currency, None),
True)]
options.extend(
[OptionLink(str(x), unapplied_url(self.currency, x), False)
for x in self.accounts])
return options
def get_csv_rows(accounts: list[Account]) -> list[CSVRow]:
"""Composes and returns the CSV rows from the line items.
:param accounts: The accounts.
:return: The CSV rows.
"""
rows: list[CSVRow] = [CSVRow(gettext("Account"), gettext("Count"))]
rows.extend([CSVRow(str(x), x.count) for x in accounts])
return rows
class AccountsWithUnappliedOriginalLineItems(BaseReport):
"""The accounts with unapplied original line items."""
def __init__(self, currency: Currency):
"""Constructs the outstanding balances.
:param currency: The currency.
"""
self.__currency: Currency = currency
"""The currency."""
self.__accounts: list[Account] = get_accounts_with_unapplied(currency)
"""The accounts."""
def csv(self) -> Response:
"""Returns the report as CSV for download.
:return: The response of the report for download.
"""
filename: str = "unapplied-accounts.csv"
return csv_download(filename, get_csv_rows(self.__accounts))
def html(self) -> str:
"""Composes and returns the report as HTML.
:return: The report as HTML.
"""
return render_template("accounting/report/unapplied-accounts.html",
report=PageParams(currency=self.__currency,
accounts=self.__accounts))

View File

@ -1,214 +0,0 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/17
# Copyright (c) 2023 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""The unmatched offsets.
"""
import datetime as dt
from decimal import Decimal
from flask import render_template, Response
from flask_babel import LazyString
from accounting.locale import gettext
from accounting.models import Currency, Account, JournalEntryLineItem
from accounting.report.utils.base_page_params import BasePageParams
from accounting.report.utils.base_report import BaseReport
from accounting.report.utils.csv_export import BaseCSVRow, csv_download
from accounting.report.utils.offset_matcher import OffsetMatcher, OffsetPair
from accounting.report.utils.option_link import OptionLink
from accounting.report.utils.report_chooser import ReportChooser
from accounting.report.utils.report_type import ReportType
from accounting.report.utils.unmatched import get_accounts_with_unmatched
from accounting.report.utils.urls import unmatched_url
from accounting.utils.pagination import Pagination
class CSVRow(BaseCSVRow):
"""A row in the CSV."""
def __init__(self, journal_entry_date: str | dt.date, currency: str,
description: str | None, debit: str | Decimal,
credit: str | Decimal, balance: str | Decimal):
"""Constructs a row in the CSV.
:param journal_entry_date: The journal entry date.
:param currency: The currency.
:param description: The description.
:param debit: The debit amount.
:param credit: The credit amount.
:param balance: The balance.
"""
self.date: str | dt.date = journal_entry_date
"""The date."""
self.currency: str = currency
"""The currency."""
self.description: str | None = description
"""The description."""
self.debit: str | Decimal | None = debit
"""The debit amount."""
self.credit: str | Decimal | None = credit
"""The credit amount."""
self.balance: str | Decimal = balance
"""The balance."""
@property
def values(self) -> list[str | dt.date | Decimal | None]:
"""Returns the values of the row.
:return: The values of the row.
"""
return [self.date, self.currency, self.description, self.debit,
self.credit, self.balance]
class PageParams(BasePageParams):
"""The HTML page parameters."""
def __init__(self, currency: Currency,
account: Account,
match_status: str | LazyString,
matched_pairs: list[OffsetPair],
pagination: Pagination[JournalEntryLineItem],
line_items: list[JournalEntryLineItem]):
"""Constructs the HTML page parameters.
:param currency: The currency.
:param account: The account.
:param match_status: The match status message.
:param matched_pairs: A list of matched pairs.
:param pagination: The pagination.
:param line_items: The line items.
"""
self.currency: Currency = currency
"""The currency."""
self.account: Account = account
"""The account."""
self.match_status: str | LazyString = match_status
"""The match status message."""
self.matched_pairs: list[OffsetPair] = matched_pairs
"""A list of matched pairs."""
self.pagination: Pagination[JournalEntryLineItem] = pagination
"""The pagination."""
self.line_items: list[JournalEntryLineItem] = line_items
"""The line items."""
@property
def has_data(self) -> bool:
"""Returns whether there is any data on the page.
:return: True if there is any data, or False otherwise.
"""
return len(self.line_items) > 0
@property
def report_chooser(self) -> ReportChooser:
"""Returns the report chooser.
:return: The report chooser.
"""
return ReportChooser(ReportType.UNMATCHED, currency=self.currency,
account=self.account)
@property
def currency_options(self) -> list[OptionLink]:
"""Returns the currency options.
:return: The currency options.
"""
return self._get_currency_options(
lambda x: unmatched_url(x, self.account), self.currency)
@property
def account_options(self) -> list[OptionLink]:
"""Returns the account options.
:return: The account options.
"""
options: list[OptionLink] \
= [OptionLink(gettext("Accounts"),
unmatched_url(self.currency, None),
False)]
options.extend(
[OptionLink(str(x), unmatched_url(self.currency, x),
x.id == self.account.id)
for x in get_accounts_with_unmatched(self.currency)])
return options
def get_csv_rows(line_items: list[JournalEntryLineItem]) -> list[CSVRow]:
"""Composes and returns the CSV rows from the line items.
:param line_items: The line items.
:return: The CSV rows.
"""
rows: list[CSVRow] = [CSVRow(gettext("Date"), gettext("Currency"),
gettext("Description"), gettext("Debit"),
gettext("Credit"), gettext("Balance"))]
rows.extend([CSVRow(x.journal_entry.date, x.currency.code,
x.description, x.debit, x.credit, x.balance)
for x in line_items])
return rows
class UnmatchedOffsets(BaseReport):
"""The unmatched offsets."""
def __init__(self, currency: Currency, account: Account):
"""Constructs the unmatched offsets.
:param currency: The currency.
:param account: The account.
"""
self.__currency: Currency = currency
"""The currency."""
self.__account: Account = account
"""The account."""
offset_matcher: OffsetMatcher \
= OffsetMatcher(self.__currency, self.__account)
self.__line_items: list[JournalEntryLineItem] \
= offset_matcher.line_items
"""The line items."""
self.__match_status: str | LazyString = offset_matcher.status
"""The match status message."""
self.__matched_pairs: list[OffsetPair] = offset_matcher.matched_pairs
"""A list of matched pairs."""
def csv(self) -> Response:
"""Returns the report as CSV for download.
:return: The response of the report for download.
"""
filename: str = "unmatched-{currency}-{account}.csv"\
.format(currency=self.__currency.code, account=self.__account.code)
return csv_download(filename, get_csv_rows(self.__line_items))
def html(self) -> str:
"""Composes and returns the report as HTML.
:return: The report as HTML.
"""
pagination: Pagination[JournalEntryLineItem] \
= Pagination[JournalEntryLineItem](self.__line_items,
is_reversed=True)
params: PageParams = PageParams(currency=self.__currency,
account=self.__account,
match_status=self.__match_status,
matched_pairs=self.__matched_pairs,
pagination=pagination,
line_items=pagination.list)
return render_template("accounting/report/unmatched.html",
report=params)

View File

@ -1,156 +0,0 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/17
# Copyright (c) 2023 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""The accounts with unmatched offsets.
"""
import datetime as dt
from decimal import Decimal
from flask import render_template, Response
from accounting.locale import gettext
from accounting.models import Currency, Account
from accounting.report.utils.base_page_params import BasePageParams
from accounting.report.utils.base_report import BaseReport
from accounting.report.utils.csv_export import BaseCSVRow, csv_download
from accounting.report.utils.option_link import OptionLink
from accounting.report.utils.report_chooser import ReportChooser
from accounting.report.utils.report_type import ReportType
from accounting.report.utils.unmatched import get_accounts_with_unmatched
from accounting.report.utils.urls import unmatched_url
class CSVRow(BaseCSVRow):
"""A row in the CSV."""
def __init__(self, account: str, count: int | str):
"""Constructs a row in the CSV.
:param account: The account.
:param count: The number of unapplied original line items.
"""
self.account: str = account
"""The currency."""
self.count: int | str = count
"""The number of unapplied original line items."""
@property
def values(self) -> list[str | dt.date | Decimal | None]:
"""Returns the values of the row.
:return: The values of the row.
"""
return [self.account, self.count]
class PageParams(BasePageParams):
"""The HTML page parameters."""
def __init__(self, currency: Currency, accounts: list[Account]):
"""Constructs the HTML page parameters.
:param currency: The currency.
:param accounts: The accounts.
"""
self.currency: Currency = currency
"""The currency."""
self.accounts: list[Account] = accounts
"""The accounts."""
@property
def has_data(self) -> bool:
"""Returns whether there is any data on the page.
:return: True if there is any data, or False otherwise.
"""
return len(self.accounts) > 0
@property
def report_chooser(self) -> ReportChooser:
"""Returns the report chooser.
:return: The report chooser.
"""
return ReportChooser(ReportType.UNMATCHED, currency=self.currency,
account=None)
@property
def currency_options(self) -> list[OptionLink]:
"""Returns the currency options.
:return: The currency options.
"""
return self._get_currency_options(lambda x: unmatched_url(x, None),
self.currency)
@property
def account_options(self) -> list[OptionLink]:
"""Returns the account options.
:return: The account options.
"""
options: list[OptionLink] \
= [OptionLink(gettext("Accounts"),
unmatched_url(self.currency, None),
True)]
options.extend(
[OptionLink(str(x), unmatched_url(self.currency, x), False)
for x in self.accounts])
return options
def get_csv_rows(accounts: list[Account]) -> list[CSVRow]:
"""Composes and returns the CSV rows from the line items.
:param accounts: The accounts.
:return: The CSV rows.
"""
rows: list[CSVRow] = [CSVRow(gettext("Account"), gettext("Count"))]
rows.extend([CSVRow(str(x), x.count) for x in accounts])
return rows
class AccountsWithUnmatchedOffsets(BaseReport):
"""The accounts with unmatched offsets."""
def __init__(self, currency: Currency):
"""Constructs the outstanding balances.
:param currency: The currency.
"""
self.__currency: Currency = currency
"""The currency."""
self.__accounts: list[Account] \
= get_accounts_with_unmatched(currency)
"""The accounts."""
def csv(self) -> Response:
"""Returns the report as CSV for download.
:return: The response of the report for download.
"""
filename: str = "unmatched-accounts.csv"
return csv_download(filename, get_csv_rows(self.__accounts))
def html(self) -> str:
"""Composes and returns the report as HTML.
:return: The report as HTML.
"""
return render_template("accounting/report/unmatched-accounts.html",
report=PageParams(currency=self.__currency,
accounts=self.__accounts))

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Project.
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7
# Copyright (c) 2023 imacat.

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Project.
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7
# Copyright (c) 2023 imacat.

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Project.
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/6
# Copyright (c) 2023 imacat.
@ -17,9 +17,8 @@
"""The page parameters of a report.
"""
import typing as t
from abc import ABC, abstractmethod
from collections.abc import Callable
from typing import Type
from urllib.parse import urlparse, ParseResult, parse_qsl, urlencode, \
urlunparse
@ -27,8 +26,8 @@ import sqlalchemy as sa
from flask import request
from accounting import db
from accounting.models import Currency, JournalEntryLineItem
from accounting.utils.journal_entry_types import JournalEntryType
from accounting.models import Currency, JournalEntry
from accounting.utils.txn_types import TransactionType
from .option_link import OptionLink
from .report_chooser import ReportChooser
@ -53,12 +52,12 @@ class BasePageParams(ABC):
"""
@property
def journal_entry_types(self) -> Type[JournalEntryType]:
"""Returns the journal entry types.
def txn_types(self) -> t.Type[TransactionType]:
"""Returns the transaction types.
:return: The journal entry types.
:return: The transaction types.
"""
return JournalEntryType
return TransactionType
@property
def csv_uri(self) -> str:
@ -73,7 +72,7 @@ class BasePageParams(ABC):
return urlunparse(parts)
@staticmethod
def _get_currency_options(get_url: Callable[[Currency], str],
def _get_currency_options(get_url: t.Callable[[Currency], str],
active_currency: Currency) -> list[OptionLink]:
"""Returns the currency options.
@ -82,8 +81,8 @@ class BasePageParams(ABC):
:return: The currency options.
"""
in_use: set[str] = set(db.session.scalars(
sa.select(JournalEntryLineItem.currency_code)
.group_by(JournalEntryLineItem.currency_code)).all())
sa.select(JournalEntry.currency_code)
.group_by(JournalEntry.currency_code)).all())
return [OptionLink(str(x), get_url(x), x.code == active_currency.code)
for x in Currency.query.filter(Currency.code.in_(in_use))
.order_by(Currency.code).all()]

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Project.
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/8
# Copyright (c) 2023 imacat.

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Project.
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7
# Copyright (c) 2023 imacat.
@ -18,11 +18,10 @@
"""
import csv
import datetime as dt
from abc import ABC, abstractmethod
from datetime import timedelta, date
from decimal import Decimal
from io import StringIO
from urllib.parse import quote
from flask import Response
@ -54,7 +53,7 @@ def csv_download(filename: str, rows: list[BaseCSVRow]) -> Response:
fp.seek(0)
response: Response = Response(fp.read(), mimetype="text/csv")
response.headers["Content-Disposition"] \
= f"attachment; filename={quote(filename)}"
= f"attachment; filename={filename}"
return response
@ -77,7 +76,7 @@ def period_spec(period: Period) -> str:
return f"{start}-{end}"
def __get_start_str(start: dt.date | None) -> str | None:
def __get_start_str(start: date | None) -> str | None:
"""Returns the string representation of the start date.
:param start: The start date.
@ -93,7 +92,7 @@ def __get_start_str(start: dt.date | None) -> str | None:
return start.strftime("%Y%m%d")
def __get_end_str(end: dt.date | None) -> str | None:
def __get_end_str(end: date | None) -> str | None:
"""Returns the string representation of the end date.
:param end: The end date.
@ -104,6 +103,6 @@ def __get_end_str(end: dt.date | None) -> str | None:
return None
if end.month == 12 and end.day == 31:
return str(end.year)
if (end + dt.timedelta(days=1)).day == 1:
if (end + timedelta(days=1)).day == 1:
return end.strftime("%Y%m")
return end.strftime("%Y%m%d")

View File

@ -1,5 +1,5 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/22
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7
# Copyright (c) 2023 imacat.
#
@ -14,29 +14,28 @@
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""The current assets and liabilities account.
"""The pseudo account for the income and expenses log.
"""
from typing import Self
import typing as t
import sqlalchemy as sa
from flask import current_app
from accounting.locale import gettext
from accounting.models import Account
class CurrentAccount:
"""A current assets and liabilities account."""
class IncomeExpensesAccount:
"""The pseudo account for the income and expenses log."""
CURRENT_AL_CODE: str = "0000-000"
"""The account code for all current assets and liabilities."""
"""The account code for the current assets and liabilities."""
def __init__(self, account: Account | None = None):
"""Constructs the current assets and liabilities account.
"""Constructs the pseudo account for the income and expenses log.
:param account: The actual account.
"""
self.account: Account | None = account
"""The actual account."""
self.id: int = -1 if account is None else account.id
"""The ID."""
self.code: str = "" if account is None else account.code
@ -54,10 +53,10 @@ class CurrentAccount:
return self.str
@classmethod
def current_assets_and_liabilities(cls) -> Self:
"""Returns the pseudo account for all current assets and liabilities.
def current_assets_and_liabilities(cls) -> t.Self:
"""Returns the pseudo account for current assets and liabilities.
:return: The pseudo account for all current assets and liabilities.
:return: The pseudo account for current assets and liabilities.
"""
account: cls = cls()
account.id = 0
@ -66,28 +65,22 @@ class CurrentAccount:
account.str = account.title
return account
@classmethod
def accounts(cls) -> list[Self]:
"""Returns the current assets and liabilities accounts.
:return: The current assets and liabilities accounts.
def default_ie_account_code() -> str:
"""Returns the default account code for the income and expenses log.
:return: The default account code for the income and expenses log.
"""
accounts: list[cls] = [cls.current_assets_and_liabilities()]
accounts.extend([CurrentAccount(x)
for x in Account.query
.filter(cls.sql_condition())
.order_by(Account.base_code, Account.no)])
return accounts
with current_app.app_context():
return current_app.config.get("DEFAULT_IE_ACCOUNT", Account.CASH_CODE)
@classmethod
def sql_condition(cls) -> sa.BinaryExpression:
"""Returns the SQL condition for the current assets and liabilities
accounts.
:return: The SQL condition for the current assets and liabilities
accounts.
def default_ie_account() -> IncomeExpensesAccount:
"""Returns the default account for the income and expenses log.
:return: The default account for the income and expenses log.
"""
return sa.or_(Account.base_code.startswith("11"),
Account.base_code.startswith("12"),
Account.base_code.startswith("21"),
Account.base_code.startswith("22"))
code: str = default_ie_account_code()
if code == IncomeExpensesAccount.CURRENT_AL_CODE:
return IncomeExpensesAccount.current_assets_and_liabilities()
return IncomeExpensesAccount(Account.find_by_code(code))

View File

@ -1,178 +0,0 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/8
# Copyright (c) 2023 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""The forms for the unmatched offset management.
"""
from decimal import Decimal
import sqlalchemy as sa
from flask_babel import LazyString
from sqlalchemy.orm import selectinload
from accounting.locale import lazy_gettext
from accounting.models import Currency, Account, JournalEntry, \
JournalEntryLineItem
from accounting.report.utils.unapplied import get_net_balances
class OffsetPair:
"""A pair of an original line item and its offset."""
def __init__(self, original_line_item: JournalEntryLineItem,
offset: JournalEntryLineItem):
"""Constructs a pair of an original line item and its offset.
:param original_line_item: The original line item.
:param offset: The offset.
"""
self.original_line_item: JournalEntryLineItem = original_line_item
"""The original line item."""
self.offset: JournalEntryLineItem = offset
"""The offset."""
class OffsetMatcher:
"""The offset matcher."""
def __init__(self, currency: Currency, account: Account):
"""Constructs the offset matcher.
:param currency: The currency.
:param account: The account.
"""
self.__currency: Account = currency
"""The currency."""
self.__account: Account = account
"""The account."""
self.matched_pairs: list[OffsetPair] = []
"""A list of matched pairs."""
self.line_items: list[JournalEntryLineItem] = []
"""The unapplied debits or credits and unmatched offsets."""
self.unapplied: list[JournalEntryLineItem] = []
"""The unapplied debits or credits."""
self.unmatched: list[JournalEntryLineItem] = []
"""The unmatched offsets."""
self.__find_matches()
def __find_matches(self) -> None:
"""Finds the matched original line items and their offsets.
:return: None.
"""
self.__get_line_items()
if len(self.unapplied) == 0 or len(self.unmatched) == 0:
return
remains: list[JournalEntryLineItem] = self.unmatched.copy()
for original_item in self.unapplied:
offset_candidates: list[JournalEntryLineItem] \
= [x for x in remains
if (x.journal_entry.date > original_item.journal_entry.date
or (x.journal_entry.date
== original_item.journal_entry.date
and x.journal_entry.no
> original_item.journal_entry.no))
and x.currency_code == original_item.currency_code
and x.description == original_item.description
and x.amount == original_item.net_balance]
if len(offset_candidates) == 0:
continue
self.matched_pairs.append(
OffsetPair(original_item, offset_candidates[0]))
original_item.match = offset_candidates[0]
offset_candidates[0].match = original_item
remains.remove(offset_candidates[0])
def __get_line_items(self) -> None:
"""Returns the unapplied original line items and unmatched offsets of
the account.
:return: The unapplied original line items and unmatched offsets of the
account.
"""
net_balances: dict[int, Decimal | None] \
= get_net_balances(self.__currency, self.__account)
unmatched_offset_condition: sa.BinaryExpression \
= sa.and_(Account.id == self.__account.id,
JournalEntryLineItem.currency_code
== self.__currency.code,
JournalEntryLineItem.original_line_item_id.is_(None),
sa.or_(sa.and_(Account.base_code.startswith("2"),
JournalEntryLineItem.is_debit),
sa.and_(Account.base_code.startswith("1"),
sa.not_(JournalEntryLineItem.is_debit))))
self.line_items = JournalEntryLineItem.query \
.join(Account).join(JournalEntry) \
.filter(sa.or_(JournalEntryLineItem.id.in_(net_balances),
unmatched_offset_condition)) \
.order_by(JournalEntry.date, JournalEntry.no,
JournalEntryLineItem.is_debit, JournalEntryLineItem.no) \
.options(selectinload(JournalEntryLineItem.currency),
selectinload(JournalEntryLineItem.journal_entry)).all()
for line_item in self.line_items:
line_item.is_offset = line_item.id not in net_balances
self.unapplied = [x for x in self.line_items if not x.is_offset]
for line_item in self.unapplied:
line_item.net_balance = line_item.amount \
if net_balances[line_item.id] is None \
else net_balances[line_item.id]
self.unmatched = [x for x in self.line_items if x.is_offset]
self.__populate_accumulated_balances()
def __populate_accumulated_balances(self) -> None:
"""Populates the accumulated balances of the line items.
:return: None.
"""
balance: Decimal = Decimal("0")
for line_item in self.line_items:
amount: Decimal = line_item.amount if line_item.is_offset \
else line_item.net_balance
if line_item.is_debit:
line_item.debit = amount
line_item.credit = None
balance = balance + amount
else:
line_item.debit = None
line_item.credit = amount
balance = balance - amount
line_item.balance = balance
@property
def status(self) -> str | LazyString:
"""Returns the match status message.
:return: The match status message.
"""
if len(self.unmatched) == 0:
return lazy_gettext("There is no unmatched offset.")
if len(self.matched_pairs) == 0:
return lazy_gettext(
"%(total)s unmatched offsets without original items.",
total=len(self.unmatched))
return lazy_gettext(
"%(matches)s unmatched offsets out of %(total)s"
" can match with their original items.",
matches=len(self.matched_pairs),
total=len(self.unmatched))
def match(self) -> None:
"""Matches the original line items with offsets.
:return: None.
"""
for pair in self.matched_pairs:
pair.offset.original_line_item_id = pair.original_line_item.id

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Project.
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/5
# Copyright (c) 2023 imacat.
@ -27,15 +27,10 @@ class OptionLink:
"""Constructs an option link.
:param title: The title.
:param url: The URL.
:param url: The URI.
:param is_active: True if active, or False otherwise
:param fa_icon: The font-awesome icon, if any.
"""
self.title: str = title
"""The title."""
self.url: str = url
"""The URL."""
self.is_active: bool = is_active
"""True if active, or False otherwise."""
self.fa_icon: str | None = fa_icon
"""The font-awesome icon, if any."""

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Project.
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4
# Copyright (c) 2023 imacat.
@ -21,7 +21,7 @@ This file is largely taken from the NanoParma ERP project, first written in
"""
import re
from collections.abc import Iterator
import typing as t
from flask_babel import LazyString
@ -30,13 +30,11 @@ from accounting.locale import gettext
from accounting.models import Currency, Account
from accounting.report.period import Period, get_period
from accounting.template_globals import default_currency_code
from accounting.utils.current_account import CurrentAccount
from accounting.utils.permission import can_edit
from .ie_account import IncomeExpensesAccount
from .option_link import OptionLink
from .report_type import ReportType
from .urls import journal_url, ledger_url, income_expenses_url, \
trial_balance_url, income_statement_url, balance_sheet_url, \
unapplied_url, unmatched_url
trial_balance_url, income_statement_url, balance_sheet_url
class ReportChooser:
@ -70,15 +68,12 @@ class ReportChooser:
"""The title of the current report."""
self.is_search: bool = active_report == ReportType.SEARCH
"""Whether the current report is the search page."""
self.__reports.append(self.__income_expenses)
self.__reports.append(self.__ledger)
self.__reports.append(self.__journal)
self.__reports.append(self.__ledger)
self.__reports.append(self.__income_expenses)
self.__reports.append(self.__trial_balance)
self.__reports.append(self.__income_statement)
self.__reports.append(self.__balance_sheet)
self.__reports.append(self.__unapplied)
if can_edit():
self.__reports.append(self.__unmatched)
for report in self.__reports:
if report.is_active:
self.current_report = report.title
@ -86,20 +81,14 @@ class ReportChooser:
self.current_report = gettext("Search")
@property
def __income_expenses(self) -> OptionLink:
"""Returns the income and expenses log.
def __journal(self) -> OptionLink:
"""Returns the journal.
:return: The income and expenses log.
:return: The journal.
"""
account: Account = self.__account
if not re.match(r"[12][12]", account.base_code):
account: Account = Account.cash()
return OptionLink(gettext("Income and Expenses Log"),
income_expenses_url(self.__currency,
CurrentAccount(account),
self.__period),
self.__active_report == ReportType.INCOME_EXPENSES,
fa_icon="fa-solid fa-money-bill-wave")
return OptionLink(gettext("Journal"), journal_url(self.__period),
self.__active_report == ReportType.JOURNAL,
fa_icon="fa-solid fa-book")
@property
def __ledger(self) -> OptionLink:
@ -114,14 +103,20 @@ class ReportChooser:
fa_icon="fa-solid fa-clipboard")
@property
def __journal(self) -> OptionLink:
"""Returns the journal.
def __income_expenses(self) -> OptionLink:
"""Returns the income and expenses log.
:return: The journal.
:return: The income and expenses log.
"""
return OptionLink(gettext("Journal"), journal_url(self.__period),
self.__active_report == ReportType.JOURNAL,
fa_icon="fa-solid fa-book")
account: Account = self.__account
if not re.match(r"[12][12]", account.base_code):
account: Account = Account.cash()
return OptionLink(gettext("Income and Expenses Log"),
income_expenses_url(self.__currency,
IncomeExpensesAccount(account),
self.__period),
self.__active_report == ReportType.INCOME_EXPENSES,
fa_icon="fa-solid fa-money-bill-wave")
@property
def __trial_balance(self) -> OptionLink:
@ -156,41 +151,7 @@ class ReportChooser:
self.__active_report == ReportType.BALANCE_SHEET,
fa_icon="fa-solid fa-scale-balanced")
@property
def __unapplied(self) -> OptionLink:
"""Returns the unapplied original line items.
:return: The unapplied original line items.
"""
account: Account = self.__account
if not account.is_need_offset:
return OptionLink(gettext("Unapplied Items"),
unapplied_url(self.__currency, None),
self.__active_report == ReportType.UNAPPLIED,
fa_icon="fa-solid fa-link-slash")
return OptionLink(gettext("Unapplied Items"),
unapplied_url(self.__currency, self.__account),
self.__active_report == ReportType.UNAPPLIED,
fa_icon="fa-solid fa-link-slash")
@property
def __unmatched(self) -> OptionLink:
"""Returns the unmatched offsets.
:return: The unmatched offsets.
"""
account: Account = self.__account
if not account.is_need_offset:
return OptionLink(gettext("Unmatched Offsets"),
unmatched_url(self.__currency, None),
self.__active_report == ReportType.UNMATCHED,
fa_icon="fa-solid fa-file-circle-question")
return OptionLink(gettext("Unmatched Offsets"),
unmatched_url(self.__currency, self.__account),
self.__active_report == ReportType.UNMATCHED,
fa_icon="fa-solid fa-file-circle-question")
def __iter__(self) -> Iterator[OptionLink]:
def __iter__(self) -> t.Iterator[OptionLink]:
"""Returns the iteration of the reports.
:return: The iteration of the reports.

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Project.
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4
# Copyright (c) 2023 imacat.
@ -34,9 +34,5 @@ class ReportType(Enum):
"""The income statement."""
BALANCE_SHEET: str = "balance-sheet"
"""The balance sheet."""
UNAPPLIED: str = "unapplied"
"""The unapplied original line items."""
UNMATCHED: str = "unmatched"
"""The unmatched offsets."""
SEARCH: str = "search"
"""The search."""

View File

@ -1,104 +0,0 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/7
# Copyright (c) 2023 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""The unapplied original line item utilities.
"""
from decimal import Decimal
import sqlalchemy as sa
from accounting import db
from accounting.models import Currency, Account, JournalEntry, \
JournalEntryLineItem
from accounting.utils.offset_alias import offset_alias
def get_accounts_with_unapplied(currency: Currency) -> list[Account]:
"""Returns the accounts with unapplied original line items.
:param currency: The currency.
:return: The accounts with unapplied original line items.
"""
offset: sa.Alias = offset_alias()
net_balance: sa.Label \
= (JournalEntryLineItem.amount
+ sa.func.sum(sa.case(
(offset.c.is_debit == JournalEntryLineItem.is_debit,
offset.c.amount),
else_=-offset.c.amount))).label("net_balance")
select_unapplied: sa.Select \
= sa.select(JournalEntryLineItem.id)\
.join(JournalEntry).join(Account)\
.join(offset,
JournalEntryLineItem.id == offset.c.original_line_item_id,
isouter=True)\
.filter(Account.is_need_offset,
JournalEntryLineItem.currency_code == currency.code,
sa.or_(sa.and_(Account.base_code.startswith("2"),
sa.not_(JournalEntryLineItem.is_debit)),
sa.and_(Account.base_code.startswith("1"),
JournalEntryLineItem.is_debit)))\
.group_by(JournalEntryLineItem.id)\
.having(sa.or_(sa.func.count(offset.c.id) == 0, net_balance != 0))
count_func: sa.Label \
= sa.func.count(JournalEntryLineItem.id).label("count")
select: sa.Select = sa.select(Account.id, count_func)\
.join(JournalEntryLineItem, isouter=True)\
.filter(JournalEntryLineItem.id.in_(select_unapplied))\
.group_by(Account.id)\
.having(count_func > 0)
counts: dict[int, int] \
= {x.id: x.count for x in db.session.execute(select)}
accounts: list[Account] = Account.query.filter(Account.id.in_(counts))\
.order_by(Account.base_code, Account.no).all()
for account in accounts:
account.count = counts[account.id]
return accounts
def get_net_balances(currency: Currency, account: Account) \
-> dict[int, Decimal | None]:
"""Returns the net balances of the unapplied line items of the account.
:param currency: The currency.
:param account: The account.
:return: The net balances of the unapplied line items of the account.
"""
offset: sa.Alias = offset_alias()
net_balance: sa.Label \
= (JournalEntryLineItem.amount
+ sa.func.sum(sa.case(
(offset.c.is_debit == JournalEntryLineItem.is_debit,
offset.c.amount),
else_=-offset.c.amount))).label("net_balance")
select_net_balances: sa.Select \
= sa.select(JournalEntryLineItem.id, net_balance) \
.join(JournalEntry).join(Account) \
.join(offset,
JournalEntryLineItem.id == offset.c.original_line_item_id,
isouter=True) \
.filter(Account.id == account.id,
JournalEntryLineItem.currency_code == currency.code,
sa.or_(sa.and_(Account.base_code.startswith("2"),
sa.not_(JournalEntryLineItem.is_debit)),
sa.and_(Account.base_code.startswith("1"),
JournalEntryLineItem.is_debit))) \
.group_by(JournalEntryLineItem.id) \
.having(sa.or_(sa.func.count(offset.c.id) == 0, net_balance != 0))
return {x.id: x.net_balance
for x in db.session.execute(select_net_balances).all()}

View File

@ -1,54 +0,0 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/8
# Copyright (c) 2023 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""The unmatched offset utilities.
"""
import sqlalchemy as sa
from accounting import db
from accounting.models import Currency, Account, JournalEntry, \
JournalEntryLineItem
def get_accounts_with_unmatched(currency: Currency) -> list[Account]:
"""Returns the accounts with unmatched offsets.
:param currency: The currency.
:return: The accounts with unmatched offsets, with the "count" property set
to the number of unmatched offsets.
"""
count_func: sa.Label \
= sa.func.count(JournalEntryLineItem.id).label("count")
select: sa.Select = sa.select(Account.id, count_func)\
.select_from(Account)\
.join(JournalEntryLineItem, isouter=True).join(JournalEntry)\
.filter(Account.is_need_offset,
JournalEntryLineItem.currency_code == currency.code,
JournalEntryLineItem.original_line_item_id.is_(None),
sa.or_(sa.and_(Account.base_code.startswith("2"),
JournalEntryLineItem.is_debit),
sa.and_(Account.base_code.startswith("1"),
sa.not_(JournalEntryLineItem.is_debit))))\
.group_by(Account.id)\
.having(count_func > 0)
counts: dict[int, int] \
= {x.id: x.count for x in db.session.execute(select)}
accounts: list[Account] = Account.query.filter(Account.id.in_(counts))\
.order_by(Account.base_code, Account.no).all()
for account in accounts:
account.count = counts[account.id]
return accounts

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Project.
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/9
# Copyright (c) 2023 imacat.
@ -22,8 +22,7 @@ from flask import url_for
from accounting.models import Currency, Account
from accounting.report.period import Period
from accounting.template_globals import default_currency_code
from accounting.utils.current_account import CurrentAccount
from accounting.utils.options import options
from .ie_account import IncomeExpensesAccount, default_ie_account_code
def journal_url(period: Period) \
@ -34,8 +33,8 @@ def journal_url(period: Period) \
:return: The URL of the journal.
"""
if period.is_default:
return url_for("accounting-report.journal-default")
return url_for("accounting-report.journal", period=period)
return url_for("accounting.report.journal-default")
return url_for("accounting.report.journal", period=period)
def ledger_url(currency: Currency, account: Account, period: Period) \
@ -47,16 +46,15 @@ def ledger_url(currency: Currency, account: Account, period: Period) \
:param period: The period.
:return: The URL of the ledger.
"""
if currency.code == default_currency_code() \
and account.code == Account.CASH_CODE \
and period.is_default:
return url_for("accounting-report.ledger-default")
return url_for("accounting-report.ledger",
if period.is_default:
return url_for("accounting.report.ledger-default",
currency=currency, account=account)
return url_for("accounting.report.ledger",
currency=currency, account=account,
period=period)
def income_expenses_url(currency: Currency, account: CurrentAccount,
def income_expenses_url(currency: Currency, account: IncomeExpensesAccount,
period: Period) -> str:
"""Returns the URL of an income and expenses log.
@ -66,10 +64,13 @@ def income_expenses_url(currency: Currency, account: CurrentAccount,
:return: The URL of the income and expenses log.
"""
if currency.code == default_currency_code() \
and account.code == options.default_ie_account_code \
and account.code == default_ie_account_code() \
and period.is_default:
return url_for("accounting-report.default")
return url_for("accounting-report.income-expenses",
return url_for("accounting.report.default")
if period.is_default:
return url_for("accounting.report.income-expenses-default",
currency=currency, account=account)
return url_for("accounting.report.income-expenses",
currency=currency, account=account,
period=period)
@ -81,9 +82,10 @@ def trial_balance_url(currency: Currency, period: Period) -> str:
:param period: The period.
:return: The URL of the trial balance.
"""
if currency.code == default_currency_code() and period.is_default:
return url_for("accounting-report.trial-balance-default")
return url_for("accounting-report.trial-balance",
if period.is_default:
return url_for("accounting.report.trial-balance-default",
currency=currency)
return url_for("accounting.report.trial-balance",
currency=currency, period=period)
@ -94,9 +96,10 @@ def income_statement_url(currency: Currency, period: Period) -> str:
:param period: The period.
:return: The URL of the income statement.
"""
if currency.code == default_currency_code() and period.is_default:
return url_for("accounting-report.income-statement-default")
return url_for("accounting-report.income-statement",
if period.is_default:
return url_for("accounting.report.income-statement-default",
currency=currency)
return url_for("accounting.report.income-statement",
currency=currency, period=period)
@ -107,41 +110,8 @@ def balance_sheet_url(currency: Currency, period: Period) -> str:
:param period: The period.
:return: The URL of the balance sheet.
"""
if currency.code == default_currency_code() and period.is_default:
return url_for("accounting-report.balance-sheet-default")
return url_for("accounting-report.balance-sheet",
if period.is_default:
return url_for("accounting.report.balance-sheet-default",
currency=currency)
return url_for("accounting.report.balance-sheet",
currency=currency, period=period)
def unapplied_url(currency: Currency, account: Account | None) -> str:
"""Returns the URL of the unapplied original line items.
:param currency: The currency.
:param account: The account, or None to list the accounts with unapplied
original line items.
:return: The URL of the unapplied original line items.
"""
if account is None:
if currency.code == default_currency_code():
return url_for("accounting-report.unapplied-accounts-default")
return url_for("accounting-report.unapplied-accounts",
currency=currency)
return url_for("accounting-report.unapplied",
currency=currency, account=account)
def unmatched_url(currency: Currency, account: Account | None) -> str:
"""Returns the URL of the unmatched offset line items.
:param currency: The currency.
:param account: The account, or None to list the accounts with unmatched
offset line items.
:return: The URL of the unmatched offset line items.
"""
if account is None:
if currency.code == default_currency_code():
return url_for("accounting-report.unmatched-accounts-default")
return url_for("accounting-report.unmatched-accounts",
currency=currency)
return url_for("accounting-report.unmatched",
currency=currency, account=account)

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Project.
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/3
# Copyright (c) 2023 imacat.
@ -17,29 +17,19 @@
"""The views for the report management.
"""
from flask import Blueprint, request, Response, redirect, flash
from flask import Blueprint, request, Response
from accounting import db
from accounting.locale import lazy_gettext
from accounting.models import Currency, Account
from accounting.report.period import Period, get_period
from accounting.template_globals import default_currency_code
from accounting.utils.cast import s
from accounting.utils.current_account import CurrentAccount
from accounting.utils.next_uri import or_next
from accounting.utils.options import options
from accounting.utils.permission import has_permission, can_view, can_edit
from .period import Period, get_period
from accounting.utils.permission import has_permission, can_view
from .reports import Journal, Ledger, IncomeExpenses, TrialBalance, \
IncomeStatement, BalanceSheet, Search
from .reports.unapplied import UnappliedOriginalLineItems
from .reports.unapplied_accounts import AccountsWithUnappliedOriginalLineItems
from .reports.unmatched import UnmatchedOffsets
from .reports.unmatched_accounts import AccountsWithUnmatchedOffsets
from .template_filters import format_amount
from .utils.offset_matcher import OffsetMatcher
from .utils.urls import unmatched_url
from .utils.ie_account import IncomeExpensesAccount, default_ie_account
bp: Blueprint = Blueprint("accounting-report", __name__)
bp: Blueprint = Blueprint("report", __name__)
"""The view blueprint for the reports."""
bp.add_app_template_filter(format_amount, "accounting_report_format_amount")
@ -51,7 +41,10 @@ def get_default_report() -> str | Response:
:return: The income and expenses log in the default period.
"""
return get_default_income_expenses()
return __get_income_expenses(
db.session.get(Currency, default_currency_code()),
default_ie_account(),
get_period())
@bp.get("journal", endpoint="journal-default")
@ -87,15 +80,17 @@ def __get_journal(period: Period) -> str | Response:
return report.html()
@bp.get("ledger", endpoint="ledger-default")
@bp.get("ledger/<currency:currency>/<account:account>",
endpoint="ledger-default")
@has_permission(can_view)
def get_default_ledger() -> str | Response:
"""Returns the ledger in the default currency, cash, and default period.
def get_default_ledger(currency: Currency, account: Account) -> str | Response:
"""Returns the ledger in the default period.
:return: The ledger in the default currency, cash, and default period.
:param currency: The currency.
:param account: The account.
:return: The ledger in the default period.
"""
return __get_ledger(db.session.get(Currency, default_currency_code()),
Account.cash(), get_period())
return __get_ledger(currency, account, get_period())
@bp.get("ledger/<currency:currency>/<account:account>/<period:period>",
@ -128,23 +123,26 @@ def __get_ledger(currency: Currency, account: Account, period: Period) \
return report.html()
@bp.get("income-expenses", endpoint="income-expenses-default")
@bp.get("income-expenses/<currency:currency>/<ieAccount:account>",
endpoint="income-expenses-default")
@has_permission(can_view)
def get_default_income_expenses() -> str | Response:
def get_default_income_expenses(currency: Currency,
account: IncomeExpensesAccount) \
-> str | Response:
"""Returns the income and expenses log in the default period.
:param currency: The currency.
:param account: The account.
:return: The income and expenses log in the default period.
"""
return __get_income_expenses(
db.session.get(Currency, default_currency_code()),
options.default_ie_account,
get_period())
return __get_income_expenses(currency, account, get_period())
@bp.get("income-expenses/<currency:currency>/<currentAccount:account>/"
"<period:period>", endpoint="income-expenses")
@bp.get(
"income-expenses/<currency:currency>/<ieAccount:account>/<period:period>",
endpoint="income-expenses")
@has_permission(can_view)
def get_income_expenses(currency: Currency, account: CurrentAccount,
def get_income_expenses(currency: Currency, account: IncomeExpensesAccount,
period: Period) -> str | Response:
"""Returns the income and expenses log.
@ -156,7 +154,7 @@ def get_income_expenses(currency: Currency, account: CurrentAccount,
return __get_income_expenses(currency, account, period)
def __get_income_expenses(currency: Currency, account: CurrentAccount,
def __get_income_expenses(currency: Currency, account: IncomeExpensesAccount,
period: Period) -> str | Response:
"""Returns the income and expenses log.
@ -171,15 +169,16 @@ def __get_income_expenses(currency: Currency, account: CurrentAccount,
return report.html()
@bp.get("trial-balance", endpoint="trial-balance-default")
@bp.get("trial-balance/<currency:currency>",
endpoint="trial-balance-default")
@has_permission(can_view)
def get_default_trial_balance() -> str | Response:
def get_default_trial_balance(currency: Currency) -> str | Response:
"""Returns the trial balance in the default period.
:param currency: The currency.
:return: The trial balance in the default period.
"""
return __get_trial_balance(
db.session.get(Currency, default_currency_code()), get_period())
return __get_trial_balance(currency, get_period())
@bp.get("trial-balance/<currency:currency>/<period:period>",
@ -208,15 +207,16 @@ def __get_trial_balance(currency: Currency, period: Period) -> str | Response:
return report.html()
@bp.get("income-statement", endpoint="income-statement-default")
@bp.get("income-statement/<currency:currency>",
endpoint="income-statement-default")
@has_permission(can_view)
def get_default_income_statement() -> str | Response:
def get_default_income_statement(currency: Currency) -> str | Response:
"""Returns the income statement in the default period.
:param currency: The currency.
:return: The income statement in the default period.
"""
return __get_income_statement(
db.session.get(Currency, default_currency_code()), get_period())
return __get_income_statement(currency, get_period())
@bp.get("income-statement/<currency:currency>/<period:period>",
@ -246,15 +246,16 @@ def __get_income_statement(currency: Currency, period: Period) \
return report.html()
@bp.get("balance-sheet", endpoint="balance-sheet-default")
@bp.get("balance-sheet/<currency:currency>",
endpoint="balance-sheet-default")
@has_permission(can_view)
def get_default_balance_sheet() -> str | Response:
def get_default_balance_sheet(currency: Currency) -> str | Response:
"""Returns the balance sheet in the default period.
:param currency: The currency.
:return: The balance sheet in the default period.
"""
return __get_balance_sheet(
db.session.get(Currency, default_currency_code()), get_period())
return __get_balance_sheet(currency, get_period())
@bp.get("balance-sheet/<currency:currency>/<period:period>",
@ -285,130 +286,6 @@ def __get_balance_sheet(currency: Currency, period: Period) \
return report.html()
@bp.get("unapplied", endpoint="unapplied-accounts-default")
@has_permission(can_view)
def get_default_unapplied_accounts() -> str | Response:
"""Returns the accounts with unapplied original line items.
:return: The accounts with unapplied original line items.
"""
return __get_unapplied_accounts(
db.session.get(Currency, default_currency_code()))
@bp.get("unapplied/<currency:currency>", endpoint="unapplied-accounts")
@has_permission(can_view)
def get_unapplied_accounts(currency: Currency) -> str | Response:
"""Returns the accounts with unapplied original line items.
:param currency: The currency.
:return: The accounts with unapplied original line items.
"""
return __get_unapplied_accounts(currency)
def __get_unapplied_accounts(currency: Currency) -> str | Response:
"""Returns the accounts with unapplied original line items.
:param currency: The currency.
:return: The accounts with unapplied original line items.
"""
report: AccountsWithUnappliedOriginalLineItems \
= AccountsWithUnappliedOriginalLineItems(currency)
if "as" in request.args and request.args["as"] == "csv":
return report.csv()
return report.html()
@bp.get("unapplied/<currency:currency>/<needOffsetAccount:account>",
endpoint="unapplied")
@has_permission(can_view)
def get_unapplied(currency: Currency, account: Account) -> str | Response:
"""Returns the unapplied original line items.
:param currency: The currency.
:param account: The Account.
:return: The unapplied original line items in the period.
"""
report: UnappliedOriginalLineItems \
= UnappliedOriginalLineItems(currency, account)
if "as" in request.args and request.args["as"] == "csv":
return report.csv()
return report.html()
@bp.get("unmatched", endpoint="unmatched-accounts-default")
@has_permission(can_edit)
def get_default_unmatched_accounts() -> str | Response:
"""Returns the accounts with unmatched offsets.
:return: The accounts with unmatched offsets.
"""
return __get_unmatched_accounts(
db.session.get(Currency, default_currency_code()))
@bp.get("unmatched/<currency:currency>", endpoint="unmatched-accounts")
@has_permission(can_edit)
def get_unmatched_accounts(currency: Currency) -> str | Response:
"""Returns the accounts with unmatched offsets.
:param currency: The currency.
:return: The accounts with unmatched offsets.
"""
return __get_unmatched_accounts(currency)
def __get_unmatched_accounts(currency: Currency) -> str | Response:
"""Returns the accounts with unmatched offsets.
:param currency: The currency.
:return: The accounts with unmatched offsets.
"""
report: AccountsWithUnmatchedOffsets \
= AccountsWithUnmatchedOffsets(currency)
if "as" in request.args and request.args["as"] == "csv":
return report.csv()
return report.html()
@bp.get("unmatched/<currency:currency>/<needOffsetAccount:account>",
endpoint="unmatched")
@has_permission(can_edit)
def get_unmatched(currency: Currency, account: Account) -> str | Response:
"""Returns the unmatched offsets.
:param currency: The currency.
:param account: The Account.
:return: The unmatched offsets in the period.
"""
report: UnmatchedOffsets = UnmatchedOffsets(currency, account)
if "as" in request.args and request.args["as"] == "csv":
return report.csv()
return report.html()
@bp.post("match-offsets/<currency:currency>/<needOffsetAccount:account>",
endpoint="match-offsets")
@has_permission(can_edit)
def match_offsets(currency: Currency, account: Account) -> redirect:
"""Matches the original line items with their offsets.
:return: Redirection to the view of the unmatched offsets.
"""
matcher: OffsetMatcher = OffsetMatcher(currency, account)
if len(matcher.matched_pairs) == 0:
flash(s(lazy_gettext("No more offset to match automatically.")),
"success")
return redirect(or_next(
unmatched_url(currency, account)))
matcher.match()
db.session.commit()
flash(s(lazy_gettext("Matched %(matches)s offsets.",
matches=len(matcher.matched_pairs))), "success")
return redirect(or_next(unmatched_url(currency, account)))
@bp.get("search", endpoint="search")
@has_permission(can_view)
def search() -> str | Response:

View File

@ -1,4 +1,4 @@
/* The Mia! Accounting Project
/* The Mia! Accounting Flask Project
* style.css: The style sheet for the accounting application.
*/
@ -31,18 +31,11 @@
color: #141619;
background-color: #D3D3D4;
}
.form-control.accounting-disabled {
background-color: #e9ecef;
}
/** The toolbar */
.accounting-toolbar {
display: flex;
}
.accounting-toolbar-accounts {
max-height: 20rem;
overflow-y: scroll;
}
.accounting-toolbar .input-group > .input-group-text {
padding: 0;
background-color: transparent;
@ -80,7 +73,7 @@
height: 3.2rem;
width: 3.2rem;
border-radius: 50%;
margin-right: 0.5rem;
margin-left: 1rem;
}
.accounting-toolbar > a.btn, .accounting-toolbar > .btn-group > a.btn {
padding-top: 0.7rem;
@ -120,70 +113,46 @@
border-bottom: thick double slategray;
}
/* Links between objects */
.accounting-original-line-item {
border-top: thin solid darkslategray;
padding: 0.2rem 0.5rem;
}
.accounting-original-line-item a {
color: inherit;
text-decoration: none;
}
.accounting-original-line-item a:hover {
color: inherit;
}
.accounting-offset-line-items {
border-top: thin solid darkslategray;
padding: 0.2rem 0.5rem;
}
.accounting-offset-line-items ul li {
list-style: none;
}
.accounting-offset-line-items ul li a {
color: inherit;
text-decoration: none;
}
.accounting-offset-line-items ul li a:hover {
color: inherit;
}
/** The option selector */
.accounting-selector-list {
height: 20rem;
overflow-y: scroll;
}
/** The journal entry management */
/** The transaction management */
.accounting-currency-control {
background-color: transparent;
}
.accounting-currency-content {
width: calc(100% - 3rem);
}
.accounting-line-item-content {
.accounting-entry-content {
width: calc(100% - 3rem);
background-color: transparent;
}
.accounting-entry-control {
border-color: transparent;
}
.accounting-list-group-stripped .list-group-item:nth-child(2n+1) {
background-color: #f2f2f2;
}
.accounting-list-group-stripped .list-group-item-success:nth-child(2n+1) {
background-color: #c7dbd2;
}
.accounting-list-group-hover .list-group-item:hover {
background-color: #ececec;
}
.accounting-journal-entry-line-item {
.accounting-transaction-entry {
border: none;
}
.accounting-journal-entry-line-item-header {
.accounting-transaction-entry-header {
font-weight: bolder;
border-bottom: thick double slategray;
}
.list-group-item.accounting-journal-entry-line-item-total {
.list-group-item.accounting-transaction-entry-total {
font-weight: bolder;
border-top: thick double slategray;
}
.accounting-line-item-editor-original-line-item-content {
width: calc(100% - 3rem);
}
/* The report table */
.accounting-report-table-header, .accounting-report-table-footer {
@ -213,39 +182,21 @@ a.accounting-report-table-row {
.accounting-report-table-body .accounting-amount {
font-style: italic;
}
.accounting-report-table-body .accounting-report-table-row {
background-color: #f8f9fa;
}
.accounting-report-table-body .accounting-report-table-row:nth-child(2n+1) {
background-color: #ecedee;
background-color: #f2f2f2;
}
.accounting-report-table-body .accounting-report-table-row:hover {
background-color: #e5e6e7;
}
.accounting-report-table-body .accounting-report-table-row-danger {
background-color: #f8d7da;
}
.accounting-report-table-body .accounting-report-table-row-danger:nth-child(2n+1) {
background-color: #eccccf;
}
.accounting-report-table-body .accounting-report-table-row-danger:hover {
background-color: #e5c7ca;
background-color: rgba(0, 0, 0, 0.075);
}
.accounting-journal-table .accounting-report-table-row {
grid-template-columns: 1fr 1fr 2fr 4fr 1fr 1fr;
}
.accounting-ledger-real-table .accounting-report-table-row {
.accounting-ledger-table .accounting-report-table-row {
grid-template-columns: 1fr 4fr 1fr 1fr 1fr;
}
.accounting-ledger-real-table .accounting-report-table-footer .accounting-report-table-row {
.accounting-ledger-table .accounting-report-table-footer .accounting-report-table-row {
grid-template-columns: 5fr 1fr 1fr 1fr;
}
.accounting-ledger-nominal-table .accounting-report-table-row {
grid-template-columns: 1fr 4fr 1fr 1fr;
}
.accounting-ledger-nominal-table .accounting-report-table-footer .accounting-report-table-row {
grid-template-columns: 5fr 1fr 1fr;
}
.accounting-income-expenses-table .accounting-report-table-row {
grid-template-columns: 1fr 2fr 4fr 1fr 1fr 1fr;
}
@ -325,66 +276,12 @@ a.accounting-report-table-row {
.accounting-balance-sheet-total .accounting-amount, .accounting-balance-sheet-subtotal, .accounting-amount {
font-style: italic;
}
.accounting-unapplied-table .accounting-report-table-row {
grid-template-columns: 1fr 5fr 1fr 1fr;
}
.accounting-unapplied-account-table .accounting-report-table-row {
display: flex;
justify-content: space-between;
}
.accounting-unapplied-account-table .accounting-report-table-header .accounting-report-table-row {
display: block;
}
.accounting-unmatched-table .accounting-report-table-row {
grid-template-columns: 1fr 5fr 1fr 1fr 1fr;
}
.accounting-unmatched-account-table .accounting-report-table-row {
display: flex;
justify-content: space-between;
}
.accounting-unmatched-account-table .accounting-report-table-header .accounting-report-table-row {
display: block;
}
/* The accounting report */
.accounting-mobile-journal-credit {
padding-left: 1rem;
}
/* The description editor */
.accounting-description-editor-buttons {
max-height: 7rem;
overflow-y: scroll;
}
.accounting-description-editor-buttons .btn {
margin-bottom: 0.3rem;
}
/* The order of the journal entries in a same day */
.accounting-journal-entry-order-item, .accounting-journal-entry-order-item:hover {
color: inherit;
text-decoration: none;
}
.accounting-journal-entry-order-item-currency {
margin-left: 0.5rem;
border-top: thin solid lightgray;
margin-top: 0.2rem;
}
/* The illustration of the description template for the recurring transactions */
.accounting-recurring-description-template-illustration p {
margin: 0.2rem 0;
}
.accounting-recurring-description-template-illustration ul {
margin: 0;
}
/* The unmatched offsets */
.accounting-unmatched-offset-pair-list {
height: 20rem;
overflow-y: scroll;
}
/* The Material Design text field (floating form control in Bootstrap) */
.accounting-material-text-field {
position: relative;
@ -409,7 +306,7 @@ a.accounting-report-table-row {
.accounting-material-fab {
position: fixed;
right: 2rem;
bottom: 2rem;
bottom: 1rem;
z-index: 10;
flex-direction: column-reverse;
}

View File

@ -1,4 +1,4 @@
/* The Mia! Accounting Project
/* The Mia! Accounting Flask Project
* account-form.js: The JavaScript for the account form
*/
@ -24,157 +24,121 @@
// Initializes the page JavaScript.
document.addEventListener("DOMContentLoaded", () => {
AccountForm.initialize();
initializeBaseAccountSelector();
document.getElementById("accounting-base-code")
.onchange = validateBase;
document.getElementById("accounting-title")
.onchange = validateTitle;
document.getElementById("accounting-form")
.onsubmit = validateForm;
});
/**
* The account form.
* Initializes the base account selector.
*
* @private
*/
class AccountForm {
/**
* The base account selector
* @type {BaseAccountSelector}
*/
#baseAccountSelector;
/**
* The form element
* @type {HTMLFormElement}
*/
#formElement;
/**
* The control of the base account
* @type {HTMLDivElement}
*/
#baseControl;
/**
* The input of the base account
* @type {HTMLInputElement}
*/
#baseCode;
/**
* The base account
* @type {HTMLDivElement}
*/
#base;
/**
* The error message for the base account
* @type {HTMLDivElement}
*/
#baseError;
/**
* The title
* @type {HTMLInputElement}
*/
#title;
/**
* The error message of the title
* @type {HTMLDivElement}
*/
#titleError;
/**
* The control of the is-need-offset option
* @type {HTMLDivElement}
*/
#isNeedOffsetControl;
/**
* The is-need-offset option
* @type {HTMLInputElement}
*/
#isNeedOffset;
/**
* Constructs the account form.
*
*/
constructor() {
this.#baseAccountSelector = new BaseAccountSelector(this);
this.#formElement = document.getElementById("accounting-form");
this.#baseControl = document.getElementById("accounting-base-control");
this.#baseCode = document.getElementById("accounting-base-code");
this.#base = document.getElementById("accounting-base");
this.#baseError = document.getElementById("accounting-base-error");
this.#title = document.getElementById("accounting-title");
this.#titleError = document.getElementById("accounting-title-error");
this.#isNeedOffsetControl = document.getElementById("accounting-is-need-offset-control");
this.#isNeedOffset = document.getElementById("accounting-is-need-offset");
this.#formElement.onsubmit = () => {
return this.#validate();
};
this.#baseControl.onclick = () => {
this.#baseControl.classList.add("accounting-not-empty");
this.#baseAccountSelector.onOpen();
function initializeBaseAccountSelector() {
const selector = document.getElementById("accounting-base-selector-modal");
const base = document.getElementById("accounting-base");
const baseCode = document.getElementById("accounting-base-code");
const baseContent = document.getElementById("accounting-base-content");
const options = Array.from(document.getElementsByClassName("accounting-base-option"));
const btnClear = document.getElementById("accounting-btn-clear-base");
selector.addEventListener("show.bs.modal", () => {
base.classList.add("accounting-not-empty");
for (const option of options) {
option.classList.remove("active");
}
const selected = document.getElementById("accounting-base-option-" + baseCode.value);
if (selected !== null) {
selected.classList.add("active");
}
});
selector.addEventListener("hidden.bs.modal", () => {
if (baseCode.value === "") {
base.classList.remove("accounting-not-empty");
}
});
for (const option of options) {
option.onclick = () => {
baseCode.value = option.dataset.code;
baseContent.innerText = option.dataset.content;
btnClear.classList.add("btn-danger");
btnClear.classList.remove("btn-secondary")
btnClear.disabled = false;
validateBase();
bootstrap.Modal.getInstance(selector).hide();
};
}
/**
* Returns the base code.
*
* @return {string|null}
*/
get baseCode() {
return this.#baseCode.value === ""? null: this.#baseCode.value;
btnClear.onclick = () => {
baseCode.value = "";
baseContent.innerText = "";
btnClear.classList.add("btn-secondary")
btnClear.classList.remove("btn-danger");
btnClear.disabled = true;
validateBase();
bootstrap.Modal.getInstance(selector).hide();
}
initializeBaseAccountQuery();
}
/**
* The callback when the base account selector is closed.
* Initializes the query on the base account options.
*
* @private
*/
onBaseAccountSelectorClosed() {
if (this.#baseCode.value === "") {
this.#baseControl.classList.remove("accounting-not-empty");
function initializeBaseAccountQuery() {
const query = document.getElementById("accounting-base-selector-query");
const optionList = document.getElementById("accounting-base-option-list");
const options = Array.from(document.getElementsByClassName("accounting-base-option"));
const queryNoResult = document.getElementById("accounting-base-option-no-result");
query.addEventListener("input", () => {
if (query.value === "") {
for (const option of options) {
option.classList.remove("d-none");
}
optionList.classList.remove("d-none");
queryNoResult.classList.add("d-none");
return
}
let hasAnyMatched = false;
for (const option of options) {
const queryValues = JSON.parse(option.dataset.queryValues);
let isMatched = false;
for (const queryValue of queryValues) {
if (queryValue.includes(query.value)) {
isMatched = true;
break;
}
}
/**
* Saves the selected base account.
*
* @param account {BaseAccountOption} the selected base account
*/
saveBaseAccount(account) {
this.#baseCode.value = account.code;
this.#base.innerText = account.text;
if (["1", "2", "3"].includes(account.code.substring(0, 1))) {
this.#isNeedOffsetControl.classList.remove("d-none");
this.#isNeedOffset.disabled = false;
if (isMatched) {
option.classList.remove("d-none");
hasAnyMatched = true;
} else {
this.#isNeedOffsetControl.classList.add("d-none");
this.#isNeedOffset.disabled = true;
this.#isNeedOffset.checked = false;
option.classList.add("d-none");
}
this.#validateBase();
}
/**
* Clears the base account.
*
*/
clearBaseAccount() {
this.#baseCode.value = "";
this.#base.innerText = "";
this.#validateBase();
if (!hasAnyMatched) {
optionList.classList.add("d-none");
queryNoResult.classList.remove("d-none");
} else {
optionList.classList.remove("d-none");
queryNoResult.classList.add("d-none");
}
});
}
/**
* Validates the form.
*
* @returns {boolean} true if valid, or false otherwise
* @private
*/
#validate() {
function validateForm() {
let isValid = true;
isValid = this.#validateBase() && isValid;
isValid = this.#validateTitle() && isValid;
isValid = validateBase() && isValid;
isValid = validateTitle() && isValid;
return isValid;
}
@ -182,15 +146,20 @@ class AccountForm {
* Validates the base account.
*
* @returns {boolean} true if valid, or false otherwise
* @private
*/
#validateBase() {
if (this.#baseCode.value === "") {
this.#baseControl.classList.add("is-invalid");
this.#baseError.innerText = A_("Please select the base account.");
function validateBase() {
const field = document.getElementById("accounting-base-code");
const error = document.getElementById("accounting-base-code-error");
const displayField = document.getElementById("accounting-base");
field.value = field.value.trim();
if (field.value === "") {
displayField.classList.add("is-invalid");
error.innerText = A_("Please select the base account.");
return false;
}
this.#baseControl.classList.remove("is-invalid");
this.#baseError.innerText = "";
displayField.classList.remove("is-invalid");
error.innerText = "";
return true;
}
@ -198,230 +167,18 @@ class AccountForm {
* Validates the title.
*
* @returns {boolean} true if valid, or false otherwise
*/
#validateTitle() {
this.#title.value = this.#title.value.trim();
if (this.#title.value === "") {
this.#title.classList.add("is-invalid");
this.#titleError.innerText = A_("Please fill in the title.");
return false;
}
this.#title.classList.remove("is-invalid");
this.#titleError.innerText = "";
return true;
}
/**
* The account form
* @type {AccountForm} the form
*/
static #form;
static initialize() {
this.#form = new AccountForm();
}
}
/**
* The base account selector.
*
* @private
*/
class BaseAccountSelector {
/**
* The account form
* @type {AccountForm}
*/
form;
/**
* The selector modal
* @type {HTMLDivElement}
*/
#modal;
/**
* The query input
* @type {HTMLInputElement}
*/
#query;
/**
* The error message when the query has no result
* @type {HTMLParagraphElement}
*/
#queryNoResult;
/**
* The option list
* @type {HTMLUListElement}
*/
#optionList;
/**
* The options
* @type {BaseAccountOption[]}
*/
#options;
/**
* The button to clear the base account value
* @type {HTMLButtonElement}
*/
#clearButton;
/**
* Constructs the base account selector.
*
* @param form {AccountForm} the form
*/
constructor(form) {
this.form = form;
const prefix = "accounting-base-selector";
this.#modal = document.getElementById(`${prefix}-modal`);
this.#query = document.getElementById(`${prefix}-query`);
this.#queryNoResult = document.getElementById(`${prefix}-option-no-result`);
this.#optionList = document.getElementById(`${prefix}-option-list`);
this.#options = Array.from(document.getElementsByClassName(`${prefix}-option`)).map((element) => new BaseAccountOption(this, element));
this.#clearButton = document.getElementById(`${prefix}-clear`);
this.#modal.addEventListener("hidden.bs.modal", () => this.form.onBaseAccountSelectorClosed());
this.#query.oninput = () => this.#filterOptions();
this.#clearButton.onclick = () => this.form.clearBaseAccount();
}
/**
* Filters the options.
*
*/
#filterOptions() {
let isAnyMatched = false;
for (const option of this.#options) {
if (option.isMatched(this.#query.value)) {
option.setShown(true);
isAnyMatched = true;
} else {
option.setShown(false);
}
}
if (!isAnyMatched) {
this.#optionList.classList.add("d-none");
this.#queryNoResult.classList.remove("d-none");
} else {
this.#optionList.classList.remove("d-none");
this.#queryNoResult.classList.add("d-none");
}
}
/**
* The callback when the base account selector is shown.
*
*/
onOpen() {
this.#query.value = "";
this.#filterOptions();
for (const option of this.#options) {
option.setActive(option.code === this.form.baseCode);
}
if (this.form.baseCode === null) {
this.#clearButton.classList.add("btn-secondary")
this.#clearButton.classList.remove("btn-danger");
this.#clearButton.disabled = true;
} else {
this.#clearButton.classList.add("btn-danger");
this.#clearButton.classList.remove("btn-secondary")
this.#clearButton.disabled = false;
}
}
}
/**
* A base account option.
*
*/
class BaseAccountOption {
/**
* The element
* @type {HTMLLIElement}
*/
#element;
/**
* The account code
* @type {string}
*/
code;
/**
* The account text
* @type {string}
*/
text;
/**
* The values to query against
* @type {string[]}
*/
#queryValues;
/**
* Constructs the account in the base account selector.
*
* @param selector {BaseAccountSelector} the base account selector
* @param element {HTMLLIElement} the element
*/
constructor(selector, element) {
this.#element = element;
this.code = element.dataset.code;
this.text = element.dataset.text;
this.#queryValues = JSON.parse(element.dataset.queryValues);
this.#element.onclick = () => selector.form.saveBaseAccount(this);
}
/**
* Returns whether the account matches the query.
*
* @param query {string} the query term
* @return {boolean} true if the option matches, or false otherwise
*/
isMatched(query) {
if (query === "") {
return true;
}
for (const queryValue of this.#queryValues) {
if (queryValue.toLowerCase().includes(query.toLowerCase())) {
return true;
}
}
function validateTitle() {
const field = document.getElementById("accounting-title");
const error = document.getElementById("accounting-title-error");
field.value = field.value.trim();
if (field.value === "") {
field.classList.add("is-invalid");
error.innerText = A_("Please fill in the title.");
return false;
}
/**
* Sets whether the option is shown.
*
* @param isShown {boolean} true to show, or false otherwise
*/
setShown(isShown) {
if (isShown) {
this.#element.classList.remove("d-none");
} else {
this.#element.classList.add("d-none");
}
}
/**
* Sets whether the option is active.
*
* @param isActive {boolean} true if active, or false otherwise
*/
setActive(isActive) {
if (isActive) {
this.#element.classList.add("active");
} else {
this.#element.classList.remove("active");
}
}
field.classList.remove("is-invalid");
error.innerText = "";
return true;
}

View File

@ -1,4 +1,4 @@
/* The Mia! Accounting Project
/* The Mia! Accounting Flask Project
* account-order.js: The JavaScript for the account order
*/
@ -29,11 +29,10 @@ document.addEventListener("DOMContentLoaded", () => {
const onReorder = () => {
const accounts = Array.from(list.children);
for (let i = 0; i < accounts.length; i++) {
const no = document.getElementById(`accounting-order-${accounts[i].dataset.id}-no`);
const code = document.getElementById(`accounting-order-${accounts[i].dataset.id}-code`);
const no = document.getElementById("accounting-order-" + accounts[i].dataset.id + "-no");
const code = document.getElementById("accounting-order-" + accounts[i].dataset.id + "-code");
no.value = String(i + 1);
const zeroPaddedNo = `000${no.value}`.slice(-3)
code.innerText = `${list.dataset.baseCode}-${zeroPaddedNo}`;
code.innerText = list.dataset.baseCode + "-" + ("000" + (i + 1)).slice(-3);
}
};
initializeDragAndDropReordering(list, onReorder);

View File

@ -0,0 +1,250 @@
/* The Mia! Accounting Flask Project
* transaction-transfer-form.js: The JavaScript for the transfer transaction form
*/
/* Copyright (c) 2023 imacat.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/* Author: imacat@mail.imacat.idv.tw (imacat)
* First written: 2023/2/28
*/
"use strict";
// Initializes the page JavaScript.
document.addEventListener("DOMContentLoaded", () => {
AccountSelector.initialize();
});
/**
* The account selector.
*
*/
class AccountSelector {
/**
* The entry type
* @type {string}
*/
#entryType;
/**
* The prefix of the HTML ID and class
* @type {string}
*/
#prefix;
/**
* Constructs an account selector.
*
* @param modal {HTMLFormElement} the account selector modal
*/
constructor(modal) {
this.#entryType = modal.dataset.entryType;
this.#prefix = "accounting-account-selector-" + modal.dataset.entryType;
this.#init();
}
/**
* Initializes the account selector.
*
*/
#init() {
const formAccountControl = document.getElementById("accounting-entry-form-account-control");
const formAccount = document.getElementById("accounting-entry-form-account");
const more = document.getElementById(this.#prefix + "-more");
const btnClear = document.getElementById(this.#prefix + "-btn-clear");
const options = Array.from(document.getElementsByClassName(this.#prefix + "-option"));
more.onclick = () => {
more.classList.add("d-none");
this.#filterAccountOptions();
};
this.#initializeAccountQuery();
btnClear.onclick = () => {
formAccountControl.classList.remove("accounting-not-empty");
formAccount.innerText = "";
formAccount.dataset.code = "";
formAccount.dataset.text = "";
validateJournalEntryAccount();
};
for (const option of options) {
option.onclick = () => {
formAccountControl.classList.add("accounting-not-empty");
formAccount.innerText = option.dataset.content;
formAccount.dataset.code = option.dataset.code;
formAccount.dataset.text = option.dataset.content;
validateJournalEntryAccount();
};
}
}
/**
* Initializes the query on the account options.
*
*/
#initializeAccountQuery() {
const query = document.getElementById(this.#prefix + "-query");
query.addEventListener("input", () => {
this.#filterAccountOptions();
});
}
/**
* Filters the account options.
*
*/
#filterAccountOptions() {
const query = document.getElementById(this.#prefix + "-query");
const optionList = document.getElementById(this.#prefix + "-option-list");
if (optionList === null) {
console.log(this.#prefix + "-option-list");
}
const options = Array.from(document.getElementsByClassName(this.#prefix + "-option"));
const more = document.getElementById(this.#prefix + "-more");
const queryNoResult = document.getElementById(this.#prefix + "-option-no-result");
const codesInUse = this.#getAccountCodeUsedInForm();
let shouldAnyShow = false;
for (const option of options) {
const shouldShow = this.#shouldAccountOptionShow(option, more, codesInUse, query);
if (shouldShow) {
option.classList.remove("d-none");
shouldAnyShow = true;
} else {
option.classList.add("d-none");
}
}
if (!shouldAnyShow && more.classList.contains("d-none")) {
optionList.classList.add("d-none");
queryNoResult.classList.remove("d-none");
} else {
optionList.classList.remove("d-none");
queryNoResult.classList.add("d-none");
}
}
/**
* Returns the account codes that are used in the form.
*
* @return {string[]} the account codes that are used in the form
*/
#getAccountCodeUsedInForm() {
const accountCodes = Array.from(document.getElementsByClassName("accounting-" + this.#prefix + "-account-code"));
const formAccount = document.getElementById("accounting-entry-form-account");
const inUse = [formAccount.dataset.code];
for (const accountCode of accountCodes) {
inUse.push(accountCode.value);
}
return inUse
}
/**
* Returns whether an account option should show.
*
* @param option {HTMLLIElement} the account option
* @param more {HTMLLIElement} the more account element
* @param inUse {string[]} the account codes that are used in the form
* @param query {HTMLInputElement} the query element, if any
* @return {boolean} true if the account option should show, or false otherwise
*/
#shouldAccountOptionShow(option, more, inUse, query) {
const isQueryMatched = () => {
if (query.value === "") {
return true;
}
const queryValues = JSON.parse(option.dataset.queryValues);
for (const queryValue of queryValues) {
if (queryValue.includes(query.value)) {
return true;
}
}
return false;
};
const isMoreMatched = () => {
if (more.classList.contains("d-none")) {
return true;
}
return option.classList.contains("accounting-account-in-use") || inUse.includes(option.dataset.code);
};
return isMoreMatched() && isQueryMatched();
}
/**
* Initializes the account selector when it is shown.
*
*/
initShow() {
const formAccount = document.getElementById("accounting-entry-form-account");
const query = document.getElementById(this.#prefix + "-query")
const more = document.getElementById(this.#prefix + "-more");
const options = Array.from(document.getElementsByClassName(this.#prefix + "-option"));
const btnClear = document.getElementById(this.#prefix + "-btn-clear");
query.value = "";
more.classList.remove("d-none");
this.#filterAccountOptions();
for (const option of options) {
if (option.dataset.code === formAccount.dataset.code) {
option.classList.add("active");
} else {
option.classList.remove("active");
}
}
if (formAccount.dataset.code === "") {
btnClear.classList.add("btn-secondary");
btnClear.classList.remove("btn-danger");
btnClear.disabled = true;
} else {
btnClear.classList.add("btn-danger");
btnClear.classList.remove("btn-secondary");
btnClear.disabled = false;
}
}
/**
* The account selectors.
* @type {{debit: AccountSelector, credit: AccountSelector}}
*/
static #selectors = {}
/**
* Initializes the account selectors.
*
*/
static initialize() {
const modals = Array.from(document.getElementsByClassName("accounting-account-selector-modal"));
for (const modal of modals) {
const selector = new AccountSelector(modal);
this.#selectors[selector.#entryType] = selector;
}
this.#initializeTransactionForm();
}
/**
* Initializes the transaction form.
*
*/
static #initializeTransactionForm() {
const entryForm = document.getElementById("accounting-entry-form");
const formAccountControl = document.getElementById("accounting-entry-form-account-control");
formAccountControl.onclick = () => this.#selectors[entryForm.dataset.entryType].initShow();
}
/**
* Initializes the account selector for the journal entry form.
*x
*/
static initializeJournalEntryForm() {
const entryForm = document.getElementById("accounting-entry-form");
const formAccountControl = document.getElementById("accounting-entry-form-account-control");
formAccountControl.dataset.bsTarget = "#accounting-account-selector-" + entryForm.dataset.entryType + "-modal";
}
}

View File

@ -1,4 +1,4 @@
/* The Mia! Accounting Project
/* The Mia! Accounting Flask Project
* currency-form.js: The JavaScript for the currency form
*/
@ -24,151 +24,152 @@
// Initializes the page JavaScript.
document.addEventListener("DOMContentLoaded", () => {
CurrencyForm.initialize();
document.getElementById("accounting-code")
.onchange = validateCode;
document.getElementById("accounting-name")
.onchange = validateName;
document.getElementById("accounting-form")
.onsubmit = validateForm;
});
/**
* The currency form.
*
* The asynchronous validation result
* @type {object}
* @private
*/
class CurrencyForm {
/**
* The form.
* @type {HTMLFormElement}
*/
#formElement;
/**
* The code
* @type {HTMLInputElement}
*/
#code;
/**
* The error message of the code
* @type {HTMLDivElement}
*/
#codeError;
/**
* The name
* @type {HTMLInputElement}
*/
#name;
/**
* The error message of the name
* @type {HTMLDivElement}
*/
#nameError;
/**
* Constructs the currency form.
*
*/
constructor() {
this.#formElement = document.getElementById("accounting-form");
this.#code = document.getElementById("accounting-code");
this.#codeError = document.getElementById("accounting-code-error");
this.#name = document.getElementById("accounting-name");
this.#nameError = document.getElementById("accounting-name-error");
this.#code.onchange = () => {
this.#validateCode().then();
};
this.#name.onchange = () => {
this.#validateName();
};
this.#formElement.onsubmit = () => {
this.#validate().then((isValid) => {
if (isValid) {
this.#formElement.submit();
}
});
return false;
};
}
let isAsyncValid = {};
/**
* Validates the form.
*
* @returns {Promise<boolean>} true if valid, or false otherwise
* @returns {boolean} true if valid, or false otherwise
* @private
*/
async #validate() {
function validateForm() {
isAsyncValid = {
"code": false,
"_sync": false,
};
let isValid = true;
isValid = await this.#validateCode() && isValid;
isValid = this.#validateName() && isValid;
return isValid;
isValid = validateCode() && isValid;
isValid = validateName() && isValid;
isAsyncValid["_sync"] = isValid;
submitFormIfAllAsyncValid();
return false;
}
/**
* Submits the form if the whole form passed the asynchronous
* validations.
*
* @private
*/
function submitFormIfAllAsyncValid() {
let isValid = true;
for (const key of Object.keys(isAsyncValid)) {
isValid = isAsyncValid[key] && isValid;
}
if (isValid) {
document.getElementById("accounting-form").submit()
}
}
/**
* Validates the code.
*
* @param changeEvent {Event} the change event, if invoked from onchange
* @returns {Promise<boolean>} true if valid, or false otherwise
* @returns {boolean} true if valid, or false otherwise
* @private
*/
async #validateCode(changeEvent = null) {
this.#code.value = this.#code.value.trim();
if (this.#code.value === "") {
this.#code.classList.add("is-invalid");
this.#codeError.innerText = A_("Please fill in the code.");
function validateCode(changeEvent = null) {
const key = "code";
const isSubmission = changeEvent === null;
let hasAsyncValidation = false;
const field = document.getElementById("accounting-code");
const error = document.getElementById("accounting-code-error");
field.value = field.value.trim();
if (field.value === "") {
field.classList.add("is-invalid");
error.innerText = A_("Please fill in the code.");
return false;
}
const blocklist = JSON.parse(this.#code.dataset.blocklist);
if (blocklist.includes(this.#code.value)) {
this.#code.classList.add("is-invalid");
this.#codeError.innerText = A_("This code is not available.");
const blocklist = JSON.parse(field.dataset.blocklist);
if (blocklist.includes(field.value)) {
field.classList.add("is-invalid");
error.innerText = A_("This code is not available.");
return false;
}
if (!this.#code.value.match(/^[A-Z]{3}$/)) {
this.#code.classList.add("is-invalid");
this.#codeError.innerText = A_("Code can only be composed of 3 upper-cased letters.");
if (!field.value.match(/^[A-Z]{3}$/)) {
field.classList.add("is-invalid");
error.innerText = A_("Code can only be composed of 3 upper-cased letters.");
return false;
}
const original = this.#code.dataset.original;
if (original === "" || this.#code.value !== original) {
const response = await fetch(`${this.#code.dataset.existsUrl}?q=${encodeURIComponent(this.#code.value)}`);
const data = await response.json();
if (data["exists"]) {
this.#code.classList.add("is-invalid");
this.#codeError.innerText = A_("Code conflicts with another currency.");
return false;
const original = field.dataset.original;
if (original === "" || field.value !== original) {
hasAsyncValidation = true;
validateAsyncCodeIsDuplicated(isSubmission, key);
}
if (!hasAsyncValidation) {
isAsyncValid[key] = true;
field.classList.remove("is-invalid");
error.innerText = "";
}
this.#code.classList.remove("is-invalid");
this.#codeError.innerText = "";
return true;
}
/**
* Validates asynchronously whether the code is duplicated.
* The boolean validation result is stored in isAsyncValid[key].
*
* @param isSubmission {boolean} whether this is invoked from a form submission
* @param key {string} the key to store the result in isAsyncValid
* @private
*/
function validateAsyncCodeIsDuplicated(isSubmission, key) {
const field = document.getElementById("accounting-code");
const error = document.getElementById("accounting-code-error");
const url = field.dataset.existsUrl;
const onLoad = function () {
if (this.status === 200) {
const result = JSON.parse(this.responseText);
if (result["exists"]) {
field.classList.add("is-invalid");
error.innerText = A_("Code conflicts with another currency.");
if (isSubmission) {
isAsyncValid[key] = false;
}
return;
}
field.classList.remove("is-invalid");
error.innerText = "";
if (isSubmission) {
isAsyncValid[key] = true;
submitFormIfAllAsyncValid();
}
}
};
const request = new XMLHttpRequest();
request.onload = onLoad;
request.open("GET", url + "?q=" + encodeURIComponent(field.value));
request.send();
}
/**
* Validates the name.
*
* @returns {boolean} true if valid, or false otherwise
* @private
*/
#validateName() {
this.#name.value = this.#name.value.trim();
if (this.#name.value === "") {
this.#name.classList.add("is-invalid");
this.#nameError.innerText = A_("Please fill in the name.");
function validateName() {
const field = document.getElementById("accounting-name");
const error = document.getElementById("accounting-name-error");
field.value = field.value.trim();
if (field.value === "") {
field.classList.add("is-invalid");
error.innerText = A_("Please fill in the name.");
return false;
}
this.#name.classList.remove("is-invalid");
this.#nameError.innerText = "";
field.classList.remove("is-invalid");
error.innerText = "";
return true;
}
/**
* The form
* @type {CurrencyForm}
*/
static #form;
/**
* Initializes the currency form.
*
*/
static initialize() {
this.#form = new CurrencyForm();
}
}

Some files were not shown because too many files have changed in this diff Show More