61 Commits

Author SHA1 Message Date
d8afadda02 Advanced to version 0.8.0. 2023-03-22 01:52:24 +08:00
c8e1270d8f Updated the translation. 2023-03-22 01:50:18 +08:00
2a78799404 Revised the page to reorder the journal entries in a same day. 2023-03-22 01:47:11 +08:00
863d7a9368 Simplified the "can_delete" pseudo property of the JournalEntry data model. SQLAlchemy caches the query result. There is no need to cache the result again. 2023-03-22 01:02:09 +08:00
6fd37b21d9 Fixed so that the journal entries that has offset cannot be deleted. 2023-03-22 00:59:43 +08:00
bbf3ee3320 Added the limitation so that the default currency and the currencies in use cannot be deleted. 2023-03-22 00:37:39 +08:00
b60cc7902d Revised the test_delete test in the AccountTestCase test case. 2023-03-22 00:37:26 +08:00
623313b58a Renamed the constants to be upper-cased in test_account.py. 2023-03-22 00:37:26 +08:00
d0d2d77a2e Added the limitation so that essential accounts, like cash, and the accounts in use, cannot be deleted. 2023-03-22 00:37:26 +08:00
494faeffea Revised the toolbar of the reports to fit better in desktop browsers. 2023-03-21 23:16:47 +08:00
871a5fd1d8 Changed the "settings" button to "edit" in the account, currency, and journal entry detail pages. 2023-03-21 23:10:33 +08:00
e615ad2690 Revised the style of the toolbar buttons for better layout on mobile devices. Hid the "Back" button on mobile devices for better layout and saving spaces. 2023-03-21 23:07:05 +08:00
da92a0b42c Replaced the BABEL_DEFAULT_LOCALE configuration variable with the default_locale from the Flask-Babel instance, to get rid of the dependency to the specific configuration variable. 2023-03-21 22:34:44 +08:00
678d0aa773 Fixed the CSS version of Tempus-Dominus in the base template of the test site. 2023-03-21 21:22:48 +08:00
9248ba7e3b Removed the redundant Flask App context from the default_currency_code Jinja2 global and the default_ie_account_code function. They are always under the Flask app context. 2023-03-21 21:17:10 +08:00
446087b212 Added the ACCOUNTING_DEFAULT_CURRENCY and ACCOUNTING_DEFAULT_IE_ACCOUNT configuration to the test site configuration, for demonstration. 2023-03-21 21:15:14 +08:00
a42e7d13a2 Renamed the configuration DEFAULT_CURRENCY, DEFAULT_IE_ACCOUNT, and RECURRING to "ACCOUNTING_DEFAULT_CURRENCY", "ACCOUNTING_DEFAULT_IE_ACCOUNT", and "ACCOUNTING_RECURRING", respectively. 2023-03-21 21:13:03 +08:00
a82f5091f1 Revised the styles of the buttons in the description editor. 2023-03-21 19:50:57 +08:00
3455827c09 Added the recurring transactions. 2023-03-21 19:45:56 +08:00
5dccf99a55 Renamed "regular" to "recurring" in the description editor. 2023-03-21 17:48:19 +08:00
8818b46e01 Moved the tag initialization from the constructor to the __init_tags method in the DescriptionEditor class. 2023-03-21 17:32:07 +08:00
2f3ad99467 Removed redundant code in the templates of the journal entry form. 2023-03-21 11:54:45 +08:00
592910187b Added the common form-debit-credit.html template to reduce the duplicated code for the currency sub-forms in the transaction form. 2023-03-21 11:47:05 +08:00
cb7a0d377f Added the common form-currency.html template to reduce the duplicated code for the currency sub-forms in the transaction form. 2023-03-21 11:26:28 +08:00
79175285f8 Changed "to transfer" to "as transfer", and updated its Font Awesome icon in the toolbar of the journal entries. 2023-03-21 11:04:45 +08:00
fef474977c Adjust the location of the Material Design floating action buttons for mobile screen. 2023-03-21 10:57:08 +08:00
fa1a55cd3d Adjusted the style for the mobile toolbar for Firefox on Android with large font size. 2023-03-21 10:56:21 +08:00
2253ec7e6d Advanced to version 0.7.0. 2023-03-21 00:54:44 +08:00
32aa532548 Updated the Sphinx documentation. 2023-03-21 00:54:26 +08:00
56138f7de3 Updated the translation. 2023-03-21 00:53:52 +08:00
21ef944259 Fixed the error in the navigation menu when there is no matching endpoint. 2023-03-21 00:53:27 +08:00
760f1c2877 Fixed the query to be case-insensitive in the base account selector in the account form and the account selector in the journal entry form. 2023-03-20 23:54:50 +08:00
e377eac407 Fixed the capitalization of the currencies, base accounts, and accounts. 2023-03-20 23:54:49 +08:00
77787eee9f Fixed the search queries to be case-insensitive. 2023-03-20 23:54:38 +08:00
03265a1232 Fixed the text in the buttons to add new journal entries. 2023-03-20 23:16:57 +08:00
079dc1ab6d Renamed the "eid" field to "id" in the LineItemForm form, since the problem is found. It was the "id" property of the enclosing FormField. If we extract the form from FormField, we can still access the "id" field. 2023-03-20 23:06:57 +08:00
d4fe91ec4a Fixed the capitalization of the shortcut periods in the period chooser. 2023-03-20 22:57:04 +08:00
acc5b4d6ea Fixed the capitalization of the label of the number of items in the description editor. 2023-03-20 22:52:35 +08:00
19a93cb4c3 Fixed the text message in the add_journal_entry view. 2023-03-20 22:44:55 +08:00
116089d1d2 Fixed the text message in the add_currency view. 2023-03-20 22:44:26 +08:00
50dd6078c7 Replaced "Need offset" with "Needs Offset" as the text of the badge. 2023-03-20 22:43:53 +08:00
9a4531b26c Revised the title of the delete confirmation modal from "Delete XXX Confirmation" to "Confirm Delete XXX", as suggested by ChatGPT. 2023-03-20 22:38:35 +08:00
b1af1d7425 Renamed "voucher" to "journal entry". 2023-03-20 22:33:14 +08:00
8f909965a9 Renamed "voucher line item" to "journal entry line item". 2023-03-20 20:52:35 +08:00
e26af6f3fc Renamed "side" to "debit-credit". 2023-03-20 20:35:10 +08:00
02fffc3400 Removed the unused offset_original_line_item_id field from the DebitLineItemForm form. 2023-03-20 18:56:38 +08:00
d7d6929bf2 Fixed the parameter passed to the credit line item subform in the transfer voucher form. 2023-03-20 18:53:16 +08:00
e4cc61552e Simplified the parameter passed to the "form-line-item.html" template for the line item subform of the voucher form, to be less error-prone. 2023-03-20 18:53:11 +08:00
d18dd7d4d2 Renamed "summary" to "description" in the voucher line item. 2023-03-20 18:45:50 +08:00
3251660092 Added the SVG favicon from Font Awesome 6 to the test site. 2023-03-20 08:41:37 +08:00
c1235608d8 Renamed "journal entry" to "voucher line item", and "entry type" to "side". 2023-03-19 22:09:40 +08:00
25c45b16ae Removed the unused imports from the "accounting.voucher.utils.original_entries" module. 2023-03-19 14:15:50 +08:00
78f570b81b Removed an excess trailing blank line in test_summary_editor.py. 2023-03-19 14:05:36 +08:00
5db13393cc Renamed "transaction" to "voucher", "cash expense transaction" to "cash disbursement voucher", and "cash income transaction" to "cash receipt voucher". 2023-03-19 13:56:46 +08:00
1e286fbeba Renamed the #originalEntry, #summary, #account, and #amount private attributes of the JavaScript JournalEntryEditor class to #originalEntryText, #summaryText, #accountText, and #amountInput, to avoid confusion with the public attributes with similar names. 2023-03-19 10:42:37 +08:00
d4b3fe67b9 Removed the originalEntryId parameter from the onOpen method of the JavaScript OriginalEntrySelector class. It can be obtained from the JournalEntryEditor instance, and the parameter is not needed anymore. 2023-03-19 10:25:33 +08:00
5d0757c845 Added the JavaScript JournalEntryEditor instance to the parameters of the constructor of the JavaScript OriginalEntrySelector class, so that it always have access to the JournalEntryEditor instance. Removed the JournalEntryEditor instance from the parameters of the onOpen method of the JournalEntryEditor class. 2023-03-19 10:22:18 +08:00
b69a519904 Updated tempus-dominus from 6.2.10 to 6.4.3 in the base template of the test site. 2023-03-19 10:16:12 +08:00
122b7b059c Changed the default date and min date for the Tempus Dominus month chooser from strings to the JavaScript Date objects. 2023-03-19 10:15:32 +08:00
4977847dd8 Changed the entryType attribute of the JavaScript AccountSelector class from public to private, renamed it from entryType to #entryType. 2023-03-19 07:30:03 +08:00
b9b197ea27 Removed the unused #modal property from the JavaScript OriginalEntrySelector class. 2023-03-19 07:27:25 +08:00
132 changed files with 6864 additions and 6331 deletions

View File

@ -25,6 +25,7 @@ include docs/source/_templates/*
include tests/*
exclude tests/test_temp.py
include tests/test_site/*
include tests/test_site/static/*
include tests/test_site/templates/*
include tests/test_site/translations/*
include tests/test_site/translations/*/LC_MESSAGES/*

View File

@ -0,0 +1,45 @@
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

@ -0,0 +1,46 @@
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

@ -0,0 +1,53 @@
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.offset\_alias module
----------------------------------------------------
.. automodule:: accounting.journal_entry.utils.offset_alias
: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

@ -10,8 +10,8 @@ Subpackages
accounting.account
accounting.base_account
accounting.currency
accounting.journal_entry
accounting.report
accounting.transaction
accounting.utils
Submodules

View File

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

View File

@ -1,46 +0,0 @@
accounting.transaction package
==============================
Subpackages
-----------
.. toctree::
:maxdepth: 4
accounting.transaction.forms
accounting.transaction.utils
Submodules
----------
accounting.transaction.converters module
----------------------------------------
.. automodule:: accounting.transaction.converters
: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

@ -1,53 +0,0 @@
accounting.transaction.utils package
====================================
Submodules
----------
accounting.transaction.utils.account\_option module
---------------------------------------------------
.. automodule:: accounting.transaction.utils.account_option
:members:
:undoc-members:
:show-inheritance:
accounting.transaction.utils.offset\_alias module
-------------------------------------------------
.. automodule:: accounting.transaction.utils.offset_alias
:members:
:undoc-members:
:show-inheritance:
accounting.transaction.utils.operators module
---------------------------------------------
.. automodule:: accounting.transaction.utils.operators
:members:
:undoc-members:
:show-inheritance:
accounting.transaction.utils.original\_entries module
-----------------------------------------------------
.. automodule:: accounting.transaction.utils.original_entries
:members:
:undoc-members:
:show-inheritance:
accounting.transaction.utils.summary\_editor module
---------------------------------------------------
.. automodule:: accounting.transaction.utils.summary_editor
:members:
:undoc-members:
:show-inheritance:
Module contents
---------------
.. automodule:: accounting.transaction.utils
:members:
:undoc-members:
:show-inheritance:

View File

@ -20,6 +20,14 @@ 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
---------------------------------
@ -68,14 +76,6 @@ accounting.utils.strip\_text module
:undoc-members:
:show-inheritance:
accounting.utils.txn\_types module
----------------------------------
.. automodule:: accounting.utils.txn_types
:members:
:undoc-members:
:show-inheritance:
accounting.utils.user module
----------------------------

View File

@ -13,7 +13,7 @@ sys.path.insert(0, os.path.abspath('../../src/'))
project = 'Mia! Accounting Flask'
copyright = '2023, imacat'
author = 'imacat'
release = '0.4.0'
release = '0.8.0'
# -- General configuration ---------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration

View File

@ -17,7 +17,7 @@
[metadata]
name = mia-accounting-flask
version = 0.6.0
version = 0.8.0
author = imacat
author_email = imacat@mail.imacat.idv.tw
description = The Mia! Accounting Flask project.

View File

@ -80,8 +80,8 @@ def init_app(app: Flask, user_utils: UserUtilityInterface,
from . import currency
currency.init_app(app, bp)
from . import transaction
transaction.init_app(app, bp)
from . import journal_entry
journal_entry.init_app(app, bp)
from . import report
report.init_app(app, bp)

View File

@ -100,10 +100,11 @@ def init_accounts_command(username: str) -> None:
def __is_need_offset(base_code: str) -> bool:
"""Checks that whether entries in the account need offset.
"""Checks that whether journal entry line items in the account need offset.
:param base_code: The code of the base account.
:return: True if entries in the account need offset, or False otherwise.
:return: True if journal entry line items in the account need offset, or
False otherwise.
"""
# Assets
if base_code[0] == "1":

View File

@ -82,7 +82,7 @@ class AccountForm(FlaskForm):
"""The title."""
is_need_offset = BooleanField(
validators=[NoOffsetNominalAccount()])
"""Whether the the entries of this account need offset."""
"""Whether the the journal entry line items of this account need offset."""
def populate_obj(self, obj: Account) -> None:
"""Populates the form data into an account object.

View File

@ -40,14 +40,14 @@ def get_account_query() -> list[Account]:
conditions: list[sa.BinaryExpression] = []
for k in keywords:
l10n: list[AccountL10n] = AccountL10n.query\
.filter(AccountL10n.title.contains(k)).all()
.filter(AccountL10n.title.icontains(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.contains(k),
Account.title_l10n.icontains(k),
code.contains(k),
Account.id.in_(l10n_matches)]
if k in gettext("Need offset"):
if k in gettext("Needs Offset"):
sub_conditions.append(Account.is_need_offset)
conditions.append(sa.or_(*sub_conditions))

View File

@ -157,6 +157,9 @@ 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()

View File

@ -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.contains(k)).all()
.filter(BaseAccountL10n.title.icontains(k)).all()
l10n_matches: set[str] = {x.account_code for x in l10n}
conditions.append(sa.or_(BaseAccount.code.contains(k),
BaseAccount.title_l10n.contains(k),
BaseAccount.title_l10n.icontains(k),
BaseAccount.code.in_(l10n_matches)))
return BaseAccount.query.filter(*conditions)\
.order_by(BaseAccount.code).all()

View File

@ -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.contains(k)).all()
.filter(CurrencyL10n.name.icontains(k)).all()
l10n_matches: set[str] = {x.account_code for x in l10n}
conditions.append(sa.or_(Currency.code.contains(k),
Currency.name_l10n.contains(k),
conditions.append(sa.or_(Currency.code.icontains(k),
Currency.name_l10n.icontains(k),
Currency.code.in_(l10n_matches)))
return Currency.query.filter(*conditions)\
.order_by(Currency.code).all()

View File

@ -89,7 +89,7 @@ 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(s(lazy_gettext("The currency is added successfully.")), "success")
return redirect(inherit_next(__get_detail_uri(currency)))
@ -160,6 +160,9 @@ 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")

View File

@ -14,7 +14,7 @@
# 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 transaction management.
"""The journal entry management.
"""
from flask import Flask, Blueprint
@ -27,11 +27,11 @@ def init_app(app: Flask, bp: Blueprint) -> None:
:param bp: The blueprint of the accounting application.
:return: None.
"""
from .converters import TransactionConverter, TransactionTypeConverter, \
from .converters import JournalEntryConverter, JournalEntryTypeConverter, \
DateConverter
app.url_map.converters["transaction"] = TransactionConverter
app.url_map.converters["transactionType"] = TransactionTypeConverter
app.url_map.converters["journalEntry"] = JournalEntryConverter
app.url_map.converters["journalEntryType"] = JournalEntryTypeConverter
app.url_map.converters["date"] = DateConverter
from .views import bp as transaction_bp
bp.register_blueprint(transaction_bp, url_prefix="/transactions")
from .views import bp as journal_entry_bp
bp.register_blueprint(journal_entry_bp, url_prefix="/journal-entries")

View File

@ -0,0 +1,107 @@
# The Mia! Accounting Flask 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.
"""
from datetime import date
from flask import abort
from sqlalchemy.orm import selectinload
from werkzeug.routing import BaseConverter
from accounting.models import JournalEntry, JournalEntryLineItem
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 = JournalEntry.query\
.join(JournalEntryLineItem)\
.filter(JournalEntry.id == value)\
.options(selectinload(JournalEntry.line_items)
.selectinload(JournalEntryLineItem.offsets)
.selectinload(JournalEntryLineItem.journal_entry))\
.first()
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) -> date:
"""Converts an ISO date to a date.
:param value: The ISO date.
:return: The corresponding date.
"""
try:
return date.fromisoformat(value)
except ValueError:
abort(404)
def to_url(self, value: date) -> str:
"""Converts a date to its ISO date.
:param value: The date.
:return: The ISO date.
"""
return value.isoformat()

View File

@ -14,9 +14,9 @@
# 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 transaction management.
"""The forms for the journal entry management.
"""
from .reorder import sort_transactions_in, TransactionReorderForm
from .transaction import TransactionForm, IncomeTransactionForm, \
ExpenseTransactionForm, TransferTransactionForm
from .reorder import sort_journal_entries_in, JournalEntryReorderForm
from .journal_entry import JournalEntryForm, CashReceiptJournalEntryForm, \
CashDisbursementJournalEntryForm, TransferJournalEntryForm

View File

@ -14,7 +14,7 @@
# 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 transaction management.
"""The currency sub-forms for the journal entry management.
"""
from decimal import Decimal
@ -28,11 +28,11 @@ from wtforms.validators import DataRequired
from accounting import db
from accounting.locale import lazy_gettext
from accounting.models import Currency, JournalEntry
from accounting.transaction.utils.offset_alias import offset_alias
from accounting.models import Currency, JournalEntryLineItem
from accounting.journal_entry.utils.offset_alias import offset_alias
from accounting.utils.cast import be
from accounting.utils.strip_text import strip_text
from .journal_entry import JournalEntryForm, CreditEntryForm, DebitEntryForm
from .line_item import LineItemForm, CreditLineItemForm, DebitLineItemForm
CURRENCY_REQUIRED: DataRequired = DataRequired(
lazy_gettext("Please select the currency."))
@ -50,26 +50,28 @@ class CurrencyExists:
"The currency does not exist."))
class SameCurrencyAsOriginalEntries:
"""The validator to check if the currency is the same as the original
entries."""
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_entry_id: set[int] = {x.original_entry_id.data
for x in form.entries
if x.original_entry_id.data is not None}
if len(original_entry_id) == 0:
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_entry_currency_codes: set[str] = set(db.session.scalars(
sa.select(JournalEntry.currency_code)
.filter(JournalEntry.id.in_(original_entry_id))).all())
for currency_code in original_entry_currency_codes:
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 entry."))
"The currency must be the same as the"
" original line item."))
class KeepCurrencyWhenHavingOffset:
@ -81,31 +83,35 @@ class KeepCurrencyWhenHavingOffset:
if field.data is None:
return
offset: sa.Alias = offset_alias()
original_entries: list[JournalEntry] = JournalEntry.query\
.join(offset, be(JournalEntry.id == offset.c.original_entry_id),
original_line_items: list[JournalEntryLineItem]\
= JournalEntryLineItem.query\
.join(offset, be(JournalEntryLineItem.id
== offset.c.original_line_item_id),
isouter=True)\
.filter(JournalEntry.id.in_({x.eid.data for x in form.entries
if x.eid.data is not None}))\
.group_by(JournalEntry.id, JournalEntry.currency_code)\
.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_entry in original_entries:
if original_entry.currency_code != field.data:
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 NeedSomeJournalEntries:
"""The validator to check if there is any journal entry sub-form."""
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 journal entries."))
"Please add some line items."))
class IsBalanced:
"""The validator to check that the total amount of the debit and credit
entries are equal."""
line items are equal."""
def __call__(self, form: FlaskForm, field: BooleanField) -> None:
assert isinstance(form, TransferCurrencyForm)
@ -117,29 +123,29 @@ class IsBalanced:
class CurrencyForm(FlaskForm):
"""The form to create or edit a currency in a transaction."""
"""The form to create or edit a currency in a journal entry."""
no = IntegerField()
"""The order in the transaction."""
"""The order in the journal entry."""
code = StringField()
"""The currency code."""
whole_form = BooleanField()
"""The pseudo field for the whole form validators."""
@property
def entries(self) -> list[JournalEntryForm]:
"""Returns the journal entry sub-forms.
def line_items(self) -> list[LineItemForm]:
"""Returns the line item sub-forms.
:return: The journal entry sub-forms.
:return: The line item sub-forms.
"""
entry_forms: list[JournalEntryForm] = []
if isinstance(self, IncomeCurrencyForm):
entry_forms.extend([x.form for x in self.credit])
elif isinstance(self, ExpenseCurrencyForm):
entry_forms.extend([x.form for x in self.debit])
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):
entry_forms.extend([x.form for x in self.debit])
entry_forms.extend([x.form for x in self.credit])
return entry_forms
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:
@ -148,48 +154,50 @@ class CurrencyForm(FlaskForm):
:return: True if the currency code should not be changed, or False
otherwise
"""
entry_forms: list[JournalEntryForm] = self.entries
original_entry_id: set[int] \
= {x.original_entry_id.data for x in entry_forms
if x.original_entry_id.data is not None}
if len(original_entry_id) > 0:
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
entry_id: set[int] = {x.eid.data for x in entry_forms
if x.eid.data is not None}
select: sa.Select = sa.select(sa.func.count(JournalEntry.id))\
.filter(JournalEntry.original_entry_id.in_(entry_id))
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 IncomeCurrencyForm(CurrencyForm):
"""The form to create or edit a currency in a cash income transaction."""
class CashReceiptCurrencyForm(CurrencyForm):
"""The form to create or edit a currency in a
cash receipt journal entry."""
no = IntegerField()
"""The order in the transaction."""
"""The order in the journal entry."""
code = StringField(
filters=[strip_text],
validators=[CURRENCY_REQUIRED,
CurrencyExists(),
SameCurrencyAsOriginalEntries(),
SameCurrencyAsOriginalLineItems(),
KeepCurrencyWhenHavingOffset()])
"""The currency code."""
credit = FieldList(FormField(CreditEntryForm),
validators=[NeedSomeJournalEntries()])
"""The credit entries."""
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 journal entries.
"""Returns the total amount of the credit line items.
:return: The total amount of the credit journal entries.
: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 journal entry errors, without the errors in their
"""Returns the credit line item errors, without the errors in their
sub-forms.
:return:
@ -198,35 +206,36 @@ class IncomeCurrencyForm(CurrencyForm):
if isinstance(x, str) or isinstance(x, LazyString)]
class ExpenseCurrencyForm(CurrencyForm):
"""The form to create or edit a currency in a cash expense transaction."""
class CashDisbursementCurrencyForm(CurrencyForm):
"""The form to create or edit a currency in a
cash disbursement journal entry."""
no = IntegerField()
"""The order in the transaction."""
"""The order in the journal entry."""
code = StringField(
filters=[strip_text],
validators=[CURRENCY_REQUIRED,
CurrencyExists(),
SameCurrencyAsOriginalEntries(),
SameCurrencyAsOriginalLineItems(),
KeepCurrencyWhenHavingOffset()])
"""The currency code."""
debit = FieldList(FormField(DebitEntryForm),
validators=[NeedSomeJournalEntries()])
"""The debit entries."""
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 journal entries.
"""Returns the total amount of the debit line items.
:return: The total amount of the debit journal entries.
: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 journal entry errors, without the errors in their
"""Returns the debit line item errors, without the errors in their
sub-forms.
:return:
@ -236,46 +245,46 @@ class ExpenseCurrencyForm(CurrencyForm):
class TransferCurrencyForm(CurrencyForm):
"""The form to create or edit a currency in a transfer transaction."""
"""The form to create or edit a currency in a transfer journal entry."""
no = IntegerField()
"""The order in the transaction."""
"""The order in the journal entry."""
code = StringField(
filters=[strip_text],
validators=[CURRENCY_REQUIRED,
CurrencyExists(),
SameCurrencyAsOriginalEntries(),
SameCurrencyAsOriginalLineItems(),
KeepCurrencyWhenHavingOffset()])
"""The currency code."""
debit = FieldList(FormField(DebitEntryForm),
validators=[NeedSomeJournalEntries()])
"""The debit entries."""
credit = FieldList(FormField(CreditEntryForm),
validators=[NeedSomeJournalEntries()])
"""The credit entries."""
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 journal entries.
"""Returns the total amount of the debit line items.
:return: The total amount of the debit journal entries.
: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 journal entries.
"""Returns the total amount of the credit line items.
:return: The total amount of the credit journal entries.
: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 journal entry errors, without the errors in their
"""Returns the debit line item errors, without the errors in their
sub-forms.
:return:
@ -285,7 +294,7 @@ class TransferCurrencyForm(CurrencyForm):
@property
def credit_errors(self) -> list[str | LazyString]:
"""Returns the credit journal entry errors, without the errors in their
"""Returns the credit line item errors, without the errors in their
sub-forms.
:return:

View File

@ -0,0 +1,593 @@
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/18
# 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 journal entry forms for the journal entry management.
"""
import datetime as dt
import typing as t
from abc import ABC, abstractmethod
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.locale import lazy_gettext
from accounting.models import JournalEntry, Account, JournalEntryLineItem, \
JournalEntryCurrency
from accounting.journal_entry.utils.account_option import AccountOption
from accounting.journal_entry.utils.original_line_items import \
get_selectable_original_line_items
from accounting.journal_entry.utils.description_editor import DescriptionEditor
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: t.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.date: DateField
self.__set_date(obj, self.date.data)
obj.note = self.note.data
collector_cls: t.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.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.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)
T = t.TypeVar("T", bound=JournalEntryForm)
"""A journal entry form variant."""
class LineItemCollector(t.Generic[T], 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

@ -14,7 +14,7 @@
# 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 sub-forms for the transaction management.
"""The line item sub-forms for the journal entry management.
"""
import re
@ -30,7 +30,7 @@ from wtforms.validators import DataRequired, Optional
from accounting import db
from accounting.locale import lazy_gettext
from accounting.models import Account, JournalEntry
from accounting.models import Account, JournalEntryLineItem
from accounting.template_filters import format_amount
from accounting.utils.cast import be
from accounting.utils.random_id import new_id
@ -42,64 +42,67 @@ ACCOUNT_REQUIRED: DataRequired = DataRequired(
"""The validator to check if the account code is empty."""
class OriginalEntryExists:
"""The validator to check if the original entry exists."""
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(JournalEntry, field.data) is None:
if db.session.get(JournalEntryLineItem, field.data) is None:
raise ValidationError(lazy_gettext(
"The original entry does not exist."))
"The original line item does not exist."))
class OriginalEntryOppositeSide:
"""The validator to check if the original entry is on the opposite side."""
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_entry: JournalEntry | None \
= db.session.get(JournalEntry, field.data)
if original_entry is None:
original_line_item: JournalEntryLineItem | None \
= db.session.get(JournalEntryLineItem, field.data)
if original_line_item is None:
return
if isinstance(form, CreditEntryForm) and original_entry.is_debit:
if isinstance(form, CreditLineItemForm) \
and original_line_item.is_debit:
return
if isinstance(form, DebitEntryForm) and not original_entry.is_debit:
if isinstance(form, DebitLineItemForm) \
and not original_line_item.is_debit:
return
raise ValidationError(lazy_gettext(
"The original entry is on the same side."))
"The original line item is on the same debit or credit."))
class OriginalEntryNeedOffset:
"""The validator to check if the original entry needs offset."""
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_entry: JournalEntry | None \
= db.session.get(JournalEntry, field.data)
if original_entry is None:
original_line_item: JournalEntryLineItem | None \
= db.session.get(JournalEntryLineItem, field.data)
if original_line_item is None:
return
if not original_entry.account.is_need_offset:
if not original_line_item.account.is_need_offset:
raise ValidationError(lazy_gettext(
"The original entry does not need offset."))
"The original line item does not need offset."))
class OriginalEntryNotOffset:
"""The validator to check if the original entry is not itself an offset
entry."""
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_entry: JournalEntry | None \
= db.session.get(JournalEntry, field.data)
if original_entry is None:
original_line_item: JournalEntryLineItem | None \
= db.session.get(JournalEntryLineItem, field.data)
if original_line_item is None:
return
if original_entry.original_entry_id is not None:
if original_line_item.original_line_item_id is not None:
raise ValidationError(lazy_gettext(
"The original entry cannot be an offset entry."))
"The original line item cannot be an offset item."))
class AccountExists:
@ -114,7 +117,7 @@ class AccountExists:
class IsDebitAccount:
"""The validator to check if the account is for debit journal entries."""
"""The validator to check if the account is for debit line items."""
def __call__(self, form: FlaskForm, field: StringField) -> None:
if field.data is None:
@ -124,11 +127,11 @@ class IsDebitAccount:
and not field.data.startswith("3353-"):
return
raise ValidationError(lazy_gettext(
"This account is not for debit entries."))
"This account is not for debit line items."))
class IsCreditAccount:
"""The validator to check if the account is for credit journal entries."""
"""The validator to check if the account is for credit line items."""
def __call__(self, form: FlaskForm, field: StringField) -> None:
if field.data is None:
@ -138,73 +141,75 @@ class IsCreditAccount:
and not field.data.startswith("3353-"):
return
raise ValidationError(lazy_gettext(
"This account is not for credit entries."))
"This account is not for credit line items."))
class SameAccountAsOriginalEntry:
"""The validator to check if the account is the same as the original
entry."""
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, JournalEntryForm)
if field.data is None or form.original_entry_id.data is None:
assert isinstance(form, LineItemForm)
if field.data is None or form.original_line_item_id.data is None:
return
original_entry: JournalEntry | None \
= db.session.get(JournalEntry, form.original_entry_id.data)
if original_entry is None:
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_entry.account_code:
if field.data != original_line_item.account_code:
raise ValidationError(lazy_gettext(
"The account must be the same as the original entry."))
"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, JournalEntryForm)
if field.data is None or form.eid.data is None:
assert isinstance(form, LineItemForm)
if field.data is None or form.id.data is None:
return
entry: JournalEntry | None = db.session.query(JournalEntry)\
.filter(JournalEntry.id == form.eid.data)\
.options(selectinload(JournalEntry.offsets)).first()
if entry is None or len(entry.offsets) == 0:
line_item: JournalEntryLineItem | None = db.session\
.query(JournalEntryLineItem)\
.filter(JournalEntryLineItem.id == form.id.data)\
.options(selectinload(JournalEntryLineItem.offsets)).first()
if line_item is None or len(line_item.offsets) == 0:
return
if field.data != entry.account_code:
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 journal entry does not start from
the debit side."""
"""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, DebitEntryForm)
assert isinstance(form, DebitLineItemForm)
if field.data is None \
or field.data[0] != "2" \
or form.original_entry_id.data is not None:
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 entry cannot start from the debit side."))
"A payable line item cannot start from debit."))
class NotStartReceivableFromCredit:
"""The validator to check that a receivable journal entry does not start
from the credit side."""
"""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, CreditEntryForm)
assert isinstance(form, CreditLineItemForm)
if field.data is None \
or field.data[0] != "1" \
or form.original_entry_id.data is not None:
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 entry cannot start from the credit side."))
"A receivable line item cannot start from credit."))
class PositiveAmount:
@ -218,55 +223,61 @@ class PositiveAmount:
"Please fill in a positive amount."))
class NotExceedingOriginalEntryNetBalance:
class NotExceedingOriginalLineItemNetBalance:
"""The validator to check if the amount exceeds the net balance of the
original entry."""
original line item."""
def __call__(self, form: FlaskForm, field: DecimalField) -> None:
assert isinstance(form, JournalEntryForm)
if field.data is None or form.original_entry_id.data is None:
assert isinstance(form, LineItemForm)
if field.data is None or form.original_line_item_id.data is None:
return
original_entry: JournalEntry | None \
= db.session.get(JournalEntry, form.original_entry_id.data)
if original_entry is None:
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, DebitEntryForm)
existing_entry_id: set[int] = set()
if form.txn_form.obj is not None:
existing_entry_id = {x.id for x in form.txn_form.obj.entries}
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(
(be(JournalEntry.is_debit == is_debit), JournalEntry.amount),
else_=-JournalEntry.amount))
(be(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(be(JournalEntry.original_entry_id == original_entry.id),
JournalEntry.id.not_in(existing_entry_id)))
.filter(be(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.txn_form.entries
if x.original_entry_id.data == original_entry.id
[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_entry.amount - offset_total_but_form \
- offset_total_on_form
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 entry.", balance=format_amount(net_balance)))
" 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, JournalEntryForm)
if field.data is None or form.eid.data is None:
assert isinstance(form, LineItemForm)
if field.data is None or form.id.data is None:
return
is_debit: bool = isinstance(form, DebitEntryForm)
is_debit: bool = isinstance(form, DebitLineItemForm)
select_offset_total: sa.Select = sa.select(sa.func.sum(sa.case(
(JournalEntry.is_debit != is_debit, JournalEntry.amount),
else_=-JournalEntry.amount)))\
.filter(be(JournalEntry.original_entry_id == form.eid.data))
(JournalEntryLineItem.is_debit != is_debit,
JournalEntryLineItem.amount),
else_=-JournalEntryLineItem.amount)))\
.filter(be(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(
@ -274,29 +285,31 @@ class NotLessThanOffsetTotal:
total=format_amount(offset_total)))
class JournalEntryForm(FlaskForm):
"""The base form to create or edit a journal entry."""
eid = IntegerField()
"""The existing journal entry ID."""
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_entry_id = IntegerField()
"""The Id of the original entry."""
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 transaction form.
"""Constructs a base line item form.
:param args: The arguments.
:param kwargs: The keyword arguments.
"""
super().__init__(*args, **kwargs)
from .transaction import TransactionForm
self.txn_form: TransactionForm | None = None
"""The source transaction form."""
from .journal_entry import JournalEntryForm
self.journal_entry_form: JournalEntryForm | None = None
"""The source journal entry form."""
@property
def account_text(self) -> str:
@ -312,51 +325,51 @@ class JournalEntryForm(FlaskForm):
return str(account)
@property
def __original_entry(self) -> JournalEntry | None:
"""Returns the original entry.
def __original_line_item(self) -> JournalEntryLineItem | None:
"""Returns the original line item.
:return: The original entry.
:return: The original line item.
"""
if not hasattr(self, "____original_entry"):
def get_entry() -> JournalEntry | None:
if self.original_entry_id.data is None:
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(JournalEntry,
self.original_entry_id.data)
setattr(self, "____original_entry", get_entry())
return getattr(self, "____original_entry")
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_entry_date(self) -> date | None:
"""Returns the text representation of the original entry.
def original_line_item_date(self) -> date | None:
"""Returns the text representation of the original line item.
:return: The text representation of the original entry.
:return: The text representation of the original line item.
"""
return None if self.__original_entry is None \
else self.__original_entry.transaction.date
return None if self.__original_line_item is None \
else self.__original_line_item.journal_entry.date
@property
def original_entry_text(self) -> str | None:
"""Returns the text representation of the original entry.
def original_line_item_text(self) -> str | None:
"""Returns the text representation of the original line item.
:return: The text representation of the original entry.
:return: The text representation of the original line item.
"""
return None if self.__original_entry is None \
else str(self.__original_entry)
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 entry needs offset.
"""Returns whether the line item needs offset.
:return: True if the entry needs offset, or False otherwise.
: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, CreditEntryForm):
if isinstance(self, CreditLineItemForm):
return False
elif self.account_code.data[0] == "2":
if isinstance(self, DebitEntryForm):
if isinstance(self, DebitLineItemForm):
return False
else:
return False
@ -364,21 +377,23 @@ class JournalEntryForm(FlaskForm):
return account is not None and account.is_need_offset
@property
def offsets(self) -> list[JournalEntry]:
def offsets(self) -> list[JournalEntryLineItem]:
"""Returns the offsets.
:return: The offsets.
"""
if not hasattr(self, "__offsets"):
def get_offsets() -> list[JournalEntry]:
if not self.is_need_offset or self.eid.data is None:
def get_offsets() -> list[JournalEntryLineItem]:
if not self.is_need_offset or self.id.data is None:
return []
return JournalEntry.query\
.filter(JournalEntry.original_entry_id == self.eid.data)\
.options(selectinload(JournalEntry.transaction),
selectinload(JournalEntry.account),
selectinload(JournalEntry.offsets)
.selectinload(JournalEntry.transaction)).all()
return JournalEntryLineItem.query\
.filter(JournalEntryLineItem.original_line_item_id
== self.id.data)\
.options(selectinload(JournalEntryLineItem.journal_entry),
selectinload(JournalEntryLineItem.account),
selectinload(JournalEntryLineItem.offsets)
.selectinload(
JournalEntryLineItem.journal_entry)).all()
setattr(self, "__offsets", get_offsets())
return getattr(self, "__offsets")
@ -390,9 +405,9 @@ class JournalEntryForm(FlaskForm):
"""
if not hasattr(self, "__offset_total"):
def get_offset_total():
if not self.is_need_offset or self.eid.data is None:
if not self.is_need_offset or self.id.data is None:
return None
is_debit: bool = isinstance(self, DebitEntryForm)
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())
@ -404,7 +419,7 @@ class JournalEntryForm(FlaskForm):
:return: The net balance.
"""
if not self.is_need_offset or self.eid.data is None \
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
@ -422,50 +437,48 @@ class JournalEntryForm(FlaskForm):
return all_errors
class DebitEntryForm(JournalEntryForm):
"""The form to create or edit a debit journal entry."""
eid = IntegerField()
"""The existing journal entry ID."""
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_entry_id = IntegerField(
original_line_item_id = IntegerField(
validators=[Optional(),
OriginalEntryExists(),
OriginalEntryOppositeSide(),
OriginalEntryNeedOffset(),
OriginalEntryNotOffset()])
"""The Id of the original entry."""
OriginalLineItemExists(),
OriginalLineItemOppositeDebitCredit(),
OriginalLineItemNeedOffset(),
OriginalLineItemNotOffset()])
"""The ID of the original line item."""
account_code = StringField(
filters=[strip_text],
validators=[ACCOUNT_REQUIRED,
AccountExists(),
IsDebitAccount(),
SameAccountAsOriginalEntry(),
SameAccountAsOriginalLineItem(),
KeepAccountWhenHavingOffset(),
NotStartPayableFromDebit()])
"""The account code."""
offset_original_entry_id = IntegerField()
"""The Id of the original entry."""
summary = StringField(filters=[strip_text])
"""The summary."""
description = StringField(filters=[strip_text])
"""The description."""
amount = DecimalField(
validators=[PositiveAmount(),
NotExceedingOriginalEntryNetBalance(),
NotExceedingOriginalLineItemNetBalance(),
NotLessThanOffsetTotal()])
"""The amount."""
def populate_obj(self, obj: JournalEntry) -> None:
"""Populates the form data into a journal entry object.
def populate_obj(self, obj: JournalEntryLineItem) -> None:
"""Populates the form data into a line item object.
:param obj: The journal entry object.
:param obj: The line item object.
:return: None.
"""
is_new: bool = obj.id is None
if is_new:
obj.id = new_id(JournalEntry)
obj.original_entry_id = self.original_entry_id.data
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.summary = self.summary.data
obj.description = self.description.data
obj.is_debit = True
obj.amount = self.amount.data
if is_new:
@ -474,48 +487,48 @@ class DebitEntryForm(JournalEntryForm):
obj.updated_by_id = current_user_pk
class CreditEntryForm(JournalEntryForm):
"""The form to create or edit a credit journal entry."""
eid = IntegerField()
"""The existing journal entry ID."""
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_entry_id = IntegerField(
original_line_item_id = IntegerField(
validators=[Optional(),
OriginalEntryExists(),
OriginalEntryOppositeSide(),
OriginalEntryNeedOffset(),
OriginalEntryNotOffset()])
"""The Id of the original entry."""
OriginalLineItemExists(),
OriginalLineItemOppositeDebitCredit(),
OriginalLineItemNeedOffset(),
OriginalLineItemNotOffset()])
"""The ID of the original line item."""
account_code = StringField(
filters=[strip_text],
validators=[ACCOUNT_REQUIRED,
AccountExists(),
IsCreditAccount(),
SameAccountAsOriginalEntry(),
SameAccountAsOriginalLineItem(),
KeepAccountWhenHavingOffset(),
NotStartReceivableFromCredit()])
"""The account code."""
summary = StringField(filters=[strip_text])
"""The summary."""
description = StringField(filters=[strip_text])
"""The description."""
amount = DecimalField(
validators=[PositiveAmount(),
NotExceedingOriginalEntryNetBalance(),
NotExceedingOriginalLineItemNetBalance(),
NotLessThanOffsetTotal()])
"""The amount."""
def populate_obj(self, obj: JournalEntry) -> None:
"""Populates the form data into a journal entry object.
def populate_obj(self, obj: JournalEntryLineItem) -> None:
"""Populates the form data into a line item object.
:param obj: The journal entry object.
:param obj: The line item object.
:return: None.
"""
is_new: bool = obj.id is None
if is_new:
obj.id = new_id(JournalEntry)
obj.original_entry_id = self.original_entry_id.data
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.summary = self.summary.data
obj.description = self.description.data
obj.is_debit = False
obj.amount = self.amount.data
if is_new:

View File

@ -0,0 +1,95 @@
# The Mia! Accounting Flask 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.
"""
from datetime import date
import sqlalchemy as sa
from flask import request
from accounting import db
from accounting.models import JournalEntry
def sort_journal_entries_in(journal_entry_date: date,
exclude: int | None = None) -> None:
"""Sorts the journal entries under a date after changing the date or
deleting a journal entry.
:param journal_entry_date: The date of the journal entry.
:param exclude: The journal entry ID to exclude.
:return: None.
"""
conditions: list[sa.BinaryExpression] \
= [JournalEntry.date == journal_entry_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, journal_entry_date: date):
"""Constructs the form to reorder the journal entries in a day.
:param journal_entry_date: The date.
"""
self.date: date = journal_entry_date
self.is_modified: bool = False
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

@ -14,7 +14,7 @@
# 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 template filters for the transaction management.
"""The template filters for the journal entry management.
"""
from decimal import Decimal
@ -26,10 +26,10 @@ from flask import request
def with_type(uri: str) -> str:
"""Adds the transaction type to the URI, if it is specified.
"""Adds the journal entry type to the URI, if it is specified.
:param uri: The URI.
:return: The result URL, optionally with the transaction type added.
:return: The result URL, optionally with the journal entry type added.
"""
if "as" not in request.args:
return uri
@ -43,10 +43,10 @@ def with_type(uri: str) -> str:
def to_transfer(uri: str) -> str:
"""Adds the transfer transaction type to the URI.
"""Adds the transfer journal entry type to the URI.
:param uri: The URI.
:return: The result URL, with the transfer transaction type added.
:return: The result URL, with the transfer journal entry type added.
"""
uri_p: ParseResult = urlparse(uri)
params: list[tuple[str, str]] = parse_qsl(uri_p.query)

View File

@ -14,6 +14,6 @@
# 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 transaction management.
"""The utilities for the journal entry management.
"""

View File

@ -14,7 +14,7 @@
# 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 transaction management.
"""The account option for the journal entry management.
"""
from accounting.models import Account

View File

@ -14,22 +14,24 @@
# 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 summary editor.
"""The description editor.
"""
import re
import typing as t
import sqlalchemy as sa
from flask import current_app
from accounting import db
from accounting.models import Account, JournalEntry
from accounting.models import Account, JournalEntryLineItem
class SummaryAccount:
"""An account for a summary tag."""
class DescriptionAccount:
"""An account for a description tag."""
def __init__(self, account: Account, freq: int):
"""Constructs an account for a summary tag.
"""Constructs an account for a description tag.
:param account: The account.
:param freq: The frequency of the tag with the account.
@ -59,17 +61,17 @@ class SummaryAccount:
self.freq = self.freq + freq
class SummaryTag:
"""A summary tag."""
class DescriptionTag:
"""A description tag."""
def __init__(self, name: str):
"""Constructs a summary tag.
"""Constructs a description tag.
:param name: The tag name.
"""
self.name: str = name
"""The tag name."""
self.__account_dict: dict[int, SummaryAccount] = {}
self.__account_dict: dict[int, DescriptionAccount] = {}
"""The accounts that come with the tag, in the order of their
frequency."""
self.freq: int = 0
@ -89,11 +91,11 @@ class SummaryTag:
:param freq: The frequency of the tag name with the account.
:return: None.
"""
self.__account_dict[account.id] = SummaryAccount(account, freq)
self.__account_dict[account.id] = DescriptionAccount(account, freq)
self.freq = self.freq + freq
@property
def accounts(self) -> list[SummaryAccount]:
def accounts(self) -> list[DescriptionAccount]:
"""Returns the accounts by the order of their frequencies.
:return: The accounts by the order of their frequencies.
@ -109,17 +111,17 @@ class SummaryTag:
return [x.code for x in self.accounts]
class SummaryType:
"""A summary type"""
class DescriptionType:
"""A description type"""
def __init__(self, type_id: t.Literal["general", "travel", "bus"]):
"""Constructs a summary type.
"""Constructs a description type.
:param type_id: The type ID, either "general", "travel", or "bus".
"""
self.id: t.Literal["general", "travel", "bus"] = type_id
"""The type ID."""
self.__tag_dict: dict[str, SummaryTag] = {}
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:
@ -131,11 +133,11 @@ class SummaryType:
:return: None.
"""
if name not in self.__tag_dict:
self.__tag_dict[name] = SummaryTag(name)
self.__tag_dict[name] = DescriptionTag(name)
self.__tag_dict[name].add_account(account, freq)
@property
def tags(self) -> list[SummaryTag]:
def tags(self) -> list[DescriptionTag]:
"""Returns the tags by the order of their frequencies.
:return: The tags by the order of their frequencies.
@ -143,26 +145,51 @@ class SummaryType:
return sorted(self.__tag_dict.values(), key=lambda x: -x.freq)
class SummaryEntryType:
"""A summary type"""
class DescriptionRecurring:
"""A recurring transaction."""
def __init__(self, entry_type_id: t.Literal["debit", "credit"]):
"""Constructs a summary entry type.
def __init__(self, name: str, template: str, account: Account):
"""Constructs a recurring transaction.
:param entry_type_id: The entry type ID, either "debit" or "credit".
:param name: The name.
:param template: The template.
:param account: The account.
"""
self.type: t.Literal["debit", "credit"] = entry_type_id
"""The entry type."""
self.general: SummaryType = SummaryType("general")
self.name: str = name
self.template: str = template
self.account: DescriptionAccount = DescriptionAccount(account, 0)
@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: t.Literal["debit", "credit"]):
"""Constructs the description on debit or credit.
:param debit_credit: Either "debit" or "credit".
"""
self.debit_credit: t.Literal["debit", "credit"] = debit_credit
"""Either debit or credit."""
self.general: DescriptionType = DescriptionType("general")
"""The general tags."""
self.travel: SummaryType = SummaryType("travel")
self.travel: DescriptionType = DescriptionType("travel")
"""The travel tags."""
self.bus: SummaryType = SummaryType("bus")
self.bus: DescriptionType = DescriptionType("bus")
"""The bus tags."""
self.__type_dict: dict[t.Literal["general", "travel", "bus"],
SummaryType] \
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: t.Literal["general", "travel", "bus"],
name: str, account: Account, freq: int) -> None:
@ -177,13 +204,13 @@ class SummaryEntryType:
self.__type_dict[tag_type].add_tag(name, account, freq)
@property
def accounts(self) -> list[SummaryAccount]:
"""Returns the suggested accounts of all tags in the summary editor in
the entry type, in their frequency order.
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, SummaryAccount] = {}
accounts: dict[int, DescriptionAccount] = {}
freq: dict[int, int] = {}
for tag_type in self.__type_dict.values():
for tag in tag_type.tags:
@ -193,44 +220,104 @@ class SummaryEntryType:
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 SummaryEditor:
"""The summary editor."""
class DescriptionEditor:
"""The description editor."""
def __init__(self):
"""Constructs the summary editor."""
self.debit: SummaryEntryType = SummaryEntryType("debit")
"""Constructs the description editor."""
self.debit: DescriptionDebitCredit = DescriptionDebitCredit("debit")
"""The debit tags."""
self.credit: SummaryEntryType = SummaryEntryType("credit")
self.credit: DescriptionDebitCredit = DescriptionDebitCredit("credit")
"""The credit tags."""
entry_type: sa.Label = sa.case((JournalEntry.is_debit, "debit"),
else_="credit").label("entry_type")
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(
(JournalEntry.summary.like("_%—_%—_%→_%"), "bus"),
(sa.or_(JournalEntry.summary.like("_%—_%→_%"),
JournalEntry.summary.like("_%—_%↔_%")), "travel"),
(JournalEntryLineItem.description.like("_%—_%—_%→_%"), "bus"),
(sa.or_(JournalEntryLineItem.description.like("_%—_%→_%"),
JournalEntryLineItem.description.like("_%—_%↔_%")),
"travel"),
else_="general").label("tag_type")
tag: sa.Label = get_prefix(JournalEntry.summary, "").label("tag")
select: sa.Select = sa.Select(entry_type, tag_type, tag,
JournalEntry.account_id,
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(JournalEntry.summary.is_not(None),
JournalEntry.summary.like("_%—_%"),
JournalEntry.original_entry_id.is_(None))\
.group_by(entry_type, tag_type, tag, JournalEntry.account_id)
.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()}
entry_type_dict: dict[t.Literal["debit", "credit"], SummaryEntryType] \
= {x.type: x for x in {self.debit, self.credit}}
debit_credit_dict: dict[t.Literal["debit", "credit"],
DescriptionDebitCredit] \
= {x.debit_credit: x for x in {self.debit, self.credit}}
for row in result:
entry_type_dict[row.entry_type].add_tag(
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.
"""
if "ACCOUNTING_RECURRING" not in current_app.config:
return
data: list[tuple[t.Literal["debit", "credit"], str, str, str]] \
= [x.split("|")
for x in current_app.config["ACCOUNTING_RECURRING"].split(",")]
debit_credit_dict: dict[t.Literal["debit", "credit"],
DescriptionDebitCredit] \
= {x.debit_credit: x for x in {self.debit, self.credit}}
accounts: dict[str, Account] \
= self.__get_accounts({x[1] for x in data})
for row in data:
debit_credit_dict[row[0]].recurring.append(
DescriptionRecurring(row[2], row[3], accounts[row[1]]))
@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.
"""
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:

View File

@ -14,20 +14,20 @@
# 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 SQLAlchemy alias for the offset entries.
"""The SQLAlchemy alias for the offset items.
"""
import typing as t
import sqlalchemy as sa
from accounting.models import JournalEntry
from accounting.models import JournalEntryLineItem
def offset_alias() -> sa.Alias:
"""Returns the SQLAlchemy alias for the offset entries.
"""Returns the SQLAlchemy alias for the offset items.
:return: The SQLAlchemy alias for the offset entries.
:return: The SQLAlchemy alias for the offset items.
"""
def as_from(model_cls: t.Any) -> sa.FromClause:
@ -36,4 +36,4 @@ def offset_alias() -> sa.Alias:
def as_alias(alias: t.Any) -> sa.Alias:
return alias
return as_alias(sa.alias(as_from(JournalEntry), name="offset"))
return as_alias(sa.alias(as_from(JournalEntryLineItem), name="offset"))

View File

@ -0,0 +1,336 @@
# The Mia! Accounting Flask 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.
"""
import typing as t
from abc import ABC, abstractmethod
from flask import render_template, request, abort
from flask_wtf import FlaskForm
from accounting.models import JournalEntry
from accounting.template_globals import default_currency_code
from accounting.utils.journal_entry_types import JournalEntryType
from accounting.journal_entry.forms import JournalEntryForm, \
CashReceiptJournalEntryForm, CashDisbursementJournalEntryForm, \
TransferJournalEntryForm
from accounting.journal_entry.forms.line_item import LineItemForm
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) -> t.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) -> t.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) -> t.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) -> t.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

@ -0,0 +1,84 @@
# The Mia! Accounting Flask 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.cast import be
from .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),
(be(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, be(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, JournalEntryLineItem.is_debit,
JournalEntryLineItem.no)\
.options(selectinload(JournalEntryLineItem.currency),
selectinload(JournalEntryLineItem.account),
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

View File

@ -0,0 +1,238 @@
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/18
# 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 journal entry management.
"""
from datetime import date
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.next_uri import inherit_next, or_next
from accounting.utils.permission import has_permission, can_view, can_edit
from accounting.utils.journal_entry_types import JournalEntryType
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 = date.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:journal_entry_date>", endpoint="order")
@has_permission(can_view)
def show_journal_entry_order(journal_entry_date: date) -> str:
"""Shows the order of the journal entries in a same date.
:param journal_entry_date: The date.
:return: The order of the journal entries in the date.
"""
journal_entries: list[JournalEntry] = JournalEntry.query \
.filter(JournalEntry.date == journal_entry_date) \
.order_by(JournalEntry.no).all()
return render_template("accounting/journal-entry/order.html",
date=journal_entry_date, list=journal_entries)
@bp.post("/dates/<date:journal_entry_date>", endpoint="sort")
@has_permission(can_edit)
def sort_journal_entries(journal_entry_date: date) -> redirect:
"""Reorders the journal entries in a date.
:param journal_entry_date: The date.
:return: The redirection to the incoming account or the account list. The
reordering operation does not fail.
"""
form: JournalEntryReorderForm = JournalEntryReorderForm(journal_entry_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

@ -25,8 +25,8 @@ from datetime import date
from decimal import Decimal
import sqlalchemy as sa
from flask import current_app
from flask_babel import get_locale
from babel import Locale
from flask_babel import get_locale, get_babel
from sqlalchemy import text
from accounting import db
@ -53,7 +53,7 @@ class BaseAccount(db.Model):
:return: The string representation of the base account.
"""
return f"{self.code} {self.title}"
return f"{self.code} {self.title.title()}"
@property
def title(self) -> str:
@ -61,11 +61,11 @@ class BaseAccount(db.Model):
:return: The title in the current locale.
"""
current_locale = str(get_locale())
if current_locale == current_app.config["BABEL_DEFAULT_LOCALE"]:
current_locale: Locale = get_locale()
if current_locale == get_babel().instance.default_locale:
return self.title_l10n
for l10n in self.l10n:
if l10n.locale == current_locale:
if l10n.locale == str(current_locale):
return l10n.title
return self.title_l10n
@ -115,7 +115,7 @@ class Account(db.Model):
title_l10n = db.Column("title", db.String, nullable=False)
"""The title."""
is_need_offset = db.Column(db.Boolean, nullable=False, default=False)
"""Whether the entries of this account need offset."""
"""Whether the journal entry line items of this account need offset."""
created_at = db.Column(db.DateTime(timezone=True), nullable=False,
server_default=db.func.now())
"""The time of creation."""
@ -139,8 +139,9 @@ class Account(db.Model):
l10n = db.relationship("AccountL10n", back_populates="account",
lazy=False)
"""The localized titles."""
entries = db.relationship("JournalEntry", back_populates="account")
"""The journal entries."""
line_items = db.relationship("JournalEntryLineItem",
back_populates="account")
"""The journal entry line items."""
CASH_CODE: str = "1111-001"
"""The code of the cash account,"""
@ -154,7 +155,7 @@ class Account(db.Model):
:return: The string representation of this account.
"""
return f"{self.base_code}-{self.no:03d} {self.title}"
return f"{self.base_code}-{self.no:03d} {self.title.title()}"
@property
def code(self) -> str:
@ -170,11 +171,11 @@ class Account(db.Model):
:return: The title in the current locale.
"""
current_locale = str(get_locale())
if current_locale == current_app.config["BABEL_DEFAULT_LOCALE"]:
current_locale: Locale = get_locale()
if current_locale == get_babel().instance.default_locale:
return self.title_l10n
for l10n in self.l10n:
if l10n.locale == current_locale:
if l10n.locale == str(current_locale):
return l10n.title
return self.title_l10n
@ -188,15 +189,15 @@ class Account(db.Model):
if self.title_l10n is None:
self.title_l10n = value
return
current_locale = str(get_locale())
if current_locale == current_app.config["BABEL_DEFAULT_LOCALE"]:
current_locale: Locale = get_locale()
if current_locale == get_babel().instance.default_locale:
self.title_l10n = value
return
for l10n in self.l10n:
if l10n.locale == current_locale:
if l10n.locale == str(current_locale):
l10n.title = value
return
self.l10n.append(AccountL10n(locale=current_locale, title=value))
self.l10n.append(AccountL10n(locale=str(current_locale), title=value))
@property
def is_real(self) -> bool:
@ -235,6 +236,16 @@ class Account(db.Model):
return True
return False
@property
def can_delete(self) -> bool:
"""Returns whether the account can be deleted.
:return: True if the account can be deleted, or False otherwise.
"""
if self.code in {"1111-001", "3351-001", "3353-001"}:
return False
return len(self.line_items) == 0
def delete(self) -> None:
"""Deletes this account.
@ -363,15 +374,16 @@ class Currency(db.Model):
l10n = db.relationship("CurrencyL10n", back_populates="currency",
lazy=False)
"""The localized names."""
entries = db.relationship("JournalEntry", back_populates="currency")
"""The journal entries."""
line_items = db.relationship("JournalEntryLineItem",
back_populates="currency")
"""The journal entry line items."""
def __str__(self) -> str:
"""Returns the string representation of the currency.
:return: The string representation of the currency.
"""
return f"{self.name} ({self.code})"
return f"{self.name.title()} ({self.code})"
@property
def name(self) -> str:
@ -379,11 +391,11 @@ class Currency(db.Model):
:return: The name in the current locale.
"""
current_locale = str(get_locale())
if current_locale == current_app.config["BABEL_DEFAULT_LOCALE"]:
current_locale: Locale = get_locale()
if current_locale == get_babel().instance.default_locale:
return self.name_l10n
for l10n in self.l10n:
if l10n.locale == current_locale:
if l10n.locale == str(current_locale):
return l10n.name
return self.name_l10n
@ -397,15 +409,15 @@ class Currency(db.Model):
if self.name_l10n is None:
self.name_l10n = value
return
current_locale = str(get_locale())
if current_locale == current_app.config["BABEL_DEFAULT_LOCALE"]:
current_locale: Locale = get_locale()
if current_locale == get_babel().instance.default_locale:
self.name_l10n = value
return
for l10n in self.l10n:
if l10n.locale == current_locale:
if l10n.locale == str(current_locale):
l10n.name = value
return
self.l10n.append(CurrencyL10n(locale=current_locale, name=value))
self.l10n.append(CurrencyL10n(locale=str(current_locale), name=value))
@property
def is_modified(self) -> bool:
@ -420,6 +432,17 @@ class Currency(db.Model):
return True
return False
@property
def can_delete(self) -> bool:
"""Returns whether the currency can be deleted.
:return: True if the currency can be deleted, or False otherwise.
"""
from accounting.template_globals import default_currency_code
if self.code == default_currency_code():
return False
return len(self.line_items) == 0
def delete(self) -> None:
"""Deletes the currency.
@ -447,23 +470,23 @@ class CurrencyL10n(db.Model):
"""The localized name."""
class TransactionCurrency:
"""A currency in a transaction."""
class JournalEntryCurrency:
"""A currency in a journal entry."""
def __init__(self, code: str, debit: list[JournalEntry],
credit: list[JournalEntry]):
"""Constructs the currency in the transaction.
def __init__(self, code: str, debit: list[JournalEntryLineItem],
credit: list[JournalEntryLineItem]):
"""Constructs the currency in the journal entry.
:param code: The currency code.
:param debit: The debit entries.
:param credit: The credit entries.
:param debit: The debit line items.
:param credit: The credit line items.
"""
self.code: str = code
"""The currency code."""
self.debit: list[JournalEntry] = debit
"""The debit entries."""
self.credit: list[JournalEntry] = credit
"""The credit entries."""
self.debit: list[JournalEntryLineItem] = debit
"""The debit line items."""
self.credit: list[JournalEntryLineItem] = credit
"""The credit line items."""
@property
def name(self) -> str:
@ -475,28 +498,28 @@ class TransactionCurrency:
@property
def debit_total(self) -> Decimal:
"""Returns the total amount of the debit journal entries.
"""Returns the total amount of the debit line items.
:return: The total amount of the debit journal entries.
:return: The total amount of the debit line items.
"""
return sum([x.amount for x in self.debit])
@property
def credit_total(self) -> str:
"""Returns the total amount of the credit journal entries.
"""Returns the total amount of the credit line items.
:return: The total amount of the credit journal entries.
:return: The total amount of the credit line items.
"""
return sum([x.amount for x in self.credit])
class Transaction(db.Model):
"""A transaction."""
__tablename__ = "accounting_transactions"
class JournalEntry(db.Model):
"""A journal entry."""
__tablename__ = "accounting_journal_entries"
"""The table name."""
id = db.Column(db.Integer, nullable=False, primary_key=True,
autoincrement=False)
"""The transaction ID."""
"""The journal entry ID."""
date = db.Column(db.Date, nullable=False)
"""The date."""
no = db.Column(db.Integer, nullable=False, default=text("1"))
@ -523,46 +546,50 @@ class Transaction(db.Model):
"""The ID of the updator."""
updated_by = db.relationship(user_cls, foreign_keys=updated_by_id)
"""The updator."""
entries = db.relationship("JournalEntry", back_populates="transaction")
"""The journal entries."""
line_items = db.relationship("JournalEntryLineItem",
back_populates="journal_entry")
"""The line items."""
def __str__(self) -> str:
"""Returns the string representation of this transaction.
"""Returns the string representation of this journal entry.
:return: The string representation of this transaction.
:return: The string representation of this journal entry.
"""
if self.is_cash_expense:
return gettext("Cash Expense Transaction#%(id)s", id=self.id)
if self.is_cash_income:
return gettext("Cash Income Transaction#%(id)s", id=self.id)
return gettext("Transfer Transaction#%(id)s", id=self.id)
if self.is_cash_disbursement:
return gettext("Cash Disbursement Journal Entry#%(id)s",
id=self.id)
if self.is_cash_receipt:
return gettext("Cash Receipt Journal Entry#%(id)s", id=self.id)
return gettext("Transfer Journal Entry#%(id)s", id=self.id)
@property
def currencies(self) -> list[TransactionCurrency]:
"""Returns the journal entries categorized by their currencies.
def currencies(self) -> list[JournalEntryCurrency]:
"""Returns the line items categorized by their currencies.
:return: The currency categories.
"""
entries: list[JournalEntry] = sorted(self.entries, key=lambda x: x.no)
line_items: list[JournalEntryLineItem] = sorted(self.line_items,
key=lambda x: x.no)
codes: list[str] = []
by_currency: dict[str, list[JournalEntry]] = {}
for entry in entries:
if entry.currency_code not in by_currency:
codes.append(entry.currency_code)
by_currency[entry.currency_code] = []
by_currency[entry.currency_code].append(entry)
return [TransactionCurrency(code=x,
debit=[y for y in by_currency[x]
if y.is_debit],
credit=[y for y in by_currency[x]
if not y.is_debit])
by_currency: dict[str, list[JournalEntryLineItem]] = {}
for line_item in line_items:
if line_item.currency_code not in by_currency:
codes.append(line_item.currency_code)
by_currency[line_item.currency_code] = []
by_currency[line_item.currency_code].append(line_item)
return [JournalEntryCurrency(code=x,
debit=[y for y in by_currency[x]
if y.is_debit],
credit=[y for y in by_currency[x]
if not y.is_debit])
for x in codes]
@property
def is_cash_income(self) -> bool:
"""Returns whether this is a cash income transaction.
def is_cash_receipt(self) -> bool:
"""Returns whether this is a cash receipt journal entry.
:return: True if this is a cash income transaction, or False otherwise.
:return: True if this is a cash receipt journal entry, or False
otherwise.
"""
for currency in self.currencies:
if len(currency.debit) > 1:
@ -572,10 +599,10 @@ class Transaction(db.Model):
return True
@property
def is_cash_expense(self) -> bool:
"""Returns whether this is a cash expense transaction.
def is_cash_disbursement(self) -> bool:
"""Returns whether this is a cash disbursement journal entry.
:return: True if this is a cash expense transaction, or False
:return: True if this is a cash disbursement journal entry, or False
otherwise.
"""
for currency in self.currencies:
@ -587,99 +614,88 @@ class Transaction(db.Model):
@property
def can_delete(self) -> bool:
"""Returns whether the transaction can be deleted.
"""Returns whether the journal entry can be deleted.
:return: True if the transaction can be deleted, or False otherwise.
:return: True if the journal entry can be deleted, or False otherwise.
"""
if not hasattr(self, "__can_delete"):
def has_offset() -> bool:
for entry in self.entries:
if len(entry.offsets) > 0:
return True
for line_item in self.line_items:
if len(line_item.offsets) > 0:
return False
setattr(self, "__can_delete", not has_offset())
return getattr(self, "__can_delete")
return True
def delete(self) -> None:
"""Deletes the transaction.
"""Deletes the journal entry.
:return: None.
"""
JournalEntry.query\
.filter(JournalEntry.transaction_id == self.id).delete()
JournalEntryLineItem.query\
.filter(JournalEntryLineItem.journal_entry_id == self.id).delete()
db.session.delete(self)
class JournalEntry(db.Model):
"""An accounting journal entry."""
__tablename__ = "accounting_journal_entries"
class JournalEntryLineItem(db.Model):
"""A line item in the journal entry."""
__tablename__ = "accounting_journal_entry_line_items"
"""The table name."""
id = db.Column(db.Integer, nullable=False, primary_key=True,
autoincrement=False)
"""The entry ID."""
transaction_id = db.Column(db.Integer,
db.ForeignKey(Transaction.id,
onupdate="CASCADE",
ondelete="CASCADE"),
nullable=False)
"""The transaction ID."""
transaction = db.relationship(Transaction, back_populates="entries")
"""The transaction."""
"""The line item ID."""
journal_entry_id = db.Column(db.Integer,
db.ForeignKey(JournalEntry.id,
onupdate="CASCADE",
ondelete="CASCADE"),
nullable=False)
"""The journal entry ID."""
journal_entry = db.relationship(JournalEntry, back_populates="line_items")
"""The journal entry."""
is_debit = db.Column(db.Boolean, nullable=False)
"""True for a debit entry, or False for a credit entry."""
"""True for a debit line item, or False for a credit line item."""
no = db.Column(db.Integer, nullable=False)
"""The entry number under the transaction and debit or credit."""
original_entry_id = db.Column(db.Integer,
db.ForeignKey(id, onupdate="CASCADE"),
nullable=True)
"""The ID of the original entry."""
original_entry = db.relationship("JournalEntry", back_populates="offsets",
remote_side=id, passive_deletes=True)
"""The original entry."""
offsets = db.relationship("JournalEntry", back_populates="original_entry")
"""The offset entries."""
"""The line item number under the journal entry and debit or credit."""
original_line_item_id = db.Column(db.Integer,
db.ForeignKey(id, onupdate="CASCADE"),
nullable=True)
"""The ID of the original line item."""
original_line_item = db.relationship("JournalEntryLineItem",
back_populates="offsets",
remote_side=id, passive_deletes=True)
"""The original line item."""
offsets = db.relationship("JournalEntryLineItem",
back_populates="original_line_item")
"""The offset items."""
currency_code = db.Column(db.String,
db.ForeignKey(Currency.code, onupdate="CASCADE"),
nullable=False)
"""The currency code."""
currency = db.relationship(Currency, back_populates="entries")
currency = db.relationship(Currency, back_populates="line_items")
"""The currency."""
account_id = db.Column(db.Integer,
db.ForeignKey(Account.id,
onupdate="CASCADE"),
nullable=False)
"""The account ID."""
account = db.relationship(Account, back_populates="entries", lazy=False)
account = db.relationship(Account, back_populates="line_items", lazy=False)
"""The account."""
summary = db.Column(db.String, nullable=True)
"""The summary."""
description = db.Column(db.String, nullable=True)
"""The description."""
amount = db.Column(db.Numeric(14, 2), nullable=False)
"""The amount."""
def __str__(self) -> str:
"""Returns the string representation of the journal entry.
"""Returns the string representation of the line item.
:return: The string representation of the journal entry.
:return: The string representation of the line item.
"""
if not hasattr(self, "__str"):
from accounting.template_filters import format_date, format_amount
setattr(self, "__str",
gettext("%(date)s %(summary)s %(amount)s",
date=format_date(self.transaction.date),
summary="" if self.summary is None
else self.summary,
gettext("%(date)s %(description)s %(amount)s",
date=format_date(self.journal_entry.date),
description="" if self.description is None
else self.description,
amount=format_amount(self.amount)))
return getattr(self, "__str")
@property
def eid(self) -> int | None:
"""Returns the journal entry ID. This is the alternative name of the
ID field, to work with WTForms.
:return: The journal entry ID.
"""
return self.id
@property
def account_code(self) -> str:
"""Returns the account code.
@ -692,15 +708,15 @@ class JournalEntry(db.Model):
def debit(self) -> Decimal | None:
"""Returns the debit amount.
:return: The debit amount, or None if this is not a debit entry.
:return: The debit amount, or None if this is not a debit line item.
"""
return self.amount if self.is_debit else None
@property
def is_need_offset(self) -> bool:
"""Returns whether the entry needs offset.
"""Returns whether the line item needs offset.
:return: True if the entry needs offset, or False otherwise.
:return: True if the line item needs offset, or False otherwise.
"""
if not self.account.is_need_offset:
return False
@ -714,7 +730,7 @@ class JournalEntry(db.Model):
def credit(self) -> Decimal | None:
"""Returns the credit amount.
:return: The credit amount, or None if this is not a credit entry.
:return: The credit amount, or None if this is not a credit line item.
"""
return None if self.is_debit else self.amount
@ -750,12 +766,16 @@ class JournalEntry(db.Model):
frac: Decimal = (value - whole).normalize()
return str(whole) + str(abs(frac))[1:]
txn_day: date = self.transaction.date
summary: str = "" if self.summary is None else self.summary
return ([summary],
[str(txn_day.year),
"{}/{}".format(txn_day.year, txn_day.month),
"{}/{}".format(txn_day.month, txn_day.day),
"{}/{}/{}".format(txn_day.year, txn_day.month, txn_day.day),
journal_entry_day: date = self.journal_entry.date
description: str = "" if self.description is None else self.description
return ([description],
[str(journal_entry_day.year),
"{}/{}".format(journal_entry_day.year,
journal_entry_day.month),
"{}/{}".format(journal_entry_day.month,
journal_entry_day.day),
"{}/{}/{}".format(journal_entry_day.year,
journal_entry_day.month,
journal_entry_day.day),
format_amount(self.amount),
format_amount(self.net_balance)])

View File

@ -23,7 +23,7 @@ This file is largely taken from the NanoParma ERP project, first written in
import typing as t
from datetime import date
from accounting.models import Transaction
from accounting.models import JournalEntry
from .period import Period
from .shortcuts import ThisMonth, LastMonth, SinceLastMonth, ThisYear, \
LastYear, Today, Yesterday, AllTime, TemplatePeriod, YearPeriod
@ -61,8 +61,8 @@ class PeriodChooser:
self.url_template: str = get_url(TemplatePeriod())
"""The URL template."""
first: Transaction | None \
= Transaction.query.order_by(Transaction.date).first()
first: JournalEntry | None \
= JournalEntry.query.order_by(JournalEntry.date).first()
start: date | None = None if first is None else first.date
# Attributes

View File

@ -35,7 +35,7 @@ class ThisMonth(Period):
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
@ -55,7 +55,7 @@ class LastMonth(Period):
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
@ -75,7 +75,7 @@ class SinceLastMonth(Period):
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
@ -90,7 +90,7 @@ class ThisYear(Period):
def _set_properties(self) -> None:
self.spec = "this-year"
self.desc = gettext("This year")
self.desc = gettext("This Year")
self.is_a_year = True
@ -105,7 +105,7 @@ class LastYear(Period):
def _set_properties(self) -> None:
self.spec = "last-year"
self.desc = gettext("Last year")
self.desc = gettext("Last Year")
self.is_a_year = True

View File

@ -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, Transaction, \
JournalEntry
from accounting.models import Currency, BaseAccount, Account, JournalEntry, \
JournalEntryLineItem
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,17 +124,17 @@ class AccountCollector:
sub_conditions: list[sa.BinaryExpression] \
= [Account.base_code.startswith(x) for x in {"1", "2", "3"}]
conditions: list[sa.BinaryExpression] \
= [JournalEntry.currency_code == self.__currency.code,
= [JournalEntryLineItem.currency_code == self.__currency.code,
sa.or_(*sub_conditions)]
if self.__period.end is not None:
conditions.append(Transaction.date <= self.__period.end)
conditions.append(JournalEntry.date <= self.__period.end)
balance_func: sa.Function = sa.func.sum(sa.case(
(JournalEntry.is_debit, JournalEntry.amount),
else_=-JournalEntry.amount)).label("balance")
(JournalEntryLineItem.is_debit, JournalEntryLineItem.amount),
else_=-JournalEntryLineItem.amount)).label("balance")
select_balance: sa.Select \
= sa.select(Account.id, Account.base_code, Account.no,
balance_func)\
.join(Transaction).join(Account)\
.join(JournalEntry).join(Account)\
.filter(*conditions)\
.group_by(Account.id, Account.base_code, Account.no)\
.order_by(Account.base_code, Account.no)
@ -178,8 +178,8 @@ class AccountCollector:
if self.__period.start is None:
return None
conditions: list[sa.BinaryExpression] \
= [JournalEntry.currency_code == self.__currency.code,
Transaction.date < self.__period.start]
= [JournalEntryLineItem.currency_code == self.__currency.code,
JournalEntry.date < self.__period.start]
return self.__query_balance(conditions)
def __add_current_period(self) -> None:
@ -197,11 +197,11 @@ class AccountCollector:
:return: The net income or loss for current period.
"""
conditions: list[sa.BinaryExpression] \
= [JournalEntry.currency_code == self.__currency.code]
= [JournalEntryLineItem.currency_code == self.__currency.code]
if self.__period.start is not None:
conditions.append(Transaction.date >= self.__period.start)
conditions.append(JournalEntry.date >= self.__period.start)
if self.__period.end is not None:
conditions.append(Transaction.date <= self.__period.end)
conditions.append(JournalEntry.date <= self.__period.end)
return self.__query_balance(conditions)
@staticmethod
@ -215,10 +215,10 @@ class AccountCollector:
conditions.extend([sa.not_(Account.base_code.startswith(x))
for x in {"1", "2", "3"}])
balance_func: sa.Function = sa.func.sum(sa.case(
(JournalEntry.is_debit, JournalEntry.amount),
else_=-JournalEntry.amount))
(JournalEntryLineItem.is_debit, JournalEntryLineItem.amount),
else_=-JournalEntryLineItem.amount))
select_balance: sa.Select = sa.select(balance_func)\
.join(Transaction).join(Account).filter(*conditions)
.join(JournalEntry).join(Account).filter(*conditions)
return db.session.scalar(select_balance)
def __add_owner_s_equity(self, code: str, amount: Decimal | None,

View File

@ -26,7 +26,8 @@ from sqlalchemy.orm import selectinload
from accounting import db
from accounting.locale import gettext
from accounting.models import Currency, Account, Transaction, JournalEntry
from accounting.models import Currency, Account, JournalEntry, \
JournalEntryLineItem
from accounting.report.period import Period, PeriodChooser
from accounting.report.utils.base_page_params import BasePageParams
from accounting.report.utils.base_report import BaseReport
@ -41,24 +42,24 @@ from accounting.utils.cast import be
from accounting.utils.pagination import Pagination
class ReportEntry:
"""An entry in the report."""
class ReportLineItem:
"""A line item in the report."""
def __init__(self, entry: JournalEntry | None = None):
"""Constructs the entry in the report.
def __init__(self, line_item: JournalEntryLineItem | None = None):
"""Constructs the line item in the report.
:param entry: The journal entry.
:param line_item: The journal entry line item.
"""
self.is_brought_forward: bool = False
"""Whether this is the brought-forward entry."""
"""Whether this is the brought-forward line item."""
self.is_total: bool = False
"""Whether this is the total entry."""
"""Whether this is the total line item."""
self.date: date | None = None
"""The date."""
self.account: Account | None = None
"""The account."""
self.summary: str | None = None
"""The summary."""
self.description: str | None = None
"""The description."""
self.income: Decimal | None = None
"""The income amount."""
self.expense: Decimal | None = None
@ -68,24 +69,24 @@ class ReportEntry:
self.note: str | None = None
"""The note."""
self.url: str | None = None
"""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)
"""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)
class EntryCollector:
"""The report entry collector."""
class LineItemCollector:
"""The line item collector."""
def __init__(self, currency: Currency, account: IncomeExpensesAccount,
period: Period):
"""Constructs the report entry collector.
"""Constructs the line item collector.
:param currency: The currency.
:param account: The account.
@ -97,74 +98,78 @@ class EntryCollector:
"""The account."""
self.__period: Period = period
"""The period"""
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.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.__populate_balance()
def __get_brought_forward_entry(self) -> ReportEntry | None:
"""Queries, composes and returns the brought-forward entry.
def __get_brought_forward(self) -> ReportLineItem | None:
"""Queries, composes and returns the brought-forward line item.
:return: The brought-forward entry, or None if the period starts from
the beginning.
:return: The brought-forward line item, 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(
(JournalEntry.is_debit, JournalEntry.amount),
else_=-JournalEntry.amount))
(JournalEntryLineItem.is_debit, JournalEntryLineItem.amount),
else_=-JournalEntryLineItem.amount))
select: sa.Select = sa.Select(balance_func)\
.join(Transaction).join(Account)\
.filter(be(JournalEntry.currency_code == self.__currency.code),
.join(JournalEntry).join(Account)\
.filter(be(JournalEntryLineItem.currency_code
== self.__currency.code),
self.__account_condition,
Transaction.date < self.__period.start)
JournalEntry.date < self.__period.start)
balance: int | None = db.session.scalar(select)
if balance is None:
return None
entry: ReportEntry = ReportEntry()
entry.is_brought_forward = True
entry.date = self.__period.start
entry.account = Account.accumulated_change()
entry.summary = gettext("Brought forward")
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")
if balance > 0:
entry.income = balance
line_item.income = balance
elif balance < 0:
entry.expense = -balance
entry.balance = balance
return entry
line_item.expense = -balance
line_item.balance = balance
return line_item
def __query_entries(self) -> list[ReportEntry]:
"""Queries and returns the log entries.
def __query_line_items(self) -> list[ReportLineItem]:
"""Queries and returns the line items.
:return: The log entries.
:return: The line items.
"""
conditions: list[sa.BinaryExpression] \
= [JournalEntry.currency_code == self.__currency.code,
= [JournalEntryLineItem.currency_code == self.__currency.code,
self.__account_condition]
if self.__period.start is not None:
conditions.append(Transaction.date >= self.__period.start)
conditions.append(JournalEntry.date >= self.__period.start)
if self.__period.end is not None:
conditions.append(Transaction.date <= self.__period.end)
txn_with_account: sa.Select = sa.Select(Transaction.id).\
join(JournalEntry).join(Account).filter(*conditions)
conditions.append(JournalEntry.date <= self.__period.end)
journal_entry_with_account: sa.Select = sa.Select(JournalEntry.id).\
join(JournalEntryLineItem).join(Account).filter(*conditions)
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,
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,
sa.not_(self.__account_condition))
.order_by(Transaction.date,
Transaction.no,
JournalEntry.is_debit,
JournalEntry.no)
.options(selectinload(JournalEntry.account),
selectinload(JournalEntry.transaction))]
.order_by(JournalEntry.date,
JournalEntry.no,
JournalEntryLineItem.is_debit,
JournalEntryLineItem.no)
.options(selectinload(JournalEntryLineItem.account),
selectinload(JournalEntryLineItem.journal_entry))]
@property
def __account_condition(self) -> sa.BinaryExpression:
@ -175,66 +180,67 @@ class EntryCollector:
Account.base_code.startswith("22"))
return Account.id == self.__account.id
def __get_total_entry(self) -> ReportEntry | None:
"""Composes the total entry.
def __get_total(self) -> ReportLineItem | None:
"""Composes the total line item.
:return: The total entry, or None if there is no data.
:return: The total line item, or None if there is no data.
"""
if self.brought_forward is None and len(self.entries) == 0:
if self.brought_forward is None and len(self.line_items) == 0:
return None
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])
entry.expense = sum([x.expense for x in self.entries
if x.expense is not None])
entry.balance = entry.income - entry.expense
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
if x.income is not None])
line_item.expense = sum([x.expense for x in self.line_items
if x.expense is not None])
line_item.balance = line_item.income - line_item.expense
if self.brought_forward is not None:
entry.balance = self.brought_forward.balance + entry.balance
return entry
line_item.balance \
= self.brought_forward.balance + line_item.balance
return line_item
def __populate_balance(self) -> None:
"""Populates the balance of the entries.
"""Populates the balance of the line items.
:return: None.
"""
balance: Decimal = 0 if self.brought_forward is None \
else self.brought_forward.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
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
class CSVRow(BaseCSVRow):
"""A row in the CSV."""
def __init__(self, txn_date: date | str | None,
def __init__(self, journal_entry_date: date | str | None,
account: str | None,
summary: str | None,
description: str | None,
income: str | Decimal | None,
expense: str | Decimal | None,
balance: str | Decimal | None,
note: str | None):
"""Constructs a row in the CSV.
:param txn_date: The transaction date.
:param journal_entry_date: The journal entry date.
:param account: The account.
:param summary: The summary.
:param description: The description.
:param income: The income.
:param expense: The expense.
:param balance: The balance.
:param note: The note.
"""
self.date: date | str | None = txn_date
self.date: date | str | None = journal_entry_date
"""The date."""
self.account: str | None = account
"""The account."""
self.summary: str | None = summary
"""The summary."""
self.description: str | None = description
"""The description."""
self.income: str | Decimal | None = income
"""The income."""
self.expense: str | Decimal | None = expense
@ -250,7 +256,7 @@ class CSVRow(BaseCSVRow):
:return: The values of the row.
"""
return [self.date, self.account, self.summary,
return [self.date, self.account, self.description,
self.income, self.expense, self.balance, self.note]
@ -261,19 +267,19 @@ class PageParams(BasePageParams):
account: IncomeExpensesAccount,
period: Period,
has_data: bool,
pagination: Pagination[ReportEntry],
brought_forward: ReportEntry | None,
entries: list[ReportEntry],
total: ReportEntry | None):
pagination: Pagination[ReportLineItem],
brought_forward: ReportLineItem | None,
line_items: list[ReportLineItem],
total: ReportLineItem | 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 entry.
:param entries: The log entries.
:param total: The total entry.
:param brought_forward: The brought-forward line item.
:param line_items: The line items.
:param total: The total line item.
"""
self.currency: Currency = currency
"""The currency."""
@ -283,14 +289,14 @@ class PageParams(BasePageParams):
"""The period."""
self.__has_data: bool = has_data
"""True if there is any data, or False otherwise."""
self.pagination: Pagination[ReportEntry] = pagination
self.pagination: Pagination[ReportLineItem] = pagination
"""The pagination."""
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.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.period_chooser: PeriodChooser = PeriodChooser(
lambda x: income_expenses_url(currency, account, x))
"""The period chooser."""
@ -342,14 +348,15 @@ class PageParams(BasePageParams):
income_expenses_url(self.currency, current_al,
self.period),
self.account.id == 0)]
in_use: sa.Select = sa.Select(JournalEntry.account_id)\
in_use: sa.Select = sa.Select(JournalEntryLineItem.account_id)\
.join(Account)\
.filter(be(JournalEntry.currency_code == self.currency.code),
.filter(be(JournalEntryLineItem.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)
.group_by(JournalEntryLineItem.account_id)
options.extend([OptionLink(str(x),
income_expenses_url(
self.currency,
@ -378,14 +385,15 @@ class IncomeExpenses(BaseReport):
"""The account."""
self.__period: Period = period
"""The period."""
collector: EntryCollector = EntryCollector(
collector: LineItemCollector = LineItemCollector(
self.__currency, self.__account, self.__period)
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."""
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."""
def csv(self) -> Response:
"""Returns the report as CSV for download.
@ -403,20 +411,20 @@ class IncomeExpenses(BaseReport):
:return: The CSV rows.
"""
rows: list[CSVRow] = [CSVRow(gettext("Date"), gettext("Account"),
gettext("Summary"), gettext("Income"),
gettext("Description"), 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).title(),
self.__brought_forward.summary,
self.__brought_forward.description,
self.__brought_forward.income,
self.__brought_forward.expense,
self.__brought_forward.balance,
None))
rows.extend([CSVRow(x.date, str(x.account).title(), x.summary,
rows.extend([CSVRow(x.date, str(x.account).title(), x.description,
x.income, x.expense, x.balance, x.note)
for x in self.__entries])
for x in self.__line_items])
if self.__total is not None:
rows.append(CSVRow(gettext("Total"), None, None,
self.__total.income, self.__total.expense,
@ -428,31 +436,31 @@ class IncomeExpenses(BaseReport):
:return: The report as HTML.
"""
all_entries: list[ReportEntry] = []
all_line_items: list[ReportLineItem] = []
if self.__brought_forward is not None:
all_entries.append(self.__brought_forward)
all_entries.extend(self.__entries)
all_line_items.append(self.__brought_forward)
all_line_items.extend(self.__line_items)
if self.__total is not None:
all_entries.append(self.__total)
pagination: Pagination[ReportEntry] \
= Pagination[ReportEntry](all_entries, is_reversed=True)
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]
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]
params: PageParams = PageParams(currency=self.__currency,
account=self.__account,
period=self.__period,
has_data=has_data,
pagination=pagination,
brought_forward=brought_forward,
entries=page_entries,
line_items=page_line_items,
total=total)
return render_template("accounting/report/income-expenses.html",
report=params)

View File

@ -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, Transaction, \
JournalEntry
from accounting.models import Currency, BaseAccount, Account, JournalEntry, \
JournalEntryLineItem
from accounting.report.period import Period, PeriodChooser
from accounting.report.utils.base_page_params import BasePageParams
from accounting.report.utils.base_report import BaseReport
@ -256,17 +256,17 @@ class IncomeStatement(BaseReport):
sub_conditions: list[sa.BinaryExpression] \
= [Account.base_code.startswith(str(x)) for x in range(4, 10)]
conditions: list[sa.BinaryExpression] \
= [JournalEntry.currency_code == self.__currency.code,
= [JournalEntryLineItem.currency_code == self.__currency.code,
sa.or_(*sub_conditions)]
if self.__period.start is not None:
conditions.append(Transaction.date >= self.__period.start)
conditions.append(JournalEntry.date >= self.__period.start)
if self.__period.end is not None:
conditions.append(Transaction.date <= self.__period.end)
conditions.append(JournalEntry.date <= self.__period.end)
balance_func: sa.Function = sa.func.sum(sa.case(
(JournalEntry.is_debit, -JournalEntry.amount),
else_=JournalEntry.amount)).label("balance")
(JournalEntryLineItem.is_debit, -JournalEntryLineItem.amount),
else_=JournalEntryLineItem.amount)).label("balance")
select_balances: sa.Select = sa.select(Account.id, balance_func)\
.join(Transaction).join(Account)\
.join(JournalEntry).join(Account)\
.filter(*conditions)\
.group_by(Account.id)\
.order_by(Account.base_code, Account.no)

View File

@ -25,7 +25,8 @@ from flask import render_template, Response
from sqlalchemy.orm import selectinload
from accounting.locale import gettext
from accounting.models import Currency, Account, Transaction, JournalEntry
from accounting.models import Currency, Account, JournalEntry, \
JournalEntryLineItem
from accounting.report.period import Period, PeriodChooser
from accounting.report.utils.base_page_params import BasePageParams
from accounting.report.utils.base_report import BaseReport
@ -37,58 +38,58 @@ from accounting.report.utils.urls import journal_url
from accounting.utils.pagination import Pagination
class ReportEntry:
"""An entry in the report."""
class ReportLineItem:
"""A line item in the report."""
def __init__(self, entry: JournalEntry):
"""Constructs the entry in the report.
def __init__(self, line_item: JournalEntryLineItem):
"""Constructs the line item in the report.
:param entry: The journal entry.
:param line_item: The journal entry line item.
"""
self.entry: JournalEntry = entry
self.line_item: JournalEntryLineItem = line_item
"""The journal entry line item."""
self.journal_entry: JournalEntry = line_item.journal_entry
"""The journal entry."""
self.transaction: Transaction = entry.transaction
"""The transaction."""
self.currency: Currency = entry.currency
self.currency: Currency = line_item.currency
"""The account."""
self.account: Account = entry.account
self.account: Account = line_item.account
"""The account."""
self.summary: str | None = entry.summary
"""The summary."""
self.debit: Decimal | None = entry.debit
self.description: str | None = line_item.description
"""The description."""
self.debit: Decimal | None = line_item.debit
"""The debit amount."""
self.credit: Decimal | None = entry.credit
self.credit: Decimal | None = line_item.credit
"""The credit amount."""
self.amount: Decimal = entry.amount
self.amount: Decimal = line_item.amount
"""The amount."""
class CSVRow(BaseCSVRow):
"""A row in the CSV."""
def __init__(self, txn_date: str | date,
def __init__(self, journal_entry_date: str | date,
currency: str,
account: str,
summary: str | None,
description: str | None,
debit: str | Decimal | None,
credit: str | Decimal | None,
note: str | None):
"""Constructs a row in the CSV.
:param txn_date: The transaction date.
:param summary: The summary.
:param journal_entry_date: The journal entry date.
:param description: The description.
:param debit: The debit amount.
:param credit: The credit amount.
:param note: The note.
"""
self.date: str | date = txn_date
self.date: str | date = journal_entry_date
"""The date."""
self.currency: str = currency
"""The currency."""
self.account: str = account
"""The account."""
self.summary: str | None = summary
"""The summary."""
self.description: str | None = description
"""The description."""
self.debit: str | Decimal | None = debit
"""The debit amount."""
self.credit: str | Decimal | None = credit
@ -102,7 +103,7 @@ class CSVRow(BaseCSVRow):
:return: The values of the row.
"""
return [self.date, self.currency, self.account, self.summary,
return [self.date, self.currency, self.account, self.description,
self.debit, self.credit, self.note]
@ -110,19 +111,19 @@ class PageParams(BasePageParams):
"""The HTML page parameters."""
def __init__(self, period: Period,
pagination: Pagination[JournalEntry],
entries: list[JournalEntry]):
pagination: Pagination[JournalEntryLineItem],
line_items: list[JournalEntryLineItem]):
"""Constructs the HTML page parameters.
:param period: The period.
:param entries: The journal entries.
:param line_items: The line items.
"""
self.period: Period = period
"""The period."""
self.pagination: Pagination[JournalEntry] = pagination
self.pagination: Pagination[JournalEntryLineItem] = pagination
"""The pagination."""
self.entries: list[JournalEntry] = entries
"""The entries."""
self.line_items: list[JournalEntryLineItem] = line_items
"""The line items."""
self.period_chooser: PeriodChooser = PeriodChooser(
lambda x: journal_url(x))
"""The period chooser."""
@ -133,7 +134,7 @@ class PageParams(BasePageParams):
:return: True if there is any data, or False otherwise.
"""
return len(self.entries) > 0
return len(self.line_items) > 0
@property
def report_chooser(self) -> ReportChooser:
@ -145,20 +146,20 @@ class PageParams(BasePageParams):
period=self.period)
def get_csv_rows(entries: list[JournalEntry]) -> list[CSVRow]:
"""Composes and returns the CSV rows from the report entries.
def get_csv_rows(line_items: list[JournalEntryLineItem]) -> list[CSVRow]:
"""Composes and returns the CSV rows from the line items.
:param entries: The report entries.
:param line_items: The line items.
:return: The CSV rows.
"""
rows: list[CSVRow] = [CSVRow(gettext("Date"), gettext("Currency"),
gettext("Account"), gettext("Summary"),
gettext("Account"), gettext("Description"),
gettext("Debit"), gettext("Credit"),
gettext("Note"))]
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])
rows.extend([CSVRow(x.journal_entry.date, x.currency.code,
str(x.account).title(), x.description,
x.debit, x.credit, x.journal_entry.note)
for x in line_items])
return rows
@ -172,28 +173,29 @@ class Journal(BaseReport):
"""
self.__period: Period = period
"""The period."""
self.__entries: list[JournalEntry] = self.__query_entries()
"""The journal entries."""
self.__line_items: list[JournalEntryLineItem] \
= self.__query_line_items()
"""The line items."""
def __query_entries(self) -> list[JournalEntry]:
"""Queries and returns the journal entries.
def __query_line_items(self) -> list[JournalEntryLineItem]:
"""Queries and returns the line items.
:return: The journal entries.
:return: The line items.
"""
conditions: list[sa.BinaryExpression] = []
if self.__period.start is not None:
conditions.append(Transaction.date >= self.__period.start)
conditions.append(JournalEntry.date >= self.__period.start)
if self.__period.end is not None:
conditions.append(Transaction.date <= self.__period.end)
return JournalEntry.query.join(Transaction)\
conditions.append(JournalEntry.date <= self.__period.end)
return JournalEntryLineItem.query.join(JournalEntry)\
.filter(*conditions)\
.order_by(Transaction.date,
Transaction.no,
JournalEntry.is_debit.desc(),
JournalEntry.no)\
.options(selectinload(JournalEntry.account),
selectinload(JournalEntry.currency),
selectinload(JournalEntry.transaction)).all()
.order_by(JournalEntry.date,
JournalEntry.no,
JournalEntryLineItem.is_debit.desc(),
JournalEntryLineItem.no)\
.options(selectinload(JournalEntryLineItem.account),
selectinload(JournalEntryLineItem.currency),
selectinload(JournalEntryLineItem.journal_entry)).all()
def csv(self) -> Response:
"""Returns the report as CSV for download.
@ -201,17 +203,18 @@ 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.__entries))
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[JournalEntry] \
= Pagination[JournalEntry](self.__entries, is_reversed=True)
pagination: Pagination[JournalEntryLineItem] \
= Pagination[JournalEntryLineItem](self.__line_items,
is_reversed=True)
params: PageParams = PageParams(period=self.__period,
pagination=pagination,
entries=pagination.list)
line_items=pagination.list)
return render_template("accounting/report/journal.html",
report=params)

View File

@ -26,7 +26,8 @@ from sqlalchemy.orm import selectinload
from accounting import db
from accounting.locale import gettext
from accounting.models import Currency, Account, Transaction, JournalEntry
from accounting.models import Currency, Account, JournalEntry, \
JournalEntryLineItem
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 +41,22 @@ from accounting.utils.cast import be
from accounting.utils.pagination import Pagination
class ReportEntry:
"""An entry in the report."""
class ReportLineItem:
"""A line item in the report."""
def __init__(self, entry: JournalEntry | None = None):
"""Constructs the entry in the report.
def __init__(self, line_item: JournalEntryLineItem | None = None):
"""Constructs the line item in the report.
:param entry: The journal entry.
:param line_item: The journal entry line item.
"""
self.is_brought_forward: bool = False
"""Whether this is the brought-forward entry."""
"""Whether this is the brought-forward line item."""
self.is_total: bool = False
"""Whether this is the total entry."""
"""Whether this is the total line item."""
self.date: date | None = None
"""The date."""
self.summary: str | None = None
"""The summary."""
self.description: str | None = None
"""The description."""
self.debit: Decimal | None = None
"""The debit amount."""
self.credit: Decimal | None = None
@ -65,22 +66,22 @@ class ReportEntry:
self.note: str | None = None
"""The note."""
self.url: str | None = None
"""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)
"""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)
class EntryCollector:
"""The report entry collector."""
class LineItemCollector:
"""The line item collector."""
def __init__(self, currency: Currency, account: Account, period: Period):
"""Constructs the report entry collector.
"""Constructs the line item collector.
:param currency: The currency.
:param account: The account.
@ -92,89 +93,94 @@ class EntryCollector:
"""The account."""
self.__period: Period = period
"""The period"""
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.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.__populate_balance()
def __get_brought_forward_entry(self) -> ReportEntry | None:
"""Queries, composes and returns the brought-forward entry.
def __get_brought_forward(self) -> ReportLineItem | None:
"""Queries, composes and returns the brought-forward line item.
:return: The brought-forward entry, or None if the report starts from
the beginning.
:return: The brought-forward line item, or None if the report starts
from the beginning.
"""
if self.__period.start is None:
return None
if self.__account.is_nominal:
return None
balance_func: sa.Function = sa.func.sum(sa.case(
(JournalEntry.is_debit, JournalEntry.amount),
else_=-JournalEntry.amount))
select: sa.Select = sa.Select(balance_func).join(Transaction)\
.filter(be(JournalEntry.currency_code == self.__currency.code),
be(JournalEntry.account_id == self.__account.id),
Transaction.date < self.__period.start)
(JournalEntryLineItem.is_debit, JournalEntryLineItem.amount),
else_=-JournalEntryLineItem.amount))
select: sa.Select = sa.Select(balance_func).join(JournalEntry)\
.filter(be(JournalEntryLineItem.currency_code
== self.__currency.code),
be(JournalEntryLineItem.account_id
== self.__account.id),
JournalEntry.date < self.__period.start)
balance: int | None = db.session.scalar(select)
if balance is None:
return None
entry: ReportEntry = ReportEntry()
entry.is_brought_forward = True
entry.date = self.__period.start
entry.summary = gettext("Brought forward")
line_item: ReportLineItem = ReportLineItem()
line_item.is_brought_forward = True
line_item.date = self.__period.start
line_item.description = gettext("Brought forward")
if balance > 0:
entry.debit = balance
line_item.debit = balance
elif balance < 0:
entry.credit = -balance
entry.balance = balance
return entry
line_item.credit = -balance
line_item.balance = balance
return line_item
def __query_entries(self) -> list[ReportEntry]:
"""Queries and returns the report entries.
def __query_line_items(self) -> list[ReportLineItem]:
"""Queries and returns the line items.
:return: The report entries.
:return: The line items.
"""
conditions: list[sa.BinaryExpression] \
= [JournalEntry.currency_code == self.__currency.code,
JournalEntry.account_id == self.__account.id]
= [JournalEntryLineItem.currency_code == self.__currency.code,
JournalEntryLineItem.account_id == self.__account.id]
if self.__period.start is not None:
conditions.append(Transaction.date >= self.__period.start)
conditions.append(JournalEntry.date >= self.__period.start)
if self.__period.end is not None:
conditions.append(Transaction.date <= self.__period.end)
return [ReportEntry(x) for x in JournalEntry.query.join(Transaction)
conditions.append(JournalEntry.date <= self.__period.end)
return [ReportLineItem(x) for x in JournalEntryLineItem.query
.join(JournalEntry)
.filter(*conditions)
.order_by(Transaction.date,
Transaction.no,
JournalEntry.is_debit.desc(),
JournalEntry.no)
.options(selectinload(JournalEntry.transaction)).all()]
.order_by(JournalEntry.date,
JournalEntry.no,
JournalEntryLineItem.is_debit.desc(),
JournalEntryLineItem.no)
.options(selectinload(JournalEntryLineItem.journal_entry))
.all()]
def __get_total_entry(self) -> ReportEntry | None:
"""Composes the total entry.
def __get_total(self) -> ReportLineItem | None:
"""Composes the total line item.
:return: The total entry, or None if there is no data.
:return: The total line item, or None if there is no data.
"""
if self.brought_forward is None and len(self.entries) == 0:
if self.brought_forward is None and len(self.line_items) == 0:
return None
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])
entry.credit = sum([x.credit for x in self.entries
if x.credit is not None])
entry.balance = entry.debit - entry.credit
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
if x.debit is not None])
line_item.credit = sum([x.credit for x in self.line_items
if x.credit is not None])
line_item.balance = line_item.debit - line_item.credit
if self.brought_forward is not None:
entry.balance = self.brought_forward.balance + entry.balance
return entry
line_item.balance \
= self.brought_forward.balance + line_item.balance
return line_item
def __populate_balance(self) -> None:
"""Populates the balance of the entries.
"""Populates the balance of the line items.
:return: None.
"""
@ -182,36 +188,36 @@ class EntryCollector:
return None
balance: Decimal = 0 if self.brought_forward is None \
else self.brought_forward.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
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
class CSVRow(BaseCSVRow):
"""A row in the CSV."""
def __init__(self, txn_date: date | str | None,
summary: str | None,
def __init__(self, journal_entry_date: date | str | None,
description: str | None,
debit: str | Decimal | None,
credit: str | Decimal | None,
balance: str | Decimal | None,
note: str | None):
"""Constructs a row in the CSV.
:param txn_date: The transaction date.
:param summary: The summary.
:param journal_entry_date: The journal entry date.
:param description: The description.
:param debit: The debit amount.
:param credit: The credit amount.
:param balance: The balance.
:param note: The note.
"""
self.date: date | str | None = txn_date
self.date: date | str | None = journal_entry_date
"""The date."""
self.summary: str | None = summary
"""The summary."""
self.description: str | None = description
"""The description."""
self.debit: str | Decimal | None = debit
"""The debit amount."""
self.credit: str | Decimal | None = credit
@ -227,7 +233,7 @@ class CSVRow(BaseCSVRow):
:return: The values of the row.
"""
return [self.date, self.summary,
return [self.date, self.description,
self.debit, self.credit, self.balance, self.note]
@ -238,19 +244,19 @@ class PageParams(BasePageParams):
account: Account,
period: Period,
has_data: bool,
pagination: Pagination[ReportEntry],
brought_forward: ReportEntry | None,
entries: list[ReportEntry],
total: ReportEntry | None):
pagination: Pagination[ReportLineItem],
brought_forward: ReportLineItem | None,
line_items: list[ReportLineItem],
total: ReportLineItem | 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 entry.
:param entries: The report entries.
:param total: The total entry.
:param brought_forward: The brought-forward line item.
:param line_items: The line items.
:param total: The total line item.
"""
self.currency: Currency = currency
"""The currency."""
@ -260,14 +266,14 @@ class PageParams(BasePageParams):
"""The period."""
self.__has_data: bool = has_data
"""True if there is any data, or False otherwise."""
self.pagination: Pagination[ReportEntry] = pagination
self.pagination: Pagination[ReportLineItem] = pagination
"""The pagination."""
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.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.period_chooser: PeriodChooser = PeriodChooser(
lambda x: ledger_url(currency, account, x))
"""The period chooser."""
@ -306,9 +312,10 @@ class PageParams(BasePageParams):
:return: The account options.
"""
in_use: sa.Select = sa.Select(JournalEntry.account_id)\
.filter(be(JournalEntry.currency_code == self.currency.code))\
.group_by(JournalEntry.account_id)
in_use: sa.Select = sa.Select(JournalEntryLineItem.account_id)\
.filter(be(JournalEntryLineItem.currency_code
== self.currency.code))\
.group_by(JournalEntryLineItem.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))
@ -331,14 +338,15 @@ class Ledger(BaseReport):
"""The account."""
self.__period: Period = period
"""The period."""
collector: EntryCollector = EntryCollector(
collector: LineItemCollector = LineItemCollector(
self.__currency, self.__account, self.__period)
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."""
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."""
def csv(self) -> Response:
"""Returns the report as CSV for download.
@ -355,19 +363,19 @@ class Ledger(BaseReport):
:return: The CSV rows.
"""
rows: list[CSVRow] = [CSVRow(gettext("Date"), gettext("Summary"),
rows: list[CSVRow] = [CSVRow(gettext("Date"), gettext("Description"),
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.summary,
self.__brought_forward.description,
self.__brought_forward.debit,
self.__brought_forward.credit,
self.__brought_forward.balance,
None))
rows.extend([CSVRow(x.date, x.summary,
rows.extend([CSVRow(x.date, x.description,
x.debit, x.credit, x.balance, x.note)
for x in self.__entries])
for x in self.__line_items])
if self.__total is not None:
rows.append(CSVRow(gettext("Total"), None,
self.__total.debit, self.__total.credit,
@ -379,31 +387,31 @@ class Ledger(BaseReport):
:return: The report as HTML.
"""
all_entries: list[ReportEntry] = []
all_line_items: list[ReportLineItem] = []
if self.__brought_forward is not None:
all_entries.append(self.__brought_forward)
all_entries.extend(self.__entries)
all_line_items.append(self.__brought_forward)
all_line_items.extend(self.__line_items)
if self.__total is not None:
all_entries.append(self.__total)
pagination: Pagination[ReportEntry] \
= Pagination[ReportEntry](all_entries, is_reversed=True)
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]
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]
params: PageParams = PageParams(currency=self.__currency,
account=self.__account,
period=self.__period,
has_data=has_data,
pagination=pagination,
brought_forward=brought_forward,
entries=page_entries,
line_items=page_line_items,
total=total)
return render_template("accounting/report/ledger.html",
report=params)

View File

@ -26,7 +26,7 @@ from sqlalchemy.orm import selectinload
from accounting.locale import gettext
from accounting.models import Currency, CurrencyL10n, Account, AccountL10n, \
Transaction, JournalEntry
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 csv_download
@ -38,18 +38,18 @@ from accounting.utils.query import parse_query_keywords
from .journal import get_csv_rows
class EntryCollector:
"""The report entry collector."""
class LineItemCollector:
"""The line item collector."""
def __init__(self):
"""Constructs the report entry collector."""
self.entries: list[JournalEntry] = self.__query_entries()
"""The report entries."""
"""Constructs the line item collector."""
self.line_items: list[JournalEntryLineItem] = self.__query_line_items()
"""The line items."""
def __query_entries(self) -> list[JournalEntry]:
"""Queries and returns the journal entries.
def __query_line_items(self) -> list[JournalEntryLineItem]:
"""Queries and returns the line items.
:return: The journal entries.
:return: The line items.
"""
keywords: list[str] = parse_query_keywords(request.args.get("q"))
if len(keywords) == 0:
@ -57,26 +57,28 @@ class EntryCollector:
conditions: list[sa.BinaryExpression] = []
for k in keywords:
sub_conditions: list[sa.BinaryExpression] \
= [JournalEntry.summary.contains(k),
JournalEntry.account_id.in_(
= [JournalEntryLineItem.description.icontains(k),
JournalEntryLineItem.account_id.in_(
self.__get_account_condition(k)),
JournalEntry.currency_code.in_(
JournalEntryLineItem.currency_code.in_(
self.__get_currency_condition(k)),
JournalEntry.transaction_id.in_(
self.__get_transaction_condition(k))]
JournalEntryLineItem.journal_entry_id.in_(
self.__get_journal_entry_condition(k))]
try:
sub_conditions.append(JournalEntry.amount == Decimal(k))
sub_conditions.append(
JournalEntryLineItem.amount == Decimal(k))
except ArithmeticError:
pass
conditions.append(sa.or_(*sub_conditions))
return JournalEntry.query.join(Transaction).filter(*conditions)\
.order_by(Transaction.date,
Transaction.no,
JournalEntry.is_debit,
JournalEntry.no)\
.options(selectinload(JournalEntry.account),
selectinload(JournalEntry.currency),
selectinload(JournalEntry.transaction)).all()
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()
@staticmethod
def __get_account_condition(k: str) -> sa.Select:
@ -90,13 +92,13 @@ class EntryCollector:
sa.func.char_length(sa.cast(Account.no,
sa.String)) + 1)
select_l10n: sa.Select = sa.select(AccountL10n.account_id)\
.filter(AccountL10n.title.contains(k))
.filter(AccountL10n.title.icontains(k))
conditions: list[sa.BinaryExpression] \
= [Account.base_code.contains(k),
Account.title_l10n.contains(k),
Account.title_l10n.icontains(k),
code.contains(k),
Account.id.in_(select_l10n)]
if k in gettext("Need offset"):
if k in gettext("Needs Offset"):
conditions.append(Account.is_need_offset)
return sa.select(Account.id).filter(sa.or_(*conditions))
@ -108,57 +110,63 @@ class EntryCollector:
:return: The condition to filter the currency.
"""
select_l10n: sa.Select = sa.select(CurrencyL10n.currency_code)\
.filter(CurrencyL10n.name.contains(k))
.filter(CurrencyL10n.name.icontains(k))
return sa.select(Currency.code).filter(
sa.or_(Currency.code.contains(k),
Currency.name_l10n.contains(k),
sa.or_(Currency.code.icontains(k),
Currency.name_l10n.icontains(k),
Currency.code.in_(select_l10n)))
@staticmethod
def __get_transaction_condition(k: str) -> sa.Select:
"""Composes and returns the condition to filter the transaction.
def __get_journal_entry_condition(k: str) -> sa.Select:
"""Composes and returns the condition to filter the journal entry.
:param k: The keyword.
:return: The condition to filter the transaction.
:return: The condition to filter the journal entry.
"""
conditions: list[sa.BinaryExpression] = [Transaction.note.contains(k)]
txn_date: datetime
conditions: list[sa.BinaryExpression] \
= [JournalEntry.note.icontains(k)]
journal_entry_date: datetime
try:
txn_date = datetime.strptime(k, "%Y")
journal_entry_date = datetime.strptime(k, "%Y")
conditions.append(
be(sa.extract("year", Transaction.date) == txn_date.year))
be(sa.extract("year", JournalEntry.date)
== journal_entry_date.year))
except ValueError:
pass
try:
txn_date = datetime.strptime(k, "%Y/%m")
journal_entry_date = datetime.strptime(k, "%Y/%m")
conditions.append(sa.and_(
sa.extract("year", Transaction.date) == txn_date.year,
sa.extract("month", Transaction.date) == txn_date.month))
sa.extract("year", JournalEntry.date)
== journal_entry_date.year,
sa.extract("month", JournalEntry.date)
== journal_entry_date.month))
except ValueError:
pass
try:
txn_date = datetime.strptime(f"2000/{k}", "%Y/%m/%d")
journal_entry_date = datetime.strptime(f"2000/{k}", "%Y/%m/%d")
conditions.append(sa.and_(
sa.extract("month", Transaction.date) == txn_date.month,
sa.extract("day", Transaction.date) == txn_date.day))
sa.extract("month", JournalEntry.date)
== journal_entry_date.month,
sa.extract("day", JournalEntry.date)
== journal_entry_date.day))
except ValueError:
pass
return sa.select(Transaction.id).filter(sa.or_(*conditions))
return sa.select(JournalEntry.id).filter(sa.or_(*conditions))
class PageParams(BasePageParams):
"""The HTML page parameters."""
def __init__(self, pagination: Pagination[JournalEntry],
entries: list[JournalEntry]):
def __init__(self, pagination: Pagination[JournalEntryLineItem],
line_items: list[JournalEntryLineItem]):
"""Constructs the HTML page parameters.
:param entries: The search result entries.
:param line_items: The search result line items.
"""
self.pagination: Pagination[JournalEntry] = pagination
self.pagination: Pagination[JournalEntryLineItem] = pagination
"""The pagination."""
self.entries: list[JournalEntry] = entries
"""The entries."""
self.line_items: list[JournalEntryLineItem] = line_items
"""The line items."""
@property
def has_data(self) -> bool:
@ -166,7 +174,7 @@ class PageParams(BasePageParams):
:return: True if there is any data, or False otherwise.
"""
return len(self.entries) > 0
return len(self.line_items) > 0
@property
def report_chooser(self) -> ReportChooser:
@ -182,8 +190,9 @@ class Search(BaseReport):
def __init__(self):
"""Constructs a search."""
self.__entries: list[JournalEntry] = EntryCollector().entries
"""The journal entries."""
self.__line_items: list[JournalEntryLineItem] \
= LineItemCollector().line_items
"""The line items."""
def csv(self) -> Response:
"""Returns the report as CSV for download.
@ -191,16 +200,17 @@ 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.__entries))
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[JournalEntry] \
= Pagination[JournalEntry](self.__entries, is_reversed=True)
pagination: Pagination[JournalEntryLineItem] \
= Pagination[JournalEntryLineItem](self.__line_items,
is_reversed=True)
params: PageParams = PageParams(pagination=pagination,
entries=pagination.list)
line_items=pagination.list)
return render_template("accounting/report/search.html",
report=params)

View File

@ -24,7 +24,8 @@ from flask import Response, render_template
from accounting import db
from accounting.locale import gettext
from accounting.models import Currency, Account, Transaction, JournalEntry
from accounting.models import Currency, Account, JournalEntry, \
JournalEntryLineItem
from accounting.report.period import Period, PeriodChooser
from accounting.report.utils.base_page_params import BasePageParams
from accounting.report.utils.base_report import BaseReport
@ -178,16 +179,16 @@ class TrialBalance(BaseReport):
:return: None.
"""
conditions: list[sa.BinaryExpression] \
= [JournalEntry.currency_code == self.__currency.code]
= [JournalEntryLineItem.currency_code == self.__currency.code]
if self.__period.start is not None:
conditions.append(Transaction.date >= self.__period.start)
conditions.append(JournalEntry.date >= self.__period.start)
if self.__period.end is not None:
conditions.append(Transaction.date <= self.__period.end)
conditions.append(JournalEntry.date <= self.__period.end)
balance_func: sa.Function = sa.func.sum(sa.case(
(JournalEntry.is_debit, JournalEntry.amount),
else_=-JournalEntry.amount)).label("balance")
(JournalEntryLineItem.is_debit, JournalEntryLineItem.amount),
else_=-JournalEntryLineItem.amount)).label("balance")
select_balances: sa.Select = sa.select(Account.id, balance_func)\
.join(Transaction).join(Account)\
.join(JournalEntry).join(Account)\
.filter(*conditions)\
.group_by(Account.id)\
.order_by(Account.base_code, Account.no)

View File

@ -26,8 +26,8 @@ import sqlalchemy as sa
from flask import request
from accounting import db
from accounting.models import Currency, JournalEntry
from accounting.utils.txn_types import TransactionType
from accounting.models import Currency, JournalEntryLineItem
from accounting.utils.journal_entry_types import JournalEntryType
from .option_link import OptionLink
from .report_chooser import ReportChooser
@ -52,12 +52,12 @@ class BasePageParams(ABC):
"""
@property
def txn_types(self) -> t.Type[TransactionType]:
"""Returns the transaction types.
def journal_entry_types(self) -> t.Type[JournalEntryType]:
"""Returns the journal entry types.
:return: The transaction types.
:return: The journal entry types.
"""
return TransactionType
return JournalEntryType
@property
def csv_uri(self) -> str:
@ -81,8 +81,8 @@ class BasePageParams(ABC):
:return: The currency options.
"""
in_use: set[str] = set(db.session.scalars(
sa.select(JournalEntry.currency_code)
.group_by(JournalEntry.currency_code)).all())
sa.select(JournalEntryLineItem.currency_code)
.group_by(JournalEntryLineItem.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

@ -71,8 +71,8 @@ def default_ie_account_code() -> str:
:return: The default account code for the income and expenses log.
"""
with current_app.app_context():
return current_app.config.get("DEFAULT_IE_ACCOUNT", Account.CASH_CODE)
return current_app.config.get("ACCOUNTING_DEFAULT_IE_ACCOUNT",
Account.CASH_CODE)
def default_ie_account() -> IncomeExpensesAccount:

View File

@ -72,11 +72,14 @@
}
}
@media(max-width:767px) {
.accounting-toolbar {
width: 100%;
justify-content: space-evenly;
}
.accounting-toolbar > .btn:not(form), .accounting-toolbar > .btn-group > .btn {
height: 3.2rem;
width: 3.2rem;
border-radius: 50%;
margin-left: 1rem;
}
.accounting-toolbar > a.btn, .accounting-toolbar > .btn-group > a.btn {
padding-top: 0.7rem;
@ -117,29 +120,29 @@
}
/* Links between objects */
.accounting-original-entry {
.accounting-original-line-item {
border-top: thin solid darkslategray;
padding: 0.2rem 0.5rem;
}
.accounting-original-entry a {
.accounting-original-line-item a {
color: inherit;
text-decoration: none;
}
.accounting-original-entry a:hover {
.accounting-original-line-item a:hover {
color: inherit;
}
.accounting-offset-entries {
.accounting-offset-line-items {
border-top: thin solid darkslategray;
padding: 0.2rem 0.5rem;
}
.accounting-offset-entries ul li {
.accounting-offset-line-items ul li {
list-style: none;
}
.accounting-offset-entries ul li a {
.accounting-offset-line-items ul li a {
color: inherit;
text-decoration: none;
}
.accounting-offset-entries ul li a:hover {
.accounting-offset-line-items ul li a:hover {
color: inherit;
}
@ -149,38 +152,35 @@
overflow-y: scroll;
}
/** The transaction management */
/** The journal entry management */
.accounting-currency-control {
background-color: transparent;
}
.accounting-currency-content {
width: calc(100% - 3rem);
}
.accounting-entry-content {
.accounting-line-item-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-hover .list-group-item:hover {
background-color: #ececec;
}
.accounting-transaction-entry {
.accounting-journal-entry-line-item {
border: none;
}
.accounting-transaction-entry-header {
.accounting-journal-entry-line-item-header {
font-weight: bolder;
border-bottom: thick double slategray;
}
.list-group-item.accounting-transaction-entry-total {
.list-group-item.accounting-journal-entry-line-item-total {
font-weight: bolder;
border-top: thick double slategray;
}
.accounting-entry-editor-original-entry-content {
.accounting-line-item-editor-original-line-item-content {
width: calc(100% - 3rem);
}
@ -318,6 +318,22 @@ a.accounting-report-table-row {
padding-left: 1rem;
}
/* The description editor */
.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 Material Design text field (floating form control in Bootstrap) */
.accounting-material-text-field {
position: relative;
@ -342,7 +358,7 @@ a.accounting-report-table-row {
.accounting-material-fab {
position: fixed;
right: 2rem;
bottom: 1rem;
bottom: 2rem;
z-index: 10;
flex-direction: column-reverse;
}

View File

@ -310,7 +310,7 @@ class BaseAccountSelector {
const queryValues = JSON.parse(option.dataset.queryValues);
let isMatched = false;
for (const queryValue of queryValues) {
if (queryValue.includes(this.#query.value)) {
if (queryValue.toLowerCase().includes(this.#query.value.toLowerCase())) {
isMatched = true;
break;
}

View File

@ -1,5 +1,5 @@
/* The Mia! Accounting Flask Project
* transaction-transfer-form.js: The JavaScript for the transfer transaction form
* account-selector.js: The JavaScript for the account selector
*/
/* Copyright (c) 2023 imacat.
@ -29,16 +29,16 @@
class AccountSelector {
/**
* The journal entry editor
* @type {JournalEntryEditor}
* The line item editor
* @type {JournalEntryLineItemEditor}
*/
#entryEditor;
#lineItemEditor;
/**
* The entry type
* Either "debit" or "credit"
* @type {string}
*/
entryType;
#debitCredit;
/**
* The prefix of the HTML ID and class
@ -85,13 +85,13 @@ class AccountSelector {
/**
* Constructs an account selector.
*
* @param entryEditor {JournalEntryEditor} the journal entry editor
* @param entryType {string} the entry type, either "debit" or "credit"
* @param lineItemEditor {JournalEntryLineItemEditor} the line item editor
* @param debitCredit {string} either "debit" or "credit"
*/
constructor(entryEditor, entryType) {
this.#entryEditor = entryEditor
this.entryType = entryType;
this.#prefix = "accounting-account-selector-" + entryType;
constructor(lineItemEditor, debitCredit) {
this.#lineItemEditor = lineItemEditor
this.#debitCredit = debitCredit;
this.#prefix = "accounting-account-selector-" + debitCredit;
this.#query = document.getElementById(this.#prefix + "-query");
this.#queryNoResult = document.getElementById(this.#prefix + "-option-no-result");
this.#optionList = document.getElementById(this.#prefix + "-option-list");
@ -103,9 +103,9 @@ class AccountSelector {
this.#more.classList.add("d-none");
this.#filterOptions();
};
this.#clearButton.onclick = () => this.#entryEditor.clearAccount();
this.#clearButton.onclick = () => this.#lineItemEditor.clearAccount();
for (const option of this.#options) {
option.onclick = () => this.#entryEditor.saveAccount(option.dataset.code, option.dataset.content, option.classList.contains("accounting-account-is-need-offset"));
option.onclick = () => this.#lineItemEditor.saveAccount(option.dataset.code, option.dataset.content, option.classList.contains("accounting-account-is-need-offset"));
}
this.#query.addEventListener("input", () => {
this.#filterOptions();
@ -143,9 +143,9 @@ class AccountSelector {
* @return {string[]} the account codes that are used in the form
*/
#getCodesUsedInForm() {
const inUse = this.#entryEditor.form.getAccountCodesUsed(this.entryType);
if (this.#entryEditor.accountCode !== null) {
inUse.push(this.#entryEditor.accountCode);
const inUse = this.#lineItemEditor.form.getAccountCodesUsed(this.#debitCredit);
if (this.#lineItemEditor.accountCode !== null) {
inUse.push(this.#lineItemEditor.accountCode);
}
return inUse
}
@ -166,7 +166,7 @@ class AccountSelector {
}
const queryValues = JSON.parse(option.dataset.queryValues);
for (const queryValue of queryValues) {
if (queryValue.includes(query.value)) {
if (queryValue.toLowerCase().includes(query.value.toLowerCase())) {
return true;
}
}
@ -190,13 +190,13 @@ class AccountSelector {
this.#more.classList.remove("d-none");
this.#filterOptions();
for (const option of this.#options) {
if (option.dataset.code === this.#entryEditor.accountCode) {
if (option.dataset.code === this.#lineItemEditor.accountCode) {
option.classList.add("active");
} else {
option.classList.remove("active");
}
}
if (this.#entryEditor.accountCode === null) {
if (this.#lineItemEditor.accountCode === null) {
this.#clearButton.classList.add("btn-secondary");
this.#clearButton.classList.remove("btn-danger");
this.#clearButton.disabled = true;
@ -210,14 +210,14 @@ class AccountSelector {
/**
* Returns the account selector instances.
*
* @param entryEditor {JournalEntryEditor} the journal entry editor
* @param lineItemEditor {JournalEntryLineItemEditor} the line item editor
* @return {{debit: AccountSelector, credit: AccountSelector}}
*/
static getInstances(entryEditor) {
static getInstances(lineItemEditor) {
const selectors = {}
const modals = Array.from(document.getElementsByClassName("accounting-account-selector"));
for (const modal of modals) {
selectors[modal.dataset.entryType] = new AccountSelector(entryEditor, modal.dataset.entryType);
selectors[modal.dataset.debitCredit] = new AccountSelector(lineItemEditor, modal.dataset.debitCredit);
}
return selectors;
}

View File

@ -1,5 +1,5 @@
/* The Mia! Accounting Flask Project
* summary-editor.js: The JavaScript for the summary editor
* description-editor.js: The JavaScript for the description editor
*/
/* Copyright (c) 2023 imacat.
@ -23,19 +23,19 @@
"use strict";
/**
* A summary editor.
* A description editor.
*
*/
class SummaryEditor {
class DescriptionEditor {
/**
* The journal entry editor
* @type {JournalEntryEditor}
* The line item editor
* @type {JournalEntryLineItemEditor}
*/
#entryEditor;
lineItemEditor;
/**
* The summary editor form
* The description editor form
* @type {HTMLFormElement}
*/
#form;
@ -47,16 +47,16 @@ class SummaryEditor {
prefix;
/**
* The modal of the summary editor
* The modal of the description editor
* @type {HTMLDivElement}
*/
#modal;
/**
* The entry type, either "debit" or "credit"
* Either "debit" or "credit"
* @type {string}
*/
entryType;
debitCredit;
/**
* The current tab
@ -65,13 +65,13 @@ class SummaryEditor {
currentTab;
/**
* The summary input
* The description input
* @type {HTMLInputElement}
*/
summary;
description;
/**
* The button to the original entry selector
* The button to the original line item selector
* @type {HTMLButtonElement}
*/
#offsetButton;
@ -102,37 +102,37 @@ class SummaryEditor {
/**
* The tab planes
* @type {{general: GeneralTagTab, travel: GeneralTripTab, bus: BusTripTab, regular: RegularPaymentTab, annotation: AnnotationTab}}
* @type {{general: GeneralTagTab, travel: GeneralTripTab, bus: BusTripTab, recurring: RecurringTransactionTab, annotation: AnnotationTab}}
*/
tabPlanes = {};
/**
* Constructs a summary editor.
* Constructs a description editor.
*
* @param entryEditor {JournalEntryEditor} the journal entry editor
* @param entryType {string} the entry type, either "debit" or "credit"
* @param lineItemEditor {JournalEntryLineItemEditor} the line item editor
* @param debitCredit {string} either "debit" or "credit"
*/
constructor(entryEditor, entryType) {
this.#entryEditor = entryEditor;
this.entryType = entryType;
this.prefix = "accounting-summary-editor-" + entryType;
constructor(lineItemEditor, debitCredit) {
this.lineItemEditor = lineItemEditor;
this.debitCredit = debitCredit;
this.prefix = "accounting-description-editor-" + debitCredit;
this.#form = document.getElementById(this.prefix);
this.#modal = document.getElementById(this.prefix + "-modal");
this.summary = document.getElementById(this.prefix + "-summary");
this.description = document.getElementById(this.prefix + "-description");
this.#offsetButton = document.getElementById(this.prefix + "-offset");
this.number = document.getElementById(this.prefix + "-annotation-number");
this.note = document.getElementById(this.prefix + "-annotation-note");
// noinspection JSValidateTypes
this.#accountButtons = Array.from(document.getElementsByClassName(this.prefix + "-account"));
for (const cls of [GeneralTagTab, GeneralTripTab, BusTripTab, RegularPaymentTab, AnnotationTab]) {
for (const cls of [GeneralTagTab, GeneralTripTab, BusTripTab, RecurringTransactionTab, AnnotationTab]) {
const tab = new cls(this);
this.tabPlanes[tab.tabId()] = tab;
}
this.currentTab = this.tabPlanes.general;
this.#initializeSuggestedAccounts();
this.summary.onchange = () => this.#onSummaryChange();
this.#offsetButton.onclick = () => this.#entryEditor.originalEntrySelector.onOpen(this.#entryEditor);
this.description.onchange = () => this.#onDescriptionChange();
this.#offsetButton.onclick = () => this.lineItemEditor.originalLineItemSelector.onOpen();
this.#form.onsubmit = () => {
if (this.currentTab.validate()) {
this.#submit();
@ -142,12 +142,12 @@ class SummaryEditor {
}
/**
* The callback when the summary input is changed.
* The callback when the description input is changed.
*
*/
#onSummaryChange() {
this.summary.value = this.summary.value.trim();
for (const tabPlane of [this.tabPlanes.bus, this.tabPlanes.travel, this.tabPlanes.general]) {
#onDescriptionChange() {
this.description.value = this.description.value.trim();
for (const tabPlane of [this.tabPlanes.recurring, this.tabPlanes.bus, this.tabPlanes.travel, this.tabPlanes.general]) {
if (tabPlane.populate()) {
break;
}
@ -158,27 +158,31 @@ class SummaryEditor {
/**
* Filters the suggested accounts.
*
* @param tagButton {HTMLButtonElement|null} the tag button
* @param tagButton {HTMLButtonElement} the tag button
*/
filterSuggestedAccounts(tagButton) {
for (const accountButton of this.#accountButtons) {
accountButton.classList.add("d-none");
}
if (tagButton === null) {
this.#selectAccount(null);
return;
}
this.clearSuggestedAccounts();
const suggested = JSON.parse(tagButton.dataset.accounts);
let selectedAccountButton = null;
for (const accountButton of this.#accountButtons) {
if (suggested.includes(accountButton.dataset.code)) {
accountButton.classList.remove("d-none");
if (accountButton.dataset.code === suggested[0]) {
selectedAccountButton = accountButton;
this.#selectAccount(accountButton);
return;
}
}
}
this.#selectAccount(selectedAccountButton);
}
/**
* Clears the suggested accounts.
*
*/
clearSuggestedAccounts() {
for (const accountButton of this.#accountButtons) {
accountButton.classList.add("d-none");
}
this.#selectAccount(null);
}
/**
@ -209,34 +213,34 @@ class SummaryEditor {
}
/**
* Submits the summary.
* Submits the description.
*
*/
#submit() {
bootstrap.Modal.getOrCreateInstance(this.#modal).hide();
if (this.#selectedAccount !== null) {
this.#entryEditor.saveSummaryWithAccount(this.summary.value, this.#selectedAccount.dataset.code, this.#selectedAccount.dataset.text, this.#selectedAccount.classList.contains("accounting-account-is-need-offset"));
this.lineItemEditor.saveDescriptionWithAccount(this.description.value, this.#selectedAccount.dataset.code, this.#selectedAccount.dataset.text, this.#selectedAccount.classList.contains("accounting-account-is-need-offset"));
} else {
this.#entryEditor.saveSummary(this.summary.value);
this.lineItemEditor.saveDescription(this.description.value);
}
}
/**
* The callback when the summary editor is shown.
* The callback when the description editor is shown.
*
*/
onOpen() {
this.#reset();
this.summary.value = this.#entryEditor.summary === null? "": this.#entryEditor.summary;
this.#onSummaryChange();
this.description.value = this.lineItemEditor.description === null? "": this.lineItemEditor.description;
this.#onDescriptionChange();
}
/**
* Resets the summary editor.
* Resets the description editor.
*
*/
#reset() {
this.summary.value = "";
this.description.value = "";
for (const tabPlane of Object.values(this.tabPlanes)) {
tabPlane.reset();
}
@ -244,16 +248,16 @@ class SummaryEditor {
}
/**
* Returns the summary editor instances.
* Returns the description editor instances.
*
* @param entryEditor {JournalEntryEditor} the journal entry editor
* @return {{debit: SummaryEditor, credit: SummaryEditor}}
* @param lineItemEditor {JournalEntryLineItemEditor} the line item editor
* @return {{debit: DescriptionEditor, credit: DescriptionEditor}}
*/
static getInstances(entryEditor) {
static getInstances(lineItemEditor) {
const editors = {}
const forms = Array.from(document.getElementsByClassName("accounting-summary-editor"));
const forms = Array.from(document.getElementsByClassName("accounting-description-editor"));
for (const form of forms) {
editors[form.dataset.entryType] = new SummaryEditor(entryEditor, form.dataset.entryType);
editors[form.dataset.debitCredit] = new DescriptionEditor(lineItemEditor, form.dataset.debitCredit);
}
return editors;
}
@ -268,8 +272,8 @@ class SummaryEditor {
class TabPlane {
/**
* The parent summary editor
* @type {SummaryEditor}
* The parent description editor
* @type {DescriptionEditor}
*/
editor;
@ -294,7 +298,7 @@ class TabPlane {
/**
* Constructs a tab plane.
*
* @param editor {SummaryEditor} the parent summary editor
* @param editor {DescriptionEditor} the parent description editor
*/
constructor(editor) {
this.editor = editor;
@ -320,9 +324,9 @@ class TabPlane {
reset() { throw new Error("Method not implemented."); }
/**
* Populates the tab plane with the summary input.
* Populates the tab plane with the description input.
*
* @return {boolean} true if the summary input matches this tab, or false otherwise
* @return {boolean} true if the description input matches this tab, or false otherwise
* @abstract
*/
populate() { throw new Error("Method not implemented."); }
@ -383,7 +387,7 @@ class TagTabPlane extends TabPlane {
/**
* Constructs a tab plane.
*
* @param editor {SummaryEditor} the parent summary editor
* @param editor {DescriptionEditor} the parent description editor
* @override
*/
constructor(editor) {
@ -395,7 +399,7 @@ class TagTabPlane extends TabPlane {
this.initializeTagButtons();
this.tag.onchange = () => {
this.onTagChange();
this.updateSummary();
this.updateDescription();
};
}
@ -418,17 +422,17 @@ class TagTabPlane extends TabPlane {
}
}
if (!isMatched) {
this.editor.filterSuggestedAccounts(null);
this.editor.clearSuggestedAccounts();
}
this.validateTag();
}
/**
* Updates the summary according to the input in the tab plane.
* Updates the description according to the input in the tab plane.
*
* @abstract
*/
updateSummary() { throw new Error("Method not implemented."); }
updateDescription() { throw new Error("Method not implemented."); }
/**
* Switches to the tab plane.
@ -436,14 +440,13 @@ class TagTabPlane extends TabPlane {
*/
switchToMe() {
super.switchToMe();
let selectedTagButton = null;
for (const tagButton of this.tagButtons) {
if (tagButton.classList.contains("btn-primary")) {
selectedTagButton = tagButton;
break;
this.editor.filterSuggestedAccounts(tagButton);
return;
}
}
this.editor.filterSuggestedAccounts(selectedTagButton);
this.editor.clearSuggestedAccounts();
}
/**
@ -461,7 +464,7 @@ class TagTabPlane extends TabPlane {
tagButton.classList.add("btn-primary");
this.tag.value = tagButton.dataset.value;
this.editor.filterSuggestedAccounts(tagButton);
this.updateSummary();
this.updateDescription();
};
}
}
@ -532,28 +535,28 @@ class GeneralTagTab extends TagTabPlane {
};
/**
* Updates the summary according to the input in the tab plane.
* Updates the description according to the input in the tab plane.
*
* @override
*/
updateSummary() {
const pos = this.editor.summary.value.indexOf("—");
updateDescription() {
const pos = this.editor.description.value.indexOf("—");
const prefix = this.tag.value === ""? "": this.tag.value + "—";
if (pos === -1) {
this.editor.summary.value = prefix + this.editor.summary.value;
this.editor.description.value = prefix + this.editor.description.value;
} else {
this.editor.summary.value = prefix + this.editor.summary.value.substring(pos + 1);
this.editor.description.value = prefix + this.editor.description.value.substring(pos + 1);
}
}
/**
* Populates the tab plane with the summary input.
* Populates the tab plane with the description input.
*
* @return {boolean} true if the summary input matches this tab, or false otherwise
* @return {boolean} true if the description input matches this tab, or false otherwise
* @override
*/
populate() {
const found = this.editor.summary.value.match(/^([^—]+)—/);
const found = this.editor.description.value.match(/^([^—]+)—/);
if (found === null) {
return false;
}
@ -561,13 +564,6 @@ class GeneralTagTab extends TagTabPlane {
this.tag.value = found[1];
this.onTagChange();
}
for (const tagButton of this.tagButtons) {
if (tagButton.dataset.value === this.tag.value) {
tagButton.classList.remove("btn-outline-primary");
tagButton.classList.add("btn-primary");
this.editor.filterSuggestedAccounts(tagButton);
}
}
this.switchToMe();
return true;
}
@ -622,7 +618,7 @@ class GeneralTripTab extends TagTabPlane {
/**
* Constructs a tab plane.
*
* @param editor {SummaryEditor} the parent summary editor
* @param editor {DescriptionEditor} the parent description editor
* @override
*/
constructor(editor) {
@ -635,7 +631,7 @@ class GeneralTripTab extends TagTabPlane {
this.#directionButtons = Array.from(document.getElementsByClassName(this.prefix + "-direction"));
this.#from.onchange = () => {
this.#from.value = this.#from.value.trim();
this.updateSummary();
this.updateDescription();
this.validateFrom();
};
for (const directionButton of this.#directionButtons) {
@ -646,12 +642,12 @@ class GeneralTripTab extends TagTabPlane {
}
directionButton.classList.remove("btn-outline-primary");
directionButton.classList.add("btn-primary");
this.updateSummary();
this.updateDescription();
};
}
this.#to.onchange = () => {
this.#to.value = this.#to.value.trim();
this.updateSummary();
this.updateDescription();
this.validateTo();
};
}
@ -667,11 +663,11 @@ class GeneralTripTab extends TagTabPlane {
};
/**
* Updates the summary according to the input in the tab plane.
* Updates the description according to the input in the tab plane.
*
* @override
*/
updateSummary() {
updateDescription() {
let direction;
for (const directionButton of this.#directionButtons) {
if (directionButton.classList.contains("btn-primary")) {
@ -679,7 +675,7 @@ class GeneralTripTab extends TagTabPlane {
break;
}
}
this.editor.summary.value = this.tag.value + "—" + this.#from.value + direction + this.#to.value;
this.editor.description.value = this.tag.value + "—" + this.#from.value + direction + this.#to.value;
}
/**
@ -707,13 +703,13 @@ class GeneralTripTab extends TagTabPlane {
}
/**
* Populates the tab plane with the summary input.
* Populates the tab plane with the description input.
*
* @return {boolean} true if the summary input matches this tab, or false otherwise
* @return {boolean} true if the description input matches this tab, or false otherwise
* @override
*/
populate() {
const found = this.editor.summary.value.match(/^([^—]+)—([^—→↔]+)([→↔])(.+?)(?:[*×]\d+)?(?:\([^()]+\))?$/);
const found = this.editor.description.value.match(/^([^—]+)—([^—→↔]+)([→↔])(.+?)(?:[*×]\d+)?(?:\([^()]+\))?$/);
if (found === null) {
return false;
}
@ -732,13 +728,6 @@ class GeneralTripTab extends TagTabPlane {
}
}
this.#to.value = found[4];
for (const tagButton of this.tagButtons) {
if (tagButton.dataset.value === this.tag.value) {
tagButton.classList.remove("btn-outline-primary");
tagButton.classList.add("btn-primary");
this.editor.filterSuggestedAccounts(tagButton);
}
}
this.switchToMe();
return true;
}
@ -834,7 +823,7 @@ class BusTripTab extends TagTabPlane {
/**
* Constructs a tab plane.
*
* @param editor {SummaryEditor} the parent summary editor
* @param editor {DescriptionEditor} the parent description editor
* @override
*/
constructor(editor) {
@ -847,17 +836,17 @@ class BusTripTab extends TagTabPlane {
this.#toError = document.getElementById(this.prefix + "-to-error")
this.#route.onchange = () => {
this.#route.value = this.#route.value.trim();
this.updateSummary();
this.updateDescription();
this.validateRoute();
};
this.#from.onchange = () => {
this.#from.value = this.#from.value.trim();
this.updateSummary();
this.updateDescription();
this.validateFrom();
};
this.#to.onchange = () => {
this.#to.value = this.#to.value.trim();
this.updateSummary();
this.updateDescription();
this.validateTo();
};
}
@ -873,12 +862,12 @@ class BusTripTab extends TagTabPlane {
};
/**
* Updates the summary according to the input in the tab plane.
* Updates the description according to the input in the tab plane.
*
* @override
*/
updateSummary() {
this.editor.summary.value = this.tag.value + "—" + this.#route.value + "—" + this.#from.value + "→" + this.#to.value;
updateDescription() {
this.editor.description.value = this.tag.value + "—" + this.#route.value + "—" + this.#from.value + "→" + this.#to.value;
}
/**
@ -900,13 +889,13 @@ class BusTripTab extends TagTabPlane {
}
/**
* Populates the tab plane with the summary input.
* Populates the tab plane with the description input.
*
* @return {boolean} true if the summary input matches this tab, or false otherwise
* @return {boolean} true if the description input matches this tab, or false otherwise
* @override
*/
populate() {
const found = this.editor.summary.value.match(/^([^—]+)—([^—]+)—([^—→]+)→(.+?)(?:[*×]\d+)?(?:\([^()]+\))?$/);
const found = this.editor.description.value.match(/^([^—]+)—([^—]+)—([^—→]+)→(.+?)(?:[*×]\d+)?(?:\([^()]+\))?$/);
if (found === null) {
return false;
}
@ -917,14 +906,6 @@ class BusTripTab extends TagTabPlane {
this.#route.value = found[2];
this.#from.value = found[3];
this.#to.value = found[4];
for (const tagButton of this.tagButtons) {
if (tagButton.dataset.value === this.tag.value) {
tagButton.classList.remove("btn-outline-primary");
tagButton.classList.add("btn-primary");
this.editor.filterSuggestedAccounts(tagButton);
break;
}
}
this.switchToMe();
return true;
}
@ -985,29 +966,71 @@ class BusTripTab extends TagTabPlane {
}
/**
* The regular payment tab plane.
* The recurring transaction tab plane.
*
* @private
*/
class RegularPaymentTab extends TabPlane {
class RecurringTransactionTab extends TabPlane {
/**
* The payment buttons
* The month names
* @type {string[]}
*/
#monthNames;
/**
* The buttons of the recurring items
* @type {HTMLButtonElement[]}
*/
#payments;
#itemButtons;
// noinspection JSValidateTypes
/**
* Constructs a tab plane.
*
* @param editor {SummaryEditor} the parent summary editor
* @param editor {DescriptionEditor} the parent description editor
* @override
*/
constructor(editor) {
super(editor);
this.#monthNames = [
"",
A_("January"), A_("February"), A_("March"), A_("April"),
A_("May"), A_("June"), A_("July"), A_("August"),
A_("September"), A_("October"), A_("November"), A_("December"),
];
// noinspection JSValidateTypes
this.#payments = Array.from(document.getElementsByClassName(this.prefix + "-payment"));
this.#itemButtons = Array.from(document.getElementsByClassName(this.prefix + "-item"));
for (const itemButton of this.#itemButtons) {
itemButton.onclick = () => {
this.reset();
itemButton.classList.add("btn-primary");
itemButton.classList.remove("btn-outline-primary");
this.editor.description.value = this.#getDescription(itemButton);
this.editor.filterSuggestedAccounts(itemButton);
};
}
}
/**
* Returns the description for a recurring item.
*
* @param itemButton {HTMLButtonElement} the recurring item
* @return {string} the description of the recurring item
*/
#getDescription(itemButton) {
const today = new Date(this.editor.lineItemEditor.form.getDate());
const thisMonth = today.getMonth() + 1;
const lastMonth = (thisMonth + 10) % 12 + 1;
const lastBimonthlyFrom = ((thisMonth + thisMonth % 2 + 8) % 12 + 1);
const lastBimonthlyTo = ((thisMonth + thisMonth % 2 + 9) % 12 + 1);
return itemButton.dataset.template
.replaceAll("{this_month_number}", String(thisMonth))
.replaceAll("{this_month_name}", this.#monthNames[thisMonth])
.replaceAll("{last_month_number}", String(lastMonth))
.replaceAll("{last_month_name}", this.#monthNames[lastMonth])
.replaceAll("{last_bimonthly_number}", String(lastBimonthlyFrom) + "" + String(lastBimonthlyTo))
.replaceAll("{last_bimonthly_name}", this.#monthNames[lastBimonthlyFrom] + "" + this.#monthNames[lastBimonthlyTo]);
}
/**
@ -1017,7 +1040,7 @@ class RegularPaymentTab extends TabPlane {
* @abstract
*/
tabId() {
return "regular";
return "recurring";
};
/**
@ -1026,22 +1049,45 @@ class RegularPaymentTab extends TabPlane {
* @override
*/
reset() {
for (const payment of this.#payments) {
payment.classList.remove("btn-primary");
payment.classList.add("btn-outline-primary");
for (const itemButton of this.#itemButtons) {
itemButton.classList.remove("btn-primary");
itemButton.classList.add("btn-outline-primary");
}
}
/**
* Populates the tab plane with the summary input.
* Populates the tab plane with the description input.
*
* @return {boolean} true if the summary input matches this tab, or false otherwise
* @return {boolean} true if the description input matches this tab, or false otherwise
* @override
*/
populate() {
for (const itemButton of this.#itemButtons) {
if (this.#getDescription(itemButton) === this.editor.description.value) {
itemButton.classList.add("btn-primary");
itemButton.classList.remove("btn-outline-primary");
this.switchToMe();
return true;
}
}
return false;
}
/**
* Switches to the tab plane.
*
*/
switchToMe() {
super.switchToMe();
for (const itemButton of this.#itemButtons) {
if (itemButton.classList.contains("btn-primary")) {
this.editor.filterSuggestedAccounts(itemButton);
return;
}
}
this.editor.clearSuggestedAccounts();
}
/**
* Validates the input in the tab plane.
*
@ -1063,15 +1109,15 @@ class AnnotationTab extends TabPlane {
/**
* Constructs a tab plane.
*
* @param editor {SummaryEditor} the parent summary editor
* @param editor {DescriptionEditor} the parent description editor
* @override
*/
constructor(editor) {
super(editor);
this.editor.number.onchange = () => this.updateSummary();
this.editor.number.onchange = () => this.updateDescription();
this.editor.note.onchange = () => {
this.editor.note.value = this.editor.note.value.trim();
this.updateSummary();
this.updateDescription();
};
}
@ -1086,20 +1132,20 @@ class AnnotationTab extends TabPlane {
};
/**
* Updates the summary according to the input in the tab plane.
* Updates the description according to the input in the tab plane.
*
* @override
*/
updateSummary() {
const found = this.editor.summary.value.match(/^(.*?)(?:[*×]\d+)?(?:\([^()]+\))?$/);
updateDescription() {
const found = this.editor.description.value.match(/^(.*?)(?:[*×]\d+)?(?:\([^()]+\))?$/);
if (found !== null) {
this.editor.summary.value = found[1];
this.editor.description.value = found[1];
}
if (parseInt(this.editor.number.value) > 1) {
this.editor.summary.value = this.editor.summary.value + "×" + this.editor.number.value;
this.editor.description.value = this.editor.description.value + "×" + this.editor.number.value;
}
if (this.editor.note.value !== "") {
this.editor.summary.value = this.editor.summary.value + "(" + this.editor.note.value + ")";
this.editor.description.value = this.editor.description.value + "(" + this.editor.note.value + ")";
}
}
@ -1114,25 +1160,25 @@ class AnnotationTab extends TabPlane {
}
/**
* Populates the tab plane with the summary input.
* Populates the tab plane with the description input.
*
* @return {boolean} true if the summary input matches this tab, or false otherwise
* @return {boolean} true if the description input matches this tab, or false otherwise
* @override
*/
populate() {
const found = this.editor.summary.value.match(/^(.*?)(?:[*×](\d+))?(?:\(([^()]+)\))?$/);
this.editor.summary.value = found[1];
const found = this.editor.description.value.match(/^(.*?)(?:[*×](\d+))?(?:\(([^()]+)\))?$/);
this.editor.description.value = found[1];
if (found[2] === undefined || parseInt(found[2]) === 1) {
this.editor.number.value = "";
} else {
this.editor.number.value = found[2];
this.editor.summary.value = this.editor.summary.value + "×" + this.editor.number.value;
this.editor.description.value = this.editor.description.value + "×" + this.editor.number.value;
}
if (found[3] === undefined) {
this.editor.note.value = "";
} else {
this.editor.note.value = found[3];
this.editor.summary.value = this.editor.summary.value + "(" + this.editor.note.value + ")";
this.editor.description.value = this.editor.description.value + "(" + this.editor.note.value + ")";
}
return true;
}

View File

@ -1,596 +0,0 @@
/* The Mia! Accounting Flask Project
* journal-entry-editor.js: The JavaScript for the journal entry editor
*/
/* 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/25
*/
"use strict";
/**
* The journal entry editor.
*
*/
class JournalEntryEditor {
/**
* The transaction form
* @type {TransactionForm}
*/
form;
/**
* The journal entry editor
* @type {HTMLFormElement}
*/
#element;
/**
* The bootstrap modal
* @type {HTMLDivElement}
*/
#modal;
/**
* The entry type, either "debit" or "credit"
* @type {string}
*/
entryType;
/**
* The prefix of the HTML ID and class
* @type {string}
*/
#prefix = "accounting-entry-editor"
/**
* The container of the original entry
* @type {HTMLDivElement}
*/
#originalEntryContainer;
/**
* The control of the original entry
* @type {HTMLDivElement}
*/
#originalEntryControl;
/**
* The original entry
* @type {HTMLDivElement}
*/
#originalEntry;
/**
* The error message of the original entry
* @type {HTMLDivElement}
*/
#originalEntryError;
/**
* The delete button of the original entry
* @type {HTMLButtonElement}
*/
#originalEntryDelete;
/**
* The control of the summary
* @type {HTMLDivElement}
*/
#summaryControl;
/**
* The summary
* @type {HTMLDivElement}
*/
#summary;
/**
* The error message of the summary
* @type {HTMLDivElement}
*/
#summaryError;
/**
* The control of the account
* @type {HTMLDivElement}
*/
#accountControl;
/**
* The account
* @type {HTMLDivElement}
*/
#account;
/**
* The error message of the account
* @type {HTMLDivElement}
*/
#accountError;
/**
* The amount
* @type {HTMLInputElement}
*/
#amount;
/**
* The error message of the amount
* @type {HTMLDivElement}
*/
#amountError;
/**
* The journal entry to edit
* @type {JournalEntrySubForm|null}
*/
entry;
/**
* The debit or credit entry side sub-form
* @type {DebitCreditSideSubForm}
*/
#side;
/**
* Whether the journal entry needs offset
* @type {boolean}
*/
isNeedOffset = false;
/**
* The ID of the original entry
* @type {string|null}
*/
originalEntryId = null;
/**
* The date of the original entry
* @type {string|null}
*/
originalEntryDate = null;
/**
* The text of the original entry
* @type {string|null}
*/
originalEntryText = null;
/**
* The account code
* @type {string|null}
*/
accountCode = null;
/**
* The account text
* @type {string|null}
*/
accountText = null;
/**
* The summary
* @type {string|null}
*/
summary = null;
/**
* The amount
* @type {string}
*/
amount = "";
/**
* The summary editors
* @type {{debit: SummaryEditor, credit: SummaryEditor}}
*/
#summaryEditors;
/**
* The account selectors
* @type {{debit: AccountSelector, credit: AccountSelector}}
*/
#accountSelectors;
/**
* The original entry selector
* @type {OriginalEntrySelector}
*/
originalEntrySelector;
/**
* Constructs a new journal entry editor.
*
* @param form {TransactionForm} the transaction form
*/
constructor(form) {
this.form = form;
this.#element = document.getElementById(this.#prefix);
this.#modal = document.getElementById(this.#prefix + "-modal");
this.#originalEntryContainer = document.getElementById(this.#prefix + "-original-entry-container");
this.#originalEntryControl = document.getElementById(this.#prefix + "-original-entry-control");
this.#originalEntry = document.getElementById(this.#prefix + "-original-entry");
this.#originalEntryError = document.getElementById(this.#prefix + "-original-entry-error");
this.#originalEntryDelete = document.getElementById(this.#prefix + "-original-entry-delete");
this.#summaryControl = document.getElementById(this.#prefix + "-summary-control");
this.#summary = document.getElementById(this.#prefix + "-summary");
this.#summaryError = document.getElementById(this.#prefix + "-summary-error");
this.#accountControl = document.getElementById(this.#prefix + "-account-control");
this.#account = document.getElementById(this.#prefix + "-account");
this.#accountError = document.getElementById(this.#prefix + "-account-error")
this.#amount = document.getElementById(this.#prefix + "-amount");
this.#amountError = document.getElementById(this.#prefix + "-amount-error");
this.#summaryEditors = SummaryEditor.getInstances(this);
this.#accountSelectors = AccountSelector.getInstances(this);
this.originalEntrySelector = new OriginalEntrySelector();
this.#originalEntryControl.onclick = () => this.originalEntrySelector.onOpen(this, this.originalEntryId)
this.#originalEntryDelete.onclick = () => this.clearOriginalEntry();
this.#summaryControl.onclick = () => this.#summaryEditors[this.entryType].onOpen();
this.#accountControl.onclick = () => this.#accountSelectors[this.entryType].onOpen();
this.#amount.onchange = () => this.#validateAmount();
this.#element.onsubmit = () => {
if (this.#validate()) {
if (this.entry === null) {
this.entry = this.#side.addJournalEntry();
}
this.amount = this.#amount.value;
this.entry.save(this);
bootstrap.Modal.getInstance(this.#modal).hide();
}
return false;
};
}
/**
* Saves the original entry from the original entry selector.
*
* @param originalEntry {OriginalEntry} the original entry
*/
saveOriginalEntry(originalEntry) {
this.isNeedOffset = false;
this.#originalEntryContainer.classList.remove("d-none");
this.#originalEntryControl.classList.add("accounting-not-empty");
this.originalEntryId = originalEntry.id;
this.originalEntryDate = originalEntry.date;
this.originalEntryText = originalEntry.text;
this.#originalEntry.innerText = originalEntry.text;
this.#setEnableSummaryAccount(false);
if (originalEntry.summary === "") {
this.#summaryControl.classList.remove("accounting-not-empty");
} else {
this.#summaryControl.classList.add("accounting-not-empty");
}
this.summary = originalEntry.summary === ""? null: originalEntry.summary;
this.#summary.innerText = originalEntry.summary;
this.#accountControl.classList.add("accounting-not-empty");
this.accountCode = originalEntry.accountCode;
this.accountText = originalEntry.accountText;
this.#account.innerText = originalEntry.accountText;
this.#amount.value = String(originalEntry.netBalance);
this.#amount.max = String(originalEntry.netBalance);
this.#amount.min = "0";
this.#validate();
}
/**
* Clears the original entry.
*
*/
clearOriginalEntry() {
this.isNeedOffset = false;
this.#originalEntryContainer.classList.add("d-none");
this.#originalEntryControl.classList.remove("accounting-not-empty");
this.originalEntryId = null;
this.originalEntryDate = null;
this.originalEntryText = null;
this.#originalEntry.innerText = "";
this.#setEnableSummaryAccount(true);
this.#accountControl.classList.remove("accounting-not-empty");
this.accountCode = null;
this.accountText = null;
this.#account.innerText = "";
this.#amount.max = "";
}
/**
* Returns the currency code.
*
* @return {string} the currency code
*/
getCurrencyCode() {
return this.#side.currency.getCurrencyCode();
}
/**
* Saves the summary from the summary editor.
*
* @param summary {string} the summary
*/
saveSummary(summary) {
if (summary === "") {
this.#summaryControl.classList.remove("accounting-not-empty");
} else {
this.#summaryControl.classList.add("accounting-not-empty");
}
this.summary = summary === ""? null: summary;
this.#summary.innerText = summary;
this.#validateSummary();
bootstrap.Modal.getOrCreateInstance(this.#modal).show();
}
/**
* Saves the summary with the suggested account from the summary editor.
*
* @param summary {string} the summary
* @param accountCode {string} the account code
* @param accountText {string} the account text
* @param isAccountNeedOffset {boolean} true if the journal entries in the account need offset, or false otherwise
*/
saveSummaryWithAccount(summary, accountCode, accountText, isAccountNeedOffset) {
this.isNeedOffset = isAccountNeedOffset;
this.#accountControl.classList.add("accounting-not-empty");
this.accountCode = accountCode;
this.accountText = accountText;
this.#account.innerText = accountText;
this.#validateAccount();
this.saveSummary(summary)
}
/**
* Clears the account.
*
*/
clearAccount() {
this.isNeedOffset = false;
this.#accountControl.classList.remove("accounting-not-empty");
this.accountCode = null;
this.accountText = null;
this.#account.innerText = "";
this.#validateAccount();
}
/**
* Sets the account.
*
* @param code {string} the account code
* @param text {string} the account text
* @param isNeedOffset {boolean} true if the journal entries in the account need offset or false otherwise
*/
saveAccount(code, text, isNeedOffset) {
this.isNeedOffset = isNeedOffset;
this.#accountControl.classList.add("accounting-not-empty");
this.accountCode = code;
this.accountText = text;
this.#account.innerText = text;
this.#validateAccount();
}
/**
* Validates the form.
*
* @returns {boolean} true if valid, or false otherwise
*/
#validate() {
let isValid = true;
isValid = this.#validateOriginalEntry() && isValid;
isValid = this.#validateSummary() && isValid;
isValid = this.#validateAccount() && isValid;
isValid = this.#validateAmount() && isValid
return isValid;
}
/**
* Validates the original entry.
*
* @return {boolean} true if valid, or false otherwise
* @private
*/
#validateOriginalEntry() {
this.#originalEntryControl.classList.remove("is-invalid");
this.#originalEntryError.innerText = "";
return true;
}
/**
* Validates the summary.
*
* @return {boolean} true if valid, or false otherwise
* @private
*/
#validateSummary() {
this.#summary.classList.remove("is-invalid");
this.#summaryError.innerText = "";
return true;
}
/**
* Validates the account.
*
* @return {boolean} true if valid, or false otherwise
*/
#validateAccount() {
if (this.accountCode === null) {
this.#accountControl.classList.add("is-invalid");
this.#accountError.innerText = A_("Please select the account.");
return false;
}
this.#accountControl.classList.remove("is-invalid");
this.#accountError.innerText = "";
return true;
}
/**
* Validates the amount.
*
* @return {boolean} true if valid, or false otherwise
* @private
*/
#validateAmount() {
this.#amount.value = this.#amount.value.trim();
this.#amount.classList.remove("is-invalid");
if (this.#amount.value === "") {
this.#amount.classList.add("is-invalid");
this.#amountError.innerText = A_("Please fill in the amount.");
return false;
}
const amount =new Decimal(this.#amount.value);
if (amount.lessThanOrEqualTo(0)) {
this.#amount.classList.add("is-invalid");
this.#amountError.innerText = A_("Please fill in a positive amount.");
return false;
}
if (this.#amount.max !== "") {
if (amount.greaterThan(new Decimal(this.#amount.max))) {
this.#amount.classList.add("is-invalid");
this.#amountError.innerText = A_("The amount must not exceed the net balance %(balance)s of the original entry.", {balance: new Decimal(this.#amount.max)});
return false;
}
}
if (this.#amount.min !== "") {
const min = new Decimal(this.#amount.min);
if (amount.lessThan(min)) {
this.#amount.classList.add("is-invalid");
this.#amountError.innerText = A_("The amount must not be less than the offset total %(total)s.", {total: formatDecimal(min)});
return false;
}
}
this.#amount.classList.remove("is-invalid");
this.#amountError.innerText = "";
return true;
}
/**
* The callback when adding a new journal entry.
*
* @param side {DebitCreditSideSubForm} the debit or credit side sub-form
*/
onAddNew(side) {
this.entry = null;
this.#side = side;
this.entryType = this.#side.entryType;
this.isNeedOffset = false;
this.#originalEntryContainer.classList.add("d-none");
this.#originalEntryControl.classList.remove("accounting-not-empty");
this.#originalEntryControl.classList.remove("is-invalid");
this.originalEntryId = null;
this.originalEntryDate = null;
this.originalEntryText = null;
this.#originalEntry.innerText = "";
this.#setEnableSummaryAccount(true);
this.#summaryControl.classList.remove("accounting-not-empty");
this.#summaryControl.classList.remove("is-invalid");
this.summary = null;
this.#summary.innerText = ""
this.#summaryError.innerText = ""
this.#accountControl.classList.remove("accounting-not-empty");
this.#accountControl.classList.remove("is-invalid");
this.accountCode = null;
this.accountText = null;
this.#account.innerText = "";
this.#accountError.innerText = "";
this.#amount.value = "";
this.#amount.max = "";
this.#amount.min = "0";
this.#amount.classList.remove("is-invalid");
this.#amountError.innerText = "";
}
/**
* The callback when editing a journal entry.
*
* @param entry {JournalEntrySubForm} the journal entry sub-form
*/
onEdit(entry) {
this.entry = entry;
this.#side = entry.side;
this.entryType = this.#side.entryType;
this.isNeedOffset = entry.isNeedOffset();
this.originalEntryId = entry.getOriginalEntryId();
this.originalEntryDate = entry.getOriginalEntryDate();
this.originalEntryText = entry.getOriginalEntryText();
this.#originalEntry.innerText = this.originalEntryText;
if (this.originalEntryId === null) {
this.#originalEntryContainer.classList.add("d-none");
this.#originalEntryControl.classList.remove("accounting-not-empty");
} else {
this.#originalEntryContainer.classList.remove("d-none");
this.#originalEntryControl.classList.add("accounting-not-empty");
}
this.#setEnableSummaryAccount(!entry.isMatched && this.originalEntryId === null);
this.summary = entry.getSummary();
if (this.summary === null) {
this.#summaryControl.classList.remove("accounting-not-empty");
} else {
this.#summaryControl.classList.add("accounting-not-empty");
}
this.#summary.innerText = this.summary === null? "": this.summary;
if (entry.getAccountCode() === null) {
this.#accountControl.classList.remove("accounting-not-empty");
} else {
this.#accountControl.classList.add("accounting-not-empty");
}
this.accountCode = entry.getAccountCode();
this.accountText = entry.getAccountText();
this.#account.innerText = this.accountText;
this.#amount.value = entry.getAmount() === null? "": String(entry.getAmount());
const maxAmount = this.#getMaxAmount();
this.#amount.max = maxAmount === null? "": maxAmount;
this.#amount.min = entry.getAmountMin() === null? "": String(entry.getAmountMin());
this.#validate();
}
/**
* Finds out the max amount.
*
* @return {Decimal|null} the max amount
*/
#getMaxAmount() {
if (this.originalEntryId === null) {
return null;
}
return this.originalEntrySelector.getNetBalance(this.entry, this.form, this.originalEntryId);
}
/**
* Sets the enable status of the summary and account.
*
* @param isEnabled {boolean} true to enable, or false otherwise
*/
#setEnableSummaryAccount(isEnabled) {
if (isEnabled) {
this.#summaryControl.dataset.bsToggle = "modal";
this.#summaryControl.dataset.bsTarget = "#accounting-summary-editor-" + this.#side.entryType + "-modal";
this.#summaryControl.classList.remove("accounting-disabled");
this.#summaryControl.classList.add("accounting-clickable");
this.#accountControl.dataset.bsToggle = "modal";
this.#accountControl.dataset.bsTarget = "#accounting-account-selector-" + this.#side.entryType + "-modal";
this.#accountControl.classList.remove("accounting-disabled");
this.#accountControl.classList.add("accounting-clickable");
} else {
this.#summaryControl.dataset.bsToggle = "";
this.#summaryControl.dataset.bsTarget = "";
this.#summaryControl.classList.add("accounting-disabled");
this.#summaryControl.classList.remove("accounting-clickable");
this.#accountControl.dataset.bsToggle = "";
this.#accountControl.dataset.bsTarget = "";
this.#accountControl.classList.add("accounting-disabled");
this.#accountControl.classList.remove("accounting-clickable");
}
}
}

View File

@ -1,5 +1,5 @@
/* The Mia! Accounting Flask Project
* transaction-form.js: The JavaScript for the transaction form
* journal-entry-form.js: The JavaScript for the journal entry form
*/
/* Copyright (c) 2023 imacat.
@ -23,14 +23,14 @@
"use strict";
document.addEventListener("DOMContentLoaded", () => {
TransactionForm.initialize();
JournalEntryForm.initialize();
});
/**
* The transaction form
* The journal entry form
*
*/
class TransactionForm {
class JournalEntryForm {
/**
* The form element
@ -39,10 +39,10 @@ class TransactionForm {
#element;
/**
* The template to add a new journal entry
* The template to add a new line item
* @type {string}
*/
entryTemplate;
lineItemTemplate;
/**
* The date
@ -99,18 +99,18 @@ class TransactionForm {
#noteError;
/**
* The journal entry editor
* @type {JournalEntryEditor}
* The line item editor
* @type {JournalEntryLineItemEditor}
*/
entryEditor;
lineItemEditor;
/**
* Constructs the transaction form.
* Constructs the journal entry form.
*
*/
constructor() {
this.#element = document.getElementById("accounting-form");
this.entryTemplate = this.#element.dataset.entryTemplate;
this.lineItemTemplate = this.#element.dataset.lineItemTemplate;
this.#date = document.getElementById("accounting-date");
this.#dateError = document.getElementById("accounting-date-error");
this.#currencyControl = document.getElementById("accounting-currencies");
@ -121,7 +121,7 @@ class TransactionForm {
this.#addCurrencyButton = document.getElementById("accounting-add-currency");
this.#note = document.getElementById("accounting-note");
this.#noteError = document.getElementById("accounting-note-error");
this.entryEditor = new JournalEntryEditor(this);
this.lineItemEditor = new JournalEntryLineItemEditor(this);
this.#addCurrencyButton.onclick = () => {
const newIndex = 1 + (this.#currencies.length === 0? 0: Math.max(...this.#currencies.map((currency) => currency.index)));
@ -162,14 +162,14 @@ class TransactionForm {
this.#currencies[0].deleteButton.classList.add("d-none");
} else {
for (const currency of this.#currencies) {
let isAnyEntryMatched = false;
for (const entry of currency.getEntries()) {
if (entry.isMatched) {
isAnyEntryMatched = true;
let isAnyLineItemMatched = false;
for (const lineItem of currency.getLineItems()) {
if (lineItem.isMatched) {
isAnyLineItemMatched = true;
break;
}
}
if (isAnyEntryMatched) {
if (isAnyLineItemMatched) {
currency.deleteButton.classList.add("d-none");
} else {
currency.deleteButton.classList.remove("d-none");
@ -193,27 +193,27 @@ class TransactionForm {
}
/**
* Returns all the journal entries in the form.
* Returns all the line items in the form.
*
* @param entryType {string|null} the entry type, either "debit" or "credit", or null for both
* @return {JournalEntrySubForm[]} all the journal entry sub-forms
* @param debitCredit {string|null} Either "debit" or "credit", or null for both
* @return {LineItemSubForm[]} all the line item sub-forms
*/
getEntries(entryType = null) {
const entries = [];
getLineItems(debitCredit = null) {
const lineItems = [];
for (const currency of this.#currencies) {
entries.push(...currency.getEntries(entryType));
lineItems.push(...currency.getLineItems(debitCredit));
}
return entries;
return lineItems;
}
/**
* Returns the account codes used in the form.
*
* @param entryType {string} the entry type, either "debit" or "credit"
* @param debitCredit {string} either "debit" or "credit"
* @return {string[]} the account codes used in the form
*/
getAccountCodesUsed(entryType) {
return this.getEntries(entryType).map((entry) => entry.getAccountCode())
getAccountCodesUsed(debitCredit) {
return this.getLineItems(debitCredit).map((lineItem) => lineItem.getAccountCode())
.filter((code) => code !== null);
}
@ -231,16 +231,16 @@ class TransactionForm {
*
*/
updateMinDate() {
let lastOriginalEntryDate = null;
for (const entry of this.getEntries()) {
const date = entry.getOriginalEntryDate();
let lastOriginalLineItemDate = null;
for (const lineItem of this.getLineItems()) {
const date = lineItem.getOriginalLineItemDate();
if (date !== null) {
if (lastOriginalEntryDate === null || lastOriginalEntryDate < date) {
lastOriginalEntryDate = date;
if (lastOriginalLineItemDate === null || lastOriginalLineItemDate < date) {
lastOriginalLineItemDate = date;
}
}
}
this.#date.min = lastOriginalEntryDate === null? "": lastOriginalEntryDate;
this.#date.min = lastOriginalLineItemDate === null? "": lastOriginalLineItemDate;
this.#validateDate();
}
@ -272,7 +272,7 @@ class TransactionForm {
}
if (this.#date.value < this.#date.min) {
this.#date.classList.add("is-invalid");
this.#dateError.innerText = A_("The date cannot be earlier than the original entries.");
this.#dateError.innerText = A_("The date cannot be earlier than the original line items.");
return false;
}
this.#date.classList.remove("is-invalid");
@ -325,17 +325,17 @@ class TransactionForm {
}
/**
* The transaction form
* @type {TransactionForm}
* The journal entry form
* @type {JournalEntryForm}
*/
static #form;
/**
* Initializes the transaction form.
* Initializes the journal entry form.
*
*/
static initialize() {
this.#form = new TransactionForm()
this.#form = new JournalEntryForm()
}
}
@ -352,8 +352,8 @@ class CurrencySubForm {
element;
/**
* The transaction form
* @type {TransactionForm}
* The journal entry form
* @type {JournalEntryForm}
*/
form;
@ -406,21 +406,21 @@ class CurrencySubForm {
deleteButton;
/**
* The debit side
* @type {DebitCreditSideSubForm|null}
* The debit sub-form
* @type {DebitCreditSubForm|null}
*/
#debit;
/**
* The credit side
* @type {DebitCreditSideSubForm|null}
* The credit sub-form
* @type {DebitCreditSubForm|null}
*/
#credit;
/**
* Constructs a currency sub-form
*
* @param form {TransactionForm} the transaction form
* @param form {JournalEntryForm} the journal entry form
* @param element {HTMLDivElement} the currency sub-form element
*/
constructor(form, element) {
@ -435,9 +435,9 @@ class CurrencySubForm {
this.#codeSelect = document.getElementById(this.#prefix + "-code-select");
this.deleteButton = document.getElementById(this.#prefix + "-delete");
const debitElement = document.getElementById(this.#prefix + "-debit");
this.#debit = debitElement === null? null: new DebitCreditSideSubForm(this, debitElement, "debit");
this.#debit = debitElement === null? null: new DebitCreditSubForm(this, debitElement, "debit");
const creditElement = document.getElementById(this.#prefix + "-credit");
this.#credit = creditElement == null? null: new DebitCreditSideSubForm(this, creditElement, "credit");
this.#credit = creditElement == null? null: new DebitCreditSubForm(this, creditElement, "credit");
this.#codeSelect.onchange = () => this.#code.value = this.#codeSelect.value;
this.deleteButton.onclick = () => {
this.element.parentElement.removeChild(this.element);
@ -455,21 +455,21 @@ class CurrencySubForm {
}
/**
* Returns all the journal entries in the form.
* Returns all the line items in the form.
*
* @param entryType {string|null} the entry type, either "debit" or "credit", or null for both
* @return {JournalEntrySubForm[]} all the journal entry sub-forms
* @param debitCredit {string|null} either "debit" or "credit", or null for both
* @return {LineItemSubForm[]} all the line item sub-forms
*/
getEntries(entryType = null) {
const entries = []
for (const side of [this.#debit, this.#credit]) {
if (side !== null ) {
if (entryType === null || side.entryType === entryType) {
entries.push(...side.entries);
getLineItems(debitCredit = null) {
const lineItems = []
for (const debitCreditSubForm of [this.#debit, this.#credit]) {
if (debitCreditSubForm !== null ) {
if (debitCredit === null || debitCreditSubForm.debitCredit === debitCredit) {
lineItems.push(...debitCreditSubForm.lineItems);
}
}
}
return entries;
return lineItems;
}
/**
@ -478,8 +478,8 @@ class CurrencySubForm {
*/
updateCodeSelectorStatus() {
let isEnabled = true;
for (const entry of this.getEntries()) {
if (entry.getOriginalEntryId() !== null) {
for (const lineItem of this.getLineItems()) {
if (lineItem.getOriginalLineItemId() !== null) {
isEnabled = false;
break;
}
@ -524,10 +524,10 @@ class CurrencySubForm {
}
/**
* The debit or credit side sub-form
* The debit or credit sub-form
*
*/
class DebitCreditSideSubForm {
class DebitCreditSubForm {
/**
* The currency sub-form
@ -548,10 +548,10 @@ class DebitCreditSideSubForm {
#currencyIndex;
/**
* The entry type, either "debit" or "credit"
* Either "debit" or "credit"
* @type {string}
*/
entryType;
debitCredit;
/**
* The prefix of the HTML ID and class
@ -566,16 +566,16 @@ class DebitCreditSideSubForm {
#error;
/**
* The journal entry list
* The line item list
* @type {HTMLUListElement}
*/
#entryList;
#lineItemList;
/**
* The journal entry sub-forms
* @type {JournalEntrySubForm[]}
* The line item sub-forms
* @type {LineItemSubForm[]}
*/
entries;
lineItems;
/**
* The total
@ -584,82 +584,82 @@ class DebitCreditSideSubForm {
#total;
/**
* The button to add a new entry
* The button to add a new line item
* @type {HTMLButtonElement}
*/
#addEntryButton;
#addLineItemButton;
/**
* Constructs a debit or credit side sub-form
* Constructs a debit or credit sub-form
*
* @param currency {CurrencySubForm} the currency sub-form
* @param element {HTMLDivElement} the element
* @param entryType {string} the entry type, either "debit" or "credit"
* @param debitCredit {string} either "debit" or "credit"
*/
constructor(currency, element, entryType) {
constructor(currency, element, debitCredit) {
this.currency = currency;
this.#element = element;
this.#currencyIndex = currency.index;
this.entryType = entryType;
this.#prefix = "accounting-currency-" + String(this.#currencyIndex) + "-" + entryType;
this.debitCredit = debitCredit;
this.#prefix = "accounting-currency-" + String(this.#currencyIndex) + "-" + debitCredit;
this.#error = document.getElementById(this.#prefix + "-error");
this.#entryList = document.getElementById(this.#prefix + "-list");
this.#lineItemList = document.getElementById(this.#prefix + "-list");
// noinspection JSValidateTypes
this.entries = Array.from(document.getElementsByClassName(this.#prefix)).map((element) => new JournalEntrySubForm(this, element));
this.lineItems = Array.from(document.getElementsByClassName(this.#prefix)).map((element) => new LineItemSubForm(this, element));
this.#total = document.getElementById(this.#prefix + "-total");
this.#addEntryButton = document.getElementById(this.#prefix + "-add-entry");
this.#addEntryButton.onclick = () => this.currency.form.entryEditor.onAddNew(this);
this.#resetDeleteJournalEntryButtons();
this.#addLineItemButton = document.getElementById(this.#prefix + "-add-line-item");
this.#addLineItemButton.onclick = () => this.currency.form.lineItemEditor.onAddNew(this);
this.#resetDeleteLineItemButtons();
this.#initializeDragAndDropReordering();
}
/**
* Adds a new journal entry sub-form
* Adds a new line item sub-form
*
* @returns {JournalEntrySubForm} the newly-added journal entry sub-form
* @returns {LineItemSubForm} the newly-added line item sub-form
*/
addJournalEntry() {
const newIndex = 1 + (this.entries.length === 0? 0: Math.max(...this.entries.map((entry) => entry.entryIndex)));
const html = this.currency.form.entryTemplate
addLineItem() {
const newIndex = 1 + (this.lineItems.length === 0? 0: Math.max(...this.lineItems.map((lineItem) => lineItem.lineItemIndex)));
const html = this.currency.form.lineItemTemplate
.replaceAll("CURRENCY_INDEX", escapeHtml(String(this.#currencyIndex)))
.replaceAll("ENTRY_TYPE", escapeHtml(this.entryType))
.replaceAll("ENTRY_INDEX", escapeHtml(String(newIndex)));
this.#entryList.insertAdjacentHTML("beforeend", html);
const entry = new JournalEntrySubForm(this, document.getElementById(this.#prefix + "-" + String(newIndex)));
this.entries.push(entry);
this.#resetDeleteJournalEntryButtons();
.replaceAll("DEBIT_CREDIT", escapeHtml(this.debitCredit))
.replaceAll("LINE_ITEM_INDEX", escapeHtml(String(newIndex)));
this.#lineItemList.insertAdjacentHTML("beforeend", html);
const lineItem = new LineItemSubForm(this, document.getElementById(this.#prefix + "-" + String(newIndex)));
this.lineItems.push(lineItem);
this.#resetDeleteLineItemButtons();
this.#initializeDragAndDropReordering();
this.validate();
return entry;
return lineItem;
}
/**
* Deletes a journal entry sub-form
* Deletes a line item sub-form
*
* @param entry {JournalEntrySubForm}
* @param lineItem {LineItemSubForm}
*/
deleteJournalEntry(entry) {
const index = this.entries.indexOf(entry);
this.entries.splice(index, 1);
deleteLineItem(lineItem) {
const index = this.lineItems.indexOf(lineItem);
this.lineItems.splice(index, 1);
this.updateTotal();
this.currency.updateCodeSelectorStatus();
this.currency.form.updateMinDate();
this.#resetDeleteJournalEntryButtons();
this.#resetDeleteLineItemButtons();
}
/**
* Resets the buttons to delete the journal entry sub-forms
* Resets the buttons to delete the line item sub-forms
*
*/
#resetDeleteJournalEntryButtons() {
if (this.entries.length === 1) {
this.entries[0].deleteButton.classList.add("d-none");
#resetDeleteLineItemButtons() {
if (this.lineItems.length === 1) {
this.lineItems[0].deleteButton.classList.add("d-none");
} else {
for (const entry of this.entries) {
if (entry.isMatched) {
entry.deleteButton.classList.add("d-none");
for (const lineItem of this.lineItems) {
if (lineItem.isMatched) {
lineItem.deleteButton.classList.add("d-none");
} else {
entry.deleteButton.classList.remove("d-none");
lineItem.deleteButton.classList.remove("d-none");
}
}
}
@ -672,8 +672,8 @@ class DebitCreditSideSubForm {
*/
getTotal() {
let total = new Decimal("0");
for (const entry of this.entries) {
const amount = entry.getAmount();
for (const lineItem of this.lineItems) {
const amount = lineItem.getAmount();
if (amount !== null) {
total = total.plus(amount);
}
@ -695,11 +695,11 @@ class DebitCreditSideSubForm {
*
*/
#initializeDragAndDropReordering() {
initializeDragAndDropReordering(this.#entryList, () => {
const entryId = Array.from(this.#entryList.children).map((entry) => entry.id);
this.entries.sort((a, b) => entryId.indexOf(a.element.id) - entryId.indexOf(b.element.id));
for (let i = 0; i < this.entries.length; i++) {
this.entries[i].no.value = String(i + 1);
initializeDragAndDropReordering(this.#lineItemList, () => {
const lineItemId = Array.from(this.#lineItemList.children).map((lineItem) => lineItem.id);
this.lineItems.sort((a, b) => lineItemId.indexOf(a.element.id) - lineItemId.indexOf(b.element.id));
for (let i = 0; i < this.lineItems.length; i++) {
this.lineItems[i].no.value = String(i + 1);
}
});
}
@ -712,8 +712,8 @@ class DebitCreditSideSubForm {
validate() {
let isValid = true;
isValid = this.#validateReal() && isValid;
for (const entry of this.entries) {
isValid = entry.validate() && isValid;
for (const lineItem of this.lineItems) {
isValid = lineItem.validate() && isValid;
}
return isValid;
}
@ -724,9 +724,9 @@ class DebitCreditSideSubForm {
* @returns {boolean} true if valid, or false otherwise
*/
#validateReal() {
if (this.entries.length === 0) {
if (this.lineItems.length === 0) {
this.#element.classList.add("is-invalid");
this.#error.innerText = A_("Please add some journal entries.");
this.#error.innerText = A_("Please add some line items.");
return false;
}
this.#element.classList.remove("is-invalid");
@ -736,16 +736,16 @@ class DebitCreditSideSubForm {
}
/**
* The journal entry sub-form.
* The line item sub-form.
*
*/
class JournalEntrySubForm {
class LineItemSubForm {
/**
* The debit or credit entry side sub-form
* @type {DebitCreditSideSubForm}
* The debit or credit sub-form
* @type {DebitCreditSubForm}
*/
side;
debitCreditSubForm;
/**
* The element
@ -754,19 +754,19 @@ class JournalEntrySubForm {
element;
/**
* The entry type, either "debit" or "credit"
* Either "debit" or "credit"
* @type {string}
*/
entryType;
debitCredit;
/**
* The entry index
* The line item index
* @type {number}
*/
entryIndex;
lineItemIndex;
/**
* Whether this is an original entry with offsets
* Whether this is an original line item with offsets
* @type {boolean}
*/
isMatched;
@ -808,31 +808,31 @@ class JournalEntrySubForm {
#accountText;
/**
* The summary
* The description
* @type {HTMLInputElement}
*/
#summary;
#description;
/**
* The text display of the summary
* The text display of the description
* @type {HTMLDivElement}
*/
#summaryText;
#descriptionText;
/**
* The ID of the original entry
* The ID of the original line item
* @type {HTMLInputElement}
*/
#originalEntryId;
#originalLineItemId;
/**
* The text of the original entry
* The text of the original line item
* @type {HTMLDivElement}
*/
#originalEntryText;
#originalLineItemText;
/**
* The offset entries
* The offset items
* @type {HTMLInputElement}
*/
#offsets;
@ -850,87 +850,87 @@ class JournalEntrySubForm {
#amountText;
/**
* The button to delete journal entry
* The button to delete line item
* @type {HTMLButtonElement}
*/
deleteButton;
/**
* Constructs the journal entry sub-form.
* Constructs the line item sub-form.
*
* @param side {DebitCreditSideSubForm} the debit or credit entry side sub-form
* @param debitCredit {DebitCreditSubForm} the debit or credit sub-form
* @param element {HTMLLIElement} the element
*/
constructor(side, element) {
this.side = side;
constructor(debitCredit, element) {
this.debitCreditSubForm = debitCredit;
this.element = element;
this.entryType = element.dataset.entryType;
this.entryIndex = parseInt(element.dataset.entryIndex);
this.isMatched = element.classList.contains("accounting-matched-entry");
this.#prefix = "accounting-currency-" + element.dataset.currencyIndex + "-" + this.entryType + "-" + this.entryIndex;
this.debitCredit = element.dataset.debitCredit;
this.lineItemIndex = parseInt(element.dataset.lineItemIndex);
this.isMatched = element.classList.contains("accounting-matched-line-item");
this.#prefix = "accounting-currency-" + element.dataset.currencyIndex + "-" + this.debitCredit + "-" + this.lineItemIndex;
this.#control = document.getElementById(this.#prefix + "-control");
this.#error = document.getElementById(this.#prefix + "-error");
this.no = document.getElementById(this.#prefix + "-no");
this.#accountCode = document.getElementById(this.#prefix + "-account-code");
this.#accountText = document.getElementById(this.#prefix + "-account-text");
this.#summary = document.getElementById(this.#prefix + "-summary");
this.#summaryText = document.getElementById(this.#prefix + "-summary-text");
this.#originalEntryId = document.getElementById(this.#prefix + "-original-entry-id");
this.#originalEntryText = document.getElementById(this.#prefix + "-original-entry-text");
this.#description = document.getElementById(this.#prefix + "-description");
this.#descriptionText = document.getElementById(this.#prefix + "-description-text");
this.#originalLineItemId = document.getElementById(this.#prefix + "-original-line-item-id");
this.#originalLineItemText = document.getElementById(this.#prefix + "-original-line-item-text");
this.#offsets = document.getElementById(this.#prefix + "-offsets");
this.#amount = document.getElementById(this.#prefix + "-amount");
this.#amountText = document.getElementById(this.#prefix + "-amount-text");
this.deleteButton = document.getElementById(this.#prefix + "-delete");
this.#control.onclick = () => this.side.currency.form.entryEditor.onEdit(this);
this.#control.onclick = () => this.debitCreditSubForm.currency.form.lineItemEditor.onEdit(this);
this.deleteButton.onclick = () => {
this.element.parentElement.removeChild(this.element);
this.side.deleteJournalEntry(this);
this.debitCreditSubForm.deleteLineItem(this);
};
}
/**
* Returns whether the entry is an original entry.
* Returns whether the line item needs offset.
*
* @return {boolean} true if the entry is an original entry, or false otherwise
* @return {boolean} true if the line item needs offset, or false otherwise
*/
isNeedOffset() {
return "isNeedOffset" in this.element.dataset;
}
/**
* Returns the ID of the original entry.
* Returns the ID of the original line item.
*
* @return {string|null} the ID of the original entry
* @return {string|null} the ID of the original line item
*/
getOriginalEntryId() {
return this.#originalEntryId.value === ""? null: this.#originalEntryId.value;
getOriginalLineItemId() {
return this.#originalLineItemId.value === ""? null: this.#originalLineItemId.value;
}
/**
* Returns the date of the original entry.
* Returns the date of the original line item.
*
* @return {string|null} the date of the original entry
* @return {string|null} the date of the original line item
*/
getOriginalEntryDate() {
return this.#originalEntryId.dataset.date === ""? null: this.#originalEntryId.dataset.date;
getOriginalLineItemDate() {
return this.#originalLineItemId.dataset.date === ""? null: this.#originalLineItemId.dataset.date;
}
/**
* Returns the text of the original entry.
* Returns the text of the original line item.
*
* @return {string|null} the text of the original entry
* @return {string|null} the text of the original line item
*/
getOriginalEntryText() {
return this.#originalEntryId.dataset.text === ""? null: this.#originalEntryId.dataset.text;
getOriginalLineItemText() {
return this.#originalLineItemId.dataset.text === ""? null: this.#originalLineItemId.dataset.text;
}
/**
* Returns the summary.
* Returns the description.
*
* @return {string|null} the summary
* @return {string|null} the description
*/
getSummary() {
return this.#summary.value === ""? null: this.#summary.value;
getDescription() {
return this.#description.value === ""? null: this.#description.value;
}
/**
@ -991,9 +991,9 @@ class JournalEntrySubForm {
}
/**
* Stores the data into the journal entry sub-form.
* Stores the data into the line item sub-form.
*
* @param editor {JournalEntryEditor} the journal entry editor
* @param editor {JournalEntryLineItemEditor} the line item editor
*/
save(editor) {
if (editor.isNeedOffset) {
@ -1001,27 +1001,27 @@ class JournalEntrySubForm {
} else {
this.#offsets.classList.add("d-none");
}
this.#originalEntryId.value = editor.originalEntryId === null? "": editor.originalEntryId;
this.#originalEntryId.dataset.date = editor.originalEntryDate === null? "": editor.originalEntryDate;
this.#originalEntryId.dataset.text = editor.originalEntryText === null? "": editor.originalEntryText;
if (editor.originalEntryText === null) {
this.#originalEntryText.classList.add("d-none");
this.#originalEntryText.innerText = "";
this.#originalLineItemId.value = editor.originalLineItemId === null? "": editor.originalLineItemId;
this.#originalLineItemId.dataset.date = editor.originalLineItemDate === null? "": editor.originalLineItemDate;
this.#originalLineItemId.dataset.text = editor.originalLineItemText === null? "": editor.originalLineItemText;
if (editor.originalLineItemText === null) {
this.#originalLineItemText.classList.add("d-none");
this.#originalLineItemText.innerText = "";
} else {
this.#originalEntryText.classList.remove("d-none");
this.#originalEntryText.innerText = A_("Offset %(entry)s", {entry: editor.originalEntryText});
this.#originalLineItemText.classList.remove("d-none");
this.#originalLineItemText.innerText = A_("Offset %(item)s", {item: editor.originalLineItemText});
}
this.#accountCode.value = editor.accountCode === null? "": editor.accountCode;
this.#accountCode.dataset.text = editor.accountText === null? "": editor.accountText;
this.#accountText.innerText = editor.accountText === null? "": editor.accountText;
this.#summary.value = editor.summary === null? "": editor.summary;
this.#summaryText.innerText = editor.summary === null? "": editor.summary;
this.#description.value = editor.description === null? "": editor.description;
this.#descriptionText.innerText = editor.description === null? "": editor.description;
this.#amount.value = editor.amount;
this.#amountText.innerText = formatDecimal(new Decimal(editor.amount));
this.validate();
this.side.updateTotal();
this.side.currency.updateCodeSelectorStatus();
this.side.currency.form.updateMinDate();
this.debitCreditSubForm.updateTotal();
this.debitCreditSubForm.currency.updateCodeSelectorStatus();
this.debitCreditSubForm.currency.form.updateMinDate();
}
}

View File

@ -0,0 +1,596 @@
/* The Mia! Accounting Flask Project
* journal-entry-line-item-editor.js: The JavaScript for the journal entry line item editor
*/
/* 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/25
*/
"use strict";
/**
* The journal entry line item editor.
*
*/
class JournalEntryLineItemEditor {
/**
* The journal entry form
* @type {JournalEntryForm}
*/
form;
/**
* The journal entry line item editor
* @type {HTMLFormElement}
*/
#element;
/**
* The bootstrap modal
* @type {HTMLDivElement}
*/
#modal;
/**
* Either "debit" or "credit"
* @type {string}
*/
debitCredit;
/**
* The prefix of the HTML ID and class
* @type {string}
*/
#prefix = "accounting-line-item-editor"
/**
* The container of the original line item
* @type {HTMLDivElement}
*/
#originalLineItemContainer;
/**
* The control of the original line item
* @type {HTMLDivElement}
*/
#originalLineItemControl;
/**
* The original line item
* @type {HTMLDivElement}
*/
#originalLineItemText;
/**
* The error message of the original line item
* @type {HTMLDivElement}
*/
#originalLineItemError;
/**
* The delete button of the original line item
* @type {HTMLButtonElement}
*/
#originalLineItemDelete;
/**
* The control of the description
* @type {HTMLDivElement}
*/
#descriptionControl;
/**
* The description
* @type {HTMLDivElement}
*/
#descriptionText;
/**
* The error message of the description
* @type {HTMLDivElement}
*/
#descriptionError;
/**
* The control of the account
* @type {HTMLDivElement}
*/
#accountControl;
/**
* The account
* @type {HTMLDivElement}
*/
#accountText;
/**
* The error message of the account
* @type {HTMLDivElement}
*/
#accountError;
/**
* The amount
* @type {HTMLInputElement}
*/
#amountInput;
/**
* The error message of the amount
* @type {HTMLDivElement}
*/
#amountError;
/**
* The journal entry line item to edit
* @type {LineItemSubForm|null}
*/
lineItem;
/**
* The debit or credit sub-form
* @type {DebitCreditSubForm}
*/
#debitCreditSubForm;
/**
* Whether the journal entry line item needs offset
* @type {boolean}
*/
isNeedOffset = false;
/**
* The ID of the original line item
* @type {string|null}
*/
originalLineItemId = null;
/**
* The date of the original line item
* @type {string|null}
*/
originalLineItemDate = null;
/**
* The text of the original line item
* @type {string|null}
*/
originalLineItemText = null;
/**
* The account code
* @type {string|null}
*/
accountCode = null;
/**
* The account text
* @type {string|null}
*/
accountText = null;
/**
* The description
* @type {string|null}
*/
description = null;
/**
* The amount
* @type {string}
*/
amount = "";
/**
* The description editors
* @type {{debit: DescriptionEditor, credit: DescriptionEditor}}
*/
#descriptionEditors;
/**
* The account selectors
* @type {{debit: AccountSelector, credit: AccountSelector}}
*/
#accountSelectors;
/**
* The original line item selector
* @type {OriginalLineItemSelector}
*/
originalLineItemSelector;
/**
* Constructs a new journal entry line item editor.
*
* @param form {JournalEntryForm} the journal entry form
*/
constructor(form) {
this.form = form;
this.#element = document.getElementById(this.#prefix);
this.#modal = document.getElementById(this.#prefix + "-modal");
this.#originalLineItemContainer = document.getElementById(this.#prefix + "-original-line-item-container");
this.#originalLineItemControl = document.getElementById(this.#prefix + "-original-line-item-control");
this.#originalLineItemText = document.getElementById(this.#prefix + "-original-line-item");
this.#originalLineItemError = document.getElementById(this.#prefix + "-original-line-item-error");
this.#originalLineItemDelete = document.getElementById(this.#prefix + "-original-line-item-delete");
this.#descriptionControl = document.getElementById(this.#prefix + "-description-control");
this.#descriptionText = document.getElementById(this.#prefix + "-description");
this.#descriptionError = document.getElementById(this.#prefix + "-description-error");
this.#accountControl = document.getElementById(this.#prefix + "-account-control");
this.#accountText = document.getElementById(this.#prefix + "-account");
this.#accountError = document.getElementById(this.#prefix + "-account-error")
this.#amountInput = document.getElementById(this.#prefix + "-amount");
this.#amountError = document.getElementById(this.#prefix + "-amount-error");
this.#descriptionEditors = DescriptionEditor.getInstances(this);
this.#accountSelectors = AccountSelector.getInstances(this);
this.originalLineItemSelector = new OriginalLineItemSelector(this);
this.#originalLineItemControl.onclick = () => this.originalLineItemSelector.onOpen()
this.#originalLineItemDelete.onclick = () => this.clearOriginalLineItem();
this.#descriptionControl.onclick = () => this.#descriptionEditors[this.debitCredit].onOpen();
this.#accountControl.onclick = () => this.#accountSelectors[this.debitCredit].onOpen();
this.#amountInput.onchange = () => this.#validateAmount();
this.#element.onsubmit = () => {
if (this.#validate()) {
if (this.lineItem === null) {
this.lineItem = this.#debitCreditSubForm.addLineItem();
}
this.amount = this.#amountInput.value;
this.lineItem.save(this);
bootstrap.Modal.getInstance(this.#modal).hide();
}
return false;
};
}
/**
* Saves the original line item from the original line item selector.
*
* @param originalLineItem {OriginalLineItem} the original line item
*/
saveOriginalLineItem(originalLineItem) {
this.isNeedOffset = false;
this.#originalLineItemContainer.classList.remove("d-none");
this.#originalLineItemControl.classList.add("accounting-not-empty");
this.originalLineItemId = originalLineItem.id;
this.originalLineItemDate = originalLineItem.date;
this.originalLineItemText = originalLineItem.text;
this.#originalLineItemText.innerText = originalLineItem.text;
this.#setEnableDescriptionAccount(false);
if (originalLineItem.description === "") {
this.#descriptionControl.classList.remove("accounting-not-empty");
} else {
this.#descriptionControl.classList.add("accounting-not-empty");
}
this.description = originalLineItem.description === ""? null: originalLineItem.description;
this.#descriptionText.innerText = originalLineItem.description;
this.#accountControl.classList.add("accounting-not-empty");
this.accountCode = originalLineItem.accountCode;
this.accountText = originalLineItem.accountText;
this.#accountText.innerText = originalLineItem.accountText;
this.#amountInput.value = String(originalLineItem.netBalance);
this.#amountInput.max = String(originalLineItem.netBalance);
this.#amountInput.min = "0";
this.#validate();
}
/**
* Clears the original line item.
*
*/
clearOriginalLineItem() {
this.isNeedOffset = false;
this.#originalLineItemContainer.classList.add("d-none");
this.#originalLineItemControl.classList.remove("accounting-not-empty");
this.originalLineItemId = null;
this.originalLineItemDate = null;
this.originalLineItemText = null;
this.#originalLineItemText.innerText = "";
this.#setEnableDescriptionAccount(true);
this.#accountControl.classList.remove("accounting-not-empty");
this.accountCode = null;
this.accountText = null;
this.#accountText.innerText = "";
this.#amountInput.max = "";
}
/**
* Returns the currency code.
*
* @return {string} the currency code
*/
getCurrencyCode() {
return this.#debitCreditSubForm.currency.getCurrencyCode();
}
/**
* Saves the description from the description editor.
*
* @param description {string} the description
*/
saveDescription(description) {
if (description === "") {
this.#descriptionControl.classList.remove("accounting-not-empty");
} else {
this.#descriptionControl.classList.add("accounting-not-empty");
}
this.description = description === ""? null: description;
this.#descriptionText.innerText = description;
this.#validateDescription();
bootstrap.Modal.getOrCreateInstance(this.#modal).show();
}
/**
* Saves the description with the suggested account from the description editor.
*
* @param description {string} the description
* @param accountCode {string} the account code
* @param accountText {string} the account text
* @param isAccountNeedOffset {boolean} true if the line items in the account need offset, or false otherwise
*/
saveDescriptionWithAccount(description, accountCode, accountText, isAccountNeedOffset) {
this.isNeedOffset = isAccountNeedOffset;
this.#accountControl.classList.add("accounting-not-empty");
this.accountCode = accountCode;
this.accountText = accountText;
this.#accountText.innerText = accountText;
this.#validateAccount();
this.saveDescription(description)
}
/**
* Clears the account.
*
*/
clearAccount() {
this.isNeedOffset = false;
this.#accountControl.classList.remove("accounting-not-empty");
this.accountCode = null;
this.accountText = null;
this.#accountText.innerText = "";
this.#validateAccount();
}
/**
* Sets the account.
*
* @param code {string} the account code
* @param text {string} the account text
* @param isNeedOffset {boolean} true if the line items in the account need offset or false otherwise
*/
saveAccount(code, text, isNeedOffset) {
this.isNeedOffset = isNeedOffset;
this.#accountControl.classList.add("accounting-not-empty");
this.accountCode = code;
this.accountText = text;
this.#accountText.innerText = text;
this.#validateAccount();
}
/**
* Validates the form.
*
* @returns {boolean} true if valid, or false otherwise
*/
#validate() {
let isValid = true;
isValid = this.#validateOriginalLineItem() && isValid;
isValid = this.#validateDescription() && isValid;
isValid = this.#validateAccount() && isValid;
isValid = this.#validateAmount() && isValid
return isValid;
}
/**
* Validates the original line item.
*
* @return {boolean} true if valid, or false otherwise
* @private
*/
#validateOriginalLineItem() {
this.#originalLineItemControl.classList.remove("is-invalid");
this.#originalLineItemError.innerText = "";
return true;
}
/**
* Validates the description.
*
* @return {boolean} true if valid, or false otherwise
* @private
*/
#validateDescription() {
this.#descriptionText.classList.remove("is-invalid");
this.#descriptionError.innerText = "";
return true;
}
/**
* Validates the account.
*
* @return {boolean} true if valid, or false otherwise
*/
#validateAccount() {
if (this.accountCode === null) {
this.#accountControl.classList.add("is-invalid");
this.#accountError.innerText = A_("Please select the account.");
return false;
}
this.#accountControl.classList.remove("is-invalid");
this.#accountError.innerText = "";
return true;
}
/**
* Validates the amount.
*
* @return {boolean} true if valid, or false otherwise
* @private
*/
#validateAmount() {
this.#amountInput.value = this.#amountInput.value.trim();
this.#amountInput.classList.remove("is-invalid");
if (this.#amountInput.value === "") {
this.#amountInput.classList.add("is-invalid");
this.#amountError.innerText = A_("Please fill in the amount.");
return false;
}
const amount =new Decimal(this.#amountInput.value);
if (amount.lessThanOrEqualTo(0)) {
this.#amountInput.classList.add("is-invalid");
this.#amountError.innerText = A_("Please fill in a positive amount.");
return false;
}
if (this.#amountInput.max !== "") {
if (amount.greaterThan(new Decimal(this.#amountInput.max))) {
this.#amountInput.classList.add("is-invalid");
this.#amountError.innerText = A_("The amount must not exceed the net balance %(balance)s of the original line item.", {balance: new Decimal(this.#amountInput.max)});
return false;
}
}
if (this.#amountInput.min !== "") {
const min = new Decimal(this.#amountInput.min);
if (amount.lessThan(min)) {
this.#amountInput.classList.add("is-invalid");
this.#amountError.innerText = A_("The amount must not be less than the offset total %(total)s.", {total: formatDecimal(min)});
return false;
}
}
this.#amountInput.classList.remove("is-invalid");
this.#amountError.innerText = "";
return true;
}
/**
* The callback when adding a new journal entry line item.
*
* @param debitCredit {DebitCreditSubForm} the debit or credit sub-form
*/
onAddNew(debitCredit) {
this.lineItem = null;
this.#debitCreditSubForm = debitCredit;
this.debitCredit = this.#debitCreditSubForm.debitCredit;
this.isNeedOffset = false;
this.#originalLineItemContainer.classList.add("d-none");
this.#originalLineItemControl.classList.remove("accounting-not-empty");
this.#originalLineItemControl.classList.remove("is-invalid");
this.originalLineItemId = null;
this.originalLineItemDate = null;
this.originalLineItemText = null;
this.#originalLineItemText.innerText = "";
this.#setEnableDescriptionAccount(true);
this.#descriptionControl.classList.remove("accounting-not-empty");
this.#descriptionControl.classList.remove("is-invalid");
this.description = null;
this.#descriptionText.innerText = ""
this.#descriptionError.innerText = ""
this.#accountControl.classList.remove("accounting-not-empty");
this.#accountControl.classList.remove("is-invalid");
this.accountCode = null;
this.accountText = null;
this.#accountText.innerText = "";
this.#accountError.innerText = "";
this.#amountInput.value = "";
this.#amountInput.max = "";
this.#amountInput.min = "0";
this.#amountInput.classList.remove("is-invalid");
this.#amountError.innerText = "";
}
/**
* The callback when editing a journal entry line item.
*
* @param lineItem {LineItemSubForm} the journal entry line item sub-form
*/
onEdit(lineItem) {
this.lineItem = lineItem;
this.#debitCreditSubForm = lineItem.debitCreditSubForm;
this.debitCredit = this.#debitCreditSubForm.debitCredit;
this.isNeedOffset = lineItem.isNeedOffset();
this.originalLineItemId = lineItem.getOriginalLineItemId();
this.originalLineItemDate = lineItem.getOriginalLineItemDate();
this.originalLineItemText = lineItem.getOriginalLineItemText();
this.#originalLineItemText.innerText = this.originalLineItemText;
if (this.originalLineItemId === null) {
this.#originalLineItemContainer.classList.add("d-none");
this.#originalLineItemControl.classList.remove("accounting-not-empty");
} else {
this.#originalLineItemContainer.classList.remove("d-none");
this.#originalLineItemControl.classList.add("accounting-not-empty");
}
this.#setEnableDescriptionAccount(!lineItem.isMatched && this.originalLineItemId === null);
this.description = lineItem.getDescription();
if (this.description === null) {
this.#descriptionControl.classList.remove("accounting-not-empty");
} else {
this.#descriptionControl.classList.add("accounting-not-empty");
}
this.#descriptionText.innerText = this.description === null? "": this.description;
if (lineItem.getAccountCode() === null) {
this.#accountControl.classList.remove("accounting-not-empty");
} else {
this.#accountControl.classList.add("accounting-not-empty");
}
this.accountCode = lineItem.getAccountCode();
this.accountText = lineItem.getAccountText();
this.#accountText.innerText = this.accountText;
this.#amountInput.value = lineItem.getAmount() === null? "": String(lineItem.getAmount());
const maxAmount = this.#getMaxAmount();
this.#amountInput.max = maxAmount === null? "": maxAmount;
this.#amountInput.min = lineItem.getAmountMin() === null? "": String(lineItem.getAmountMin());
this.#validate();
}
/**
* Finds out the max amount.
*
* @return {Decimal|null} the max amount
*/
#getMaxAmount() {
if (this.originalLineItemId === null) {
return null;
}
return this.originalLineItemSelector.getNetBalance(this.lineItem, this.form, this.originalLineItemId);
}
/**
* Sets the enable status of the description and account.
*
* @param isEnabled {boolean} true to enable, or false otherwise
*/
#setEnableDescriptionAccount(isEnabled) {
if (isEnabled) {
this.#descriptionControl.dataset.bsToggle = "modal";
this.#descriptionControl.dataset.bsTarget = "#accounting-description-editor-" + this.#debitCreditSubForm.debitCredit + "-modal";
this.#descriptionControl.classList.remove("accounting-disabled");
this.#descriptionControl.classList.add("accounting-clickable");
this.#accountControl.dataset.bsToggle = "modal";
this.#accountControl.dataset.bsTarget = "#accounting-account-selector-" + this.#debitCreditSubForm.debitCredit + "-modal";
this.#accountControl.classList.remove("accounting-disabled");
this.#accountControl.classList.add("accounting-clickable");
} else {
this.#descriptionControl.dataset.bsToggle = "";
this.#descriptionControl.dataset.bsTarget = "";
this.#descriptionControl.classList.add("accounting-disabled");
this.#descriptionControl.classList.remove("accounting-clickable");
this.#accountControl.dataset.bsToggle = "";
this.#accountControl.dataset.bsTarget = "";
this.#accountControl.classList.add("accounting-disabled");
this.#accountControl.classList.remove("accounting-clickable");
}
}
}

View File

@ -1,5 +1,5 @@
/* The Mia! Accounting Flask Project
* transaction-order.js: The JavaScript for the transaction order
* journal-entry-order.js: The JavaScript for the journal entry order
*/
/* Copyright (c) 2023 imacat.

View File

@ -1,5 +1,5 @@
/* The Mia! Accounting Flask Project
* original-entry-selector.js: The JavaScript for the original entry selector
* original-line-item-selector.js: The JavaScript for the original line item selector
*/
/* Copyright (c) 2023 imacat.
@ -23,22 +23,22 @@
"use strict";
/**
* The original entry selector.
* The original line item selector.
*
*/
class OriginalEntrySelector {
class OriginalLineItemSelector {
/**
* The line item editor
* @type {JournalEntryLineItemEditor}
*/
lineItemEditor;
/**
* The prefix of the HTML ID and class
* @type {string}
*/
#prefix = "accounting-original-entry-selector";
/**
* The modal of the original entry editor
* @type {HTMLDivElement}
*/
#modal;
#prefix = "accounting-original-line-item-selector";
/**
* The query input
@ -60,22 +60,16 @@ class OriginalEntrySelector {
/**
* The options
* @type {OriginalEntry[]}
* @type {OriginalLineItem[]}
*/
#options;
/**
* The options by their ID
* @type {Object.<string, OriginalEntry>}
* @type {Object.<string, OriginalLineItem>}
*/
#optionById;
/**
* The journal entry editor
* @type {JournalEntryEditor}
*/
entryEditor;
/**
* The currency code
* @type {string}
@ -83,20 +77,21 @@ class OriginalEntrySelector {
#currencyCode;
/**
* The entry
* Either "credit" or "debit"
*/
#entryType;
#debitCredit;
/**
* Constructs an original entry selector.
* Constructs an original line item selector.
*
* @param lineItemEditor {JournalEntryLineItemEditor} the line item editor
*/
constructor() {
this.#modal = document.getElementById(this.#prefix + "-modal");
constructor(lineItemEditor) {
this.lineItemEditor = lineItemEditor;
this.#query = document.getElementById(this.#prefix + "-query");
this.#queryNoResult = document.getElementById(this.#prefix + "-option-no-result");
this.#optionList = document.getElementById(this.#prefix + "-option-list");
this.#options = Array.from(document.getElementsByClassName(this.#prefix + "-option")).map((element) => new OriginalEntry(this, element));
this.#options = Array.from(document.getElementsByClassName(this.#prefix + "-option")).map((element) => new OriginalLineItem(this, element));
this.#optionById = {};
for (const option of this.#options) {
this.#optionById[option.id] = option;
@ -107,44 +102,44 @@ class OriginalEntrySelector {
}
/**
* Returns the net balance for an original entry.
* Returns the net balance for an original line item.
*
* @param currentEntry {JournalEntrySubForm} the journal entry sub-form that is currently editing
* @param form {TransactionForm} the transaction form
* @param originalEntryId {string} the ID of the original entry
* @return {Decimal} the net balance of the original entry
* @param currentLineItem {LineItemSubForm} the line item sub-form that is currently editing
* @param form {JournalEntryForm} the journal entry form
* @param originalLineItemId {string} the ID of the original line item
* @return {Decimal} the net balance of the original line item
*/
getNetBalance(currentEntry, form, originalEntryId) {
const otherEntries = form.getEntries().filter((entry) => entry !== currentEntry);
getNetBalance(currentLineItem, form, originalLineItemId) {
const otherLineItems = form.getLineItems().filter((lineItem) => lineItem !== currentLineItem);
let otherOffset = new Decimal(0);
for (const otherEntry of otherEntries) {
if (otherEntry.getOriginalEntryId() === originalEntryId) {
const amount = otherEntry.getAmount();
for (const otherLineItem of otherLineItems) {
if (otherLineItem.getOriginalLineItemId() === originalLineItemId) {
const amount = otherLineItem.getAmount();
if (amount !== null) {
otherOffset = otherOffset.plus(amount);
}
}
}
return this.#optionById[originalEntryId].bareNetBalance.minus(otherOffset);
return this.#optionById[originalLineItemId].bareNetBalance.minus(otherOffset);
}
/**
* Updates the net balances, subtracting the offset amounts on the form but the currently editing journal entry
* Updates the net balances, subtracting the offset amounts on the form but the currently editing line item
*
*/
#updateNetBalances() {
const otherEntries = this.entryEditor.form.getEntries().filter((entry) => entry !== this.entryEditor.entry);
const otherLineItems = this.lineItemEditor.form.getLineItems().filter((lineItem) => lineItem !== this.lineItemEditor.lineItem);
const otherOffsets = {}
for (const otherEntry of otherEntries) {
const otherOriginalEntryId = otherEntry.getOriginalEntryId();
const amount = otherEntry.getAmount();
if (otherOriginalEntryId === null || amount === null) {
for (const otherLineItem of otherLineItems) {
const otherOriginalLineItemId = otherLineItem.getOriginalLineItemId();
const amount = otherLineItem.getAmount();
if (otherOriginalLineItemId === null || amount === null) {
continue;
}
if (!(otherOriginalEntryId in otherOffsets)) {
otherOffsets[otherOriginalEntryId] = new Decimal("0");
if (!(otherOriginalLineItemId in otherOffsets)) {
otherOffsets[otherOriginalLineItemId] = new Decimal("0");
}
otherOffsets[otherOriginalEntryId] = otherOffsets[otherOriginalEntryId].plus(amount);
otherOffsets[otherOriginalLineItemId] = otherOffsets[otherOriginalLineItemId].plus(amount);
}
for (const option of this.#options) {
if (option.id in otherOffsets) {
@ -162,7 +157,7 @@ class OriginalEntrySelector {
#filterOptions() {
let hasAnyMatched = false;
for (const option of this.#options) {
if (option.isMatched(this.#entryType, this.#currencyCode, this.#query.value)) {
if (option.isMatched(this.#debitCredit, this.#currencyCode, this.#query.value)) {
option.setShown(true);
hasAnyMatched = true;
} else {
@ -179,17 +174,14 @@ class OriginalEntrySelector {
}
/**
* The callback when the original entry selector is shown.
* The callback when the original line item selector is shown.
*
* @param entryEditor {JournalEntryEditor} the journal entry editor
* @param originalEntryId {string|null} the ID of the original entry
*/
onOpen(entryEditor, originalEntryId = null) {
this.entryEditor = entryEditor
this.#currencyCode = entryEditor.getCurrencyCode();
this.#entryType = entryEditor.entryType;
onOpen() {
this.#currencyCode = this.lineItemEditor.getCurrencyCode();
this.#debitCredit = this.lineItemEditor.debitCredit;
for (const option of this.#options) {
option.setActive(option.id === originalEntryId);
option.setActive(option.id === this.lineItemEditor.originalLineItemId);
}
this.#query.value = "";
this.#updateNetBalances();
@ -198,14 +190,14 @@ class OriginalEntrySelector {
}
/**
* An original entry.
* An original line item.
*
*/
class OriginalEntry {
class OriginalLineItem {
/**
* The original entry selector
* @type {OriginalEntrySelector}
* The original line item selector
* @type {OriginalLineItemSelector}
*/
#selector;
@ -228,10 +220,10 @@ class OriginalEntry {
date;
/**
* The entry type, either "debit" or "credit"
* Either "debit" or "credit"
* @type {string}
*/
#entryType;
#debitCredit;
/**
* The currency code
@ -252,10 +244,10 @@ class OriginalEntry {
accountText;
/**
* The summary
* The description
* @type {string}
*/
summary;
description;
/**
* The net balance, without the offset amounts on the form
@ -276,7 +268,7 @@ class OriginalEntry {
netBalanceText;
/**
* The text representation of the original entry
* The text representation of the original line item
* @type {string}
*/
text;
@ -288,9 +280,9 @@ class OriginalEntry {
#queryValues;
/**
* Constructs an original entry.
* Constructs an original line item.
*
* @param selector {OriginalEntrySelector} the original entry selector
* @param selector {OriginalLineItemSelector} the original line item selector
* @param element {HTMLLIElement} the element
*/
constructor(selector, element) {
@ -298,17 +290,17 @@ class OriginalEntry {
this.#element = element;
this.id = element.dataset.id;
this.date = element.dataset.date;
this.#entryType = element.dataset.entryType;
this.#debitCredit = element.dataset.debitCredit;
this.#currencyCode = element.dataset.currencyCode;
this.accountCode = element.dataset.accountCode;
this.accountText = element.dataset.accountText;
this.summary = element.dataset.summary;
this.description = element.dataset.description;
this.bareNetBalance = new Decimal(element.dataset.netBalance);
this.netBalance = this.bareNetBalance;
this.netBalanceText = document.getElementById("accounting-original-entry-selector-option-" + this.id + "-net-balance");
this.netBalanceText = document.getElementById("accounting-original-line-item-selector-option-" + this.id + "-net-balance");
this.text = element.dataset.text;
this.#queryValues = JSON.parse(element.dataset.queryValues);
this.#element.onclick = () => this.#selector.entryEditor.saveOriginalEntry(this);
this.#element.onclick = () => this.#selector.lineItemEditor.saveOriginalLineItem(this);
}
/**
@ -343,31 +335,31 @@ class OriginalEntry {
/**
* Returns whether the original matches.
*
* @param entryType {string} the entry type, either "debit" or "credit"
* @param debitCredit {string} either "debit" or "credit"
* @param currencyCode {string} the currency code
* @param query {string|null} the query term
*/
isMatched(entryType, currencyCode, query = null) {
isMatched(debitCredit, currencyCode, query = null) {
return this.netBalance.greaterThan(0)
&& this.date <= this.#selector.entryEditor.form.getDate()
&& this.#isEntryTypeMatches(entryType)
&& this.date <= this.#selector.lineItemEditor.form.getDate()
&& this.#isDebitCreditMatches(debitCredit)
&& this.#currencyCode === currencyCode
&& this.#isQueryMatches(query);
}
/**
* Returns whether the original entry matches the entry type.
* Returns whether the original line item matches the debit or credit.
*
* @param entryType {string} the entry type, either "debit" or credit
* @param debitCredit {string} either "debit" or credit
* @return {boolean} true if the option matches, or false otherwise
*/
#isEntryTypeMatches(entryType) {
return (entryType === "debit" && this.#entryType === "credit")
|| (entryType === "credit" && this.#entryType === "debit");
#isDebitCreditMatches(debitCredit) {
return (debitCredit === "debit" && this.#debitCredit === "credit")
|| (debitCredit === "credit" && this.#debitCredit === "debit");
}
/**
* Returns whether the original entry matches the query.
* Returns whether the original line item matches the query.
*
* @param query {string|null} the query term
* @return {boolean} true if the option matches, or false otherwise

View File

@ -175,7 +175,7 @@ class MonthTab extends TabPlane {
let start = monthChooser.dataset.start;
this.#monthChooser = new tempusDominus.TempusDominus(monthChooser, {
restrictions: {
minDate: start,
minDate: new Date(start),
},
display: {
inline: true,
@ -184,7 +184,7 @@ class MonthTab extends TabPlane {
clock: false,
},
},
defaultDate: monthChooser.dataset.default,
defaultDate: new Date(monthChooser.dataset.default),
});
monthChooser.addEventListener(tempusDominus.Namespace.events.change, (e) => {
const date = e.detail.date;

View File

@ -14,7 +14,7 @@
# 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 template globals for the transaction management.
"""The template globals.
"""
from flask import current_app
@ -35,5 +35,4 @@ def default_currency_code() -> str:
:return: The default currency code.
"""
with current_app.app_context():
return current_app.config.get("DEFAULT_CURRENCY", "USD")
return current_app.config.get("ACCOUNTING_DEFAULT_CURRENCY", "USD")

View File

@ -25,26 +25,33 @@ First written: 2023/1/31
{% block content %}
<div class="btn-group mb-3">
<a class="btn btn-primary" href="{{ url_for("accounting.account.list")|accounting_or_next }}">
<div class="mb-3 accounting-toolbar">
<a class="btn btn-primary d-none d-md-inline" href="{{ url_for("accounting.account.list")|accounting_or_next }}">
<i class="fa-solid fa-circle-chevron-left"></i>
{{ A_("Back") }}
</a>
{% if accounting_can_edit() %}
<a class="btn btn-primary d-none d-md-inline" href="{{ url_for("accounting.account.edit", account=obj)|accounting_inherit_next }}">
<i class="fa-solid fa-gear"></i>
{{ A_("Settings") }}
<i class="fa-solid fa-pen-to-square"></i>
{{ A_("Edit") }}
</a>
{% endif %}
<a class="btn btn-primary" href="{{ url_for("accounting.account.order", base=obj.base)|accounting_append_next }}">
<i class="fa-solid fa-bars-staggered"></i>
{{ A_("Order") }}
<span class="d-none d-md-inline">{{ A_("Order") }}</span>
</a>
{% if accounting_can_edit() %}
<button class="btn btn-danger" type="button" data-bs-toggle="modal" data-bs-target="#accounting-delete-modal">
<i class="fa-solid fa-trash"></i>
{{ A_("Delete") }}
</button>
{% if obj.can_delete %}
<button class="btn btn-danger" type="button" data-bs-toggle="modal" data-bs-target="#accounting-delete-modal">
<i class="fa-solid fa-trash"></i>
<span class="d-none d-md-inline">{{ A_("Delete") }}</span>
</button>
{% else %}
<button class="btn btn-secondary" type="button" disabled="disabled">
<i class="fa-solid fa-trash"></i>
<span class="d-none d-md-inline">{{ A_("Delete") }}</span>
</button>
{% endif %}
{% endif %}
</div>
@ -56,7 +63,7 @@ First written: 2023/1/31
</div>
{% endif %}
{% if accounting_can_edit() %}
{% if accounting_can_edit() and obj.can_delete %}
<form action="{{ url_for("accounting.account.delete", account=obj) }}" method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
{% if request.args.next %}
@ -66,7 +73,7 @@ First written: 2023/1/31
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-5" id="accounting-delete-modal-label">{{ A_("Delete Account Confirmation") }}</h1>
<h1 class="modal-title fs-5" id="accounting-delete-modal-label">{{ A_("Confirm Delete Account") }}</h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{{ A_("Close") }}"></button>
</div>
<div class="modal-body">
@ -83,11 +90,11 @@ First written: 2023/1/31
{% endif %}
<div class="accounting-card col-sm-6">
<div class="accounting-card-title">{{ obj.title }}</div>
<div class="accounting-card-title">{{ obj.title|title }}</div>
<div class="accounting-card-code">{{ obj.code }}</div>
{% if obj.is_need_offset %}
<div>
<span class="badge rounded-pill bg-info">{{ A_("Need offset") }}</span>
<span class="badge rounded-pill bg-info">{{ A_("Needs Offset") }}</span>
</div>
{% endif %}
<div class="small text-secondary fst-italic">

View File

@ -27,7 +27,7 @@ First written: 2023/2/1
{% block content %}
<div class="btn-group mb-3">
<div class="mb-3 d-none d-md-block">
<a class="btn btn-primary" role="button" href="{% block back_url %}{% endblock %}">
<i class="fa-solid fa-circle-chevron-left"></i>
{{ A_("Back") }}
@ -65,7 +65,7 @@ First written: 2023/2/1
<div id="accounting-is-need-offset-control" class="form-check form-switch mb-3 {% if form.base_code.data[0] not in ["1", "2", "3"] %} d-none {% endif %}">
<input id="accounting-is-need-offset" class="form-check-input" type="checkbox" name="is_need_offset" value="1" {% if form.is_need_offset.data %} checked="checked" {% endif %}>
<label class="form-check-label" for="accounting-is-need-offset">
{{ A_("The entries in the account need offset.") }}
{{ A_("The line items in the account need offset.") }}
</label>
</div>

View File

@ -59,7 +59,7 @@ First written: 2023/1/30
<a class="list-group-item list-group-item-action" href="{{ url_for("accounting.account.detail", account=item)|accounting_append_next }}">
{{ item }}
{% if item.is_need_offset %}
<span class="badge rounded-pill bg-info">{{ A_("Need offset") }}</span>
<span class="badge rounded-pill bg-info">{{ A_("Needs Offset") }}</span>
{% endif %}
</a>
{% endfor %}

View File

@ -30,7 +30,7 @@ First written: 2023/2/2
{% block content %}
<div class="btn-group mb-3">
<div class="mb-3 d-none d-md-block">
<a class="btn btn-primary" href="{{ url_for("accounting.account.list")|accounting_or_next }}">
<i class="fa-solid fa-circle-chevron-left"></i>
{{ A_("Back") }}

View File

@ -25,7 +25,7 @@ First written: 2023/2/1
{% block content %}
<div class="btn-group mb-3">
<div class="mb-3 d-none d-md-block">
<a class="btn btn-primary" href="{{ url_for("accounting.account.list")|accounting_or_next }}">
<i class="fa-solid fa-circle-chevron-left"></i>
{{ A_("Back") }}
@ -33,7 +33,7 @@ First written: 2023/2/1
</div>
<div class="accounting-card col-sm-6">
<div class="accounting-card-title">{{ obj.title }}</div>
<div class="accounting-card-title">{{ obj.title|title }}</div>
<div class="accounting-card-code">{{ obj.code }}</div>
{% if obj.accounts %}
<div>

View File

@ -25,22 +25,29 @@ First written: 2023/2/6
{% block content %}
<div class="btn-group mb-3">
<a class="btn btn-primary" href="{{ url_for("accounting.currency.list")|accounting_or_next }}">
<div class="mb-3 accounting-toolbar">
<a class="btn btn-primary d-none d-md-inline" href="{{ url_for("accounting.currency.list")|accounting_or_next }}">
<i class="fa-solid fa-circle-chevron-left"></i>
{{ A_("Back") }}
</a>
{% if accounting_can_edit() %}
<a class="btn btn-primary d-none d-md-inline" href="{{ url_for("accounting.currency.edit", currency=obj)|accounting_inherit_next }}">
<i class="fa-solid fa-gear"></i>
{{ A_("Settings") }}
<i class="fa-solid fa-pen-to-square"></i>
{{ A_("Edit") }}
</a>
{% endif %}
{% if accounting_can_edit() %}
<button class="btn btn-danger" type="button" data-bs-toggle="modal" data-bs-target="#accounting-delete-modal">
<i class="fa-solid fa-trash"></i>
{{ A_("Delete") }}
</button>
{% if obj.can_delete %}
<button class="btn btn-danger" type="button" data-bs-toggle="modal" data-bs-target="#accounting-delete-modal">
<i class="fa-solid fa-trash"></i>
<span class="d-none d-md-inline">{{ A_("Delete") }}</span>
</button>
{% else %}
<button class="btn btn-secondary" type="button" disabled="disabled">
<i class="fa-solid fa-trash"></i>
<span class="d-none d-md-inline">{{ A_("Delete") }}</span>
</button>
{% endif %}
{% endif %}
</div>
@ -52,7 +59,7 @@ First written: 2023/2/6
</div>
{% endif %}
{% if accounting_can_edit() %}
{% if accounting_can_edit() and obj.can_delete %}
<form action="{{ url_for("accounting.currency.delete", currency=obj) }}" method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
{% if request.args.next %}
@ -62,7 +69,7 @@ First written: 2023/2/6
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-5" id="accounting-delete-modal-label">{{ A_("Delete Currency Confirmation") }}</h1>
<h1 class="modal-title fs-5" id="accounting-delete-modal-label">{{ A_("Confirm Delete Currency") }}</h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{{ A_("Close") }}"></button>
</div>
<div class="modal-body">
@ -79,7 +86,7 @@ First written: 2023/2/6
{% endif %}
<div class="accounting-card col-sm-6">
<div class="accounting-card-title">{{ obj.name }}</div>
<div class="accounting-card-title">{{ obj.name|title }}</div>
<div class="accounting-card-code">{{ obj.code }}</div>
<div class="small text-secondary fst-italic">
<div>{{ A_("Created") }} {{ obj.created_at }} {{ obj.created_by }}</div>

View File

@ -27,7 +27,7 @@ First written: 2023/2/6
{% block content %}
<div class="btn-group mb-3">
<div class="mb-3 d-none d-md-block">
<a class="btn btn-primary" role="button" href="{% block back_url %}{% endblock %}">
<i class="fa-solid fa-circle-chevron-left"></i>
{{ A_("Back") }}

View File

@ -28,25 +28,25 @@ First written: 2023/1/26
</span>
<ul class="dropdown-menu">
<li>
<a class="dropdown-item {% if request.endpoint.startswith("accounting.report.") %} active {% endif %}" href="{{ url_for("accounting.report.default") }}">
<a class="dropdown-item {% if request.endpoint and request.endpoint.startswith("accounting.report.") %} active {% endif %}" href="{{ url_for("accounting.report.default") }}">
<i class="fa-solid fa-book"></i>
{{ A_("Reports") }}
</a>
</li>
<li>
<a class="dropdown-item {% if request.endpoint.startswith("accounting.account.") %} active {% endif %}" href="{{ url_for("accounting.account.list") }}">
<a class="dropdown-item {% if request.endpoint and request.endpoint.startswith("accounting.account.") %} active {% endif %}" href="{{ url_for("accounting.account.list") }}">
<i class="fa-solid fa-clipboard"></i>
{{ A_("Accounts") }}
</a>
</li>
<li>
<a class="dropdown-item {% if request.endpoint.startswith("accounting.base-account.") %} active {% endif %}" href="{{ url_for("accounting.base-account.list") }}">
<a class="dropdown-item {% if request.endpoint and request.endpoint.startswith("accounting.base-account.") %} active {% endif %}" href="{{ url_for("accounting.base-account.list") }}">
<i class="fa-solid fa-list"></i>
{{ A_("Base Accounts") }}
</a>
</li>
<li>
<a class="dropdown-item {% if request.endpoint.startswith("accounting.currency.") %} active {% endif %}" href="{{ url_for("accounting.currency.list") }}">
<a class="dropdown-item {% if request.endpoint and request.endpoint.startswith("accounting.currency.") %} active {% endif %}" href="{{ url_for("accounting.currency.list") }}">
<i class="fa-solid fa-money-bill-wave"></i>
{{ A_("Currencies") }}
</a>

View File

@ -1,6 +1,6 @@
{#
The Mia! Accounting Flask Project
create.html: The cash expense transaction creation form
create.html: The cash disbursement journal entry creation form
Copyright (c) 2023 imacat.
@ -19,10 +19,10 @@ create.html: The cash expense transaction creation form
Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/2/25
#}
{% extends "accounting/transaction/expense/include/form.html" %}
{% extends "accounting/journal-entry/disbursement/include/form.html" %}
{% block header %}{% block title %}{{ A_("Add a New Cash Expense Transaction") }}{% endblock %}{% endblock %}
{% block header %}{% block title %}{{ A_("Add a New Cash Disbursement Journal Entry") }}{% endblock %}{% endblock %}
{% block back_url %}{{ request.args.get("next") or url_for("accounting.report.default") }}{% endblock %}
{% block action_url %}{{ url_for("accounting.transaction.store", txn_type=txn_type) }}{% endblock %}
{% block action_url %}{{ url_for("accounting.journal-entry.store", journal_entry_type=journal_entry_type) }}{% endblock %}

View File

@ -1,6 +1,6 @@
{#
The Mia! Accounting Flask Project
detail.html: The account detail
detail.html: The cash disbursement journal entry detail
Copyright (c) 2023 imacat.
@ -19,26 +19,26 @@ detail.html: The account detail
Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/2/26
#}
{% extends "accounting/transaction/include/detail.html" %}
{% extends "accounting/journal-entry/include/detail.html" %}
{% block to_transfer %}
<a class="btn btn-primary" href="{{ url_for("accounting.transaction.edit", txn=obj)|accounting_txn_to_transfer|accounting_inherit_next }}">
<i class="fa-solid fa-bars-staggered"></i>
{{ A_("To Transfer") }}
{% block as_trasfer %}
<a class="btn btn-primary" href="{{ url_for("accounting.journal-entry.edit", journal_entry=obj)|accounting_journal_entry_to_transfer|accounting_inherit_next }}">
<i class="fa-solid fa-table-columns"></i>
<span class="d-none d-md-inline">{{ A_("As Transfer") }}</span>
</a>
{% endblock %}
{% block transaction_currencies %}
{% block journal_entry_currencies %}
{% for currency in obj.currencies %}
<div class="mb-3">
<div class="mb-2 fw-bolder">{{ currency.name }}</div>
<ul class="list-group accounting-list-group-stripped accounting-list-group-hover">
<li class="list-group-item accounting-transaction-entry accounting-transaction-entry-header">{{ A_("Content") }}</li>
{% with entries = currency.credit %}
{% include "accounting/transaction/include/detail-entries.html" %}
<li class="list-group-item accounting-journal-entry-line-item accounting-journal-entry-line-item-header">{{ A_("Content") }}</li>
{% with line_items = currency.debit %}
{% include "accounting/journal-entry/include/detail-line-items.html" %}
{% endwith %}
<li class="list-group-item accounting-transaction-entry accounting-transaction-entry-total">
<li class="list-group-item accounting-journal-entry-line-item accounting-journal-entry-line-item-total">
<div class="d-flex justify-content-between">
<div>{{ A_("Total") }}</div>
<div>{{ currency.debit_total|accounting_format_amount }}</div>

View File

@ -0,0 +1,28 @@
{#
The Mia! Accounting Flask Project
edit.html: The cash disbursement journal entry edit 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/25
#}
{% extends "accounting/journal-entry/disbursement/include/form.html" %}
{% block header %}{% block title %}{{ A_("Editing %(journal_entry)s", journal_entry=journal_entry) }}{% endblock %}{% endblock %}
{% block back_url %}{{ url_for("accounting.journal-entry.detail", journal_entry=journal_entry)|accounting_inherit_next }}{% endblock %}
{% block action_url %}{{ url_for("accounting.journal-entry.update", journal_entry=journal_entry)|accounting_journal_entry_with_type }}{% endblock %}

View File

@ -1,6 +1,6 @@
{#
The Mia! Accounting Flask Project
edit.html: The cash income transaction edit form
form-currency.html: The currency sub-form in the cash disbursement journal entry form
Copyright (c) 2023 imacat.
@ -19,10 +19,15 @@ edit.html: The cash income transaction edit form
Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/2/25
#}
{% extends "accounting/transaction/income/include/form.html" %}
{% extends "accounting/journal-entry/include/form-currency.html" %}
{% block header %}{% block title %}{{ A_("Editing %(txn)s", txn=txn) }}{% endblock %}{% endblock %}
{% block back_url %}{{ url_for("accounting.transaction.detail", txn=txn)|accounting_inherit_next }}{% endblock %}
{% block action_url %}{{ url_for("accounting.transaction.update", txn=txn)|accounting_txn_with_type }}{% endblock %}
{% block line_items %}
{% with currency_index = currency_index,
debit_credit = "debit",
line_item_forms = debit_forms,
header = A_("Content"),
debit_credit_total = debit_total,
debit_credit_errors = debit_errors %}
{% include "accounting/journal-entry/include/form-debit-credit.html" %}
{% endwith %}
{% endblock %}

View File

@ -1,6 +1,6 @@
{#
The Mia! Accounting Flask Project
form.html: The cash expense transaction form
form.html: The cash disbursement journal entry form
Copyright (c) 2023 imacat.
@ -19,7 +19,7 @@ form.html: The cash expense transaction form
Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/2/25
#}
{% extends "accounting/transaction/include/form.html" %}
{% extends "accounting/journal-entry/include/form.html" %}
{% block currency_sub_forms %}
{% if form.currencies %}
@ -32,8 +32,8 @@ First written: 2023/2/25
currency_code_is_locked = currency_form.is_code_locked,
debit_forms = currency_form.debit,
debit_errors = currency_form.debit_errors,
debit_total = currency_form.form.debit_total|accounting_format_amount %}
{% include "accounting/transaction/expense/include/form-currency-item.html" %}
debit_total = currency_form.debit_total|accounting_format_amount %}
{% include "accounting/journal-entry/disbursement/include/form-currency.html" %}
{% endwith %}
{% endfor %}
{% else %}
@ -41,17 +41,17 @@ First written: 2023/2/25
only_one_currency_form = True,
currency_code_data = accounting_default_currency_code(),
debit_total = "-" %}
{% include "accounting/transaction/expense/include/form-currency-item.html" %}
{% include "accounting/journal-entry/disbursement/include/form-currency.html" %}
{% endwith %}
{% endif %}
{% endblock %}
{% block form_modals %}
{% with summary_editor = form.summary_editor.debit %}
{% include "accounting/transaction/include/summary-editor-modal.html" %}
{% with description_editor = form.description_editor.debit %}
{% include "accounting/journal-entry/include/description-editor-modal.html" %}
{% endwith %}
{% with entry_type = "debit",
{% with debit_credit = "debit",
account_options = form.debit_account_options %}
{% include "accounting/transaction/include/account-selector-modal.html" %}
{% include "accounting/journal-entry/include/account-selector-modal.html" %}
{% endwith %}
{% endblock %}

View File

@ -0,0 +1,54 @@
{#
The Mia! Accounting Flask Project
account-selector-modal.html: The modal for the account selector
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/25
#}
<div id="accounting-account-selector-{{ debit_credit }}-modal" class="modal fade accounting-account-selector" data-debit-credit="{{ debit_credit }}" tabindex="-1" aria-labelledby="accounting-account-selector-{{ debit_credit }}-modal-label" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-5" id="accounting-account-selector-{{ debit_credit }}-modal-label">{{ A_("Select Account") }}</h1>
<button type="button" class="btn-close" data-bs-toggle="modal" data-bs-target="#accounting-line-item-editor-modal" aria-label="{{ A_("Close") }}"></button>
</div>
<div class="modal-body">
<div class="input-group mb-2">
<input id="accounting-account-selector-{{ debit_credit }}-query" class="form-control form-control-sm" type="search" placeholder=" " required="required">
<label class="input-group-text" for="accounting-account-selector-{{ debit_credit }}-query">
<i class="fa-solid fa-magnifying-glass"></i>
{{ A_("Search") }}
</label>
</div>
<ul id="accounting-account-selector-{{ debit_credit }}-option-list" class="list-group accounting-selector-list">
{% for account in account_options %}
<li id="accounting-account-selector-{{ debit_credit }}-option-{{ account.code }}" class="list-group-item accounting-clickable accounting-account-selector-{{ debit_credit }}-option {% if account.is_in_use %} accounting-account-in-use {% endif %} {% if account.is_need_offset %} accounting-account-is-need-offset {% endif %}" data-code="{{ account.code }}" data-content="{{ account }}" data-query-values="{{ account.query_values|tojson|forceescape }}" data-bs-toggle="modal" data-bs-target="#accounting-line-item-editor-modal">
{{ account }}
</li>
{% endfor %}
<li id="accounting-account-selector-{{ debit_credit }}-more" class="list-group-item accounting-clickable">{{ A_("More…") }}</li>
</ul>
<p id="accounting-account-selector-{{ debit_credit }}-option-no-result" class="d-none">{{ A_("There is no data.") }}</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-toggle="modal" data-bs-target="#accounting-line-item-editor-modal">{{ A_("Cancel") }}</button>
<button id="accounting-account-selector-{{ debit_credit }}-btn-clear" type="button" class="btn btn-danger" data-bs-toggle="modal" data-bs-target="#accounting-line-item-editor-modal">{{ A_("Clear") }}</button>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,199 @@
{#
The Mia! Accounting Flask Project
description-editor-modal.html: The modal of the description editor
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
#}
<form id="accounting-description-editor-{{ description_editor.debit_credit }}" class="accounting-description-editor" data-debit-credit="{{ description_editor.debit_credit }}">
<div id="accounting-description-editor-{{ description_editor.debit_credit }}-modal" class="modal fade" tabindex="-1" aria-labelledby="accounting-description-editor-{{ description_editor.debit_credit }}-modal-label" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-5" id="accounting-description-editor-{{ description_editor.debit_credit }}-modal-label">
<label for="accounting-description-editor-{{ description_editor.debit_credit }}-description">{{ A_("Description") }}</label>
</h1>
<button class="btn-close" type="button" data-bs-toggle="modal" data-bs-target="#accounting-line-item-editor-modal" aria-label="{{ A_("Close") }}"></button>
</div>
<div class="modal-body">
<div class="d-flex justify-content-between mb-3">
<input id="accounting-description-editor-{{ description_editor.debit_credit }}-description" class="form-control" type="text" aria-labelledby="accounting-description-editor-{{ description_editor.debit_credit }}-modal-label">
<button id="accounting-description-editor-{{ description_editor.debit_credit }}-offset" class="btn btn-primary text-nowrap ms-2" type="button" data-bs-toggle="modal" data-bs-target="#accounting-original-line-item-selector-modal">
{{ A_("Offset...") }}
</button>
</div>
{# Tab navigation #}
<ul class="nav nav-tabs mb-2">
<li class="nav-item">
<span id="accounting-description-editor-{{ description_editor.debit_credit }}-general-tab" class="nav-link active accounting-clickable" aria-current="page">
{{ A_("General") }}
</span>
</li>
<li class="nav-item">
<span id="accounting-description-editor-{{ description_editor.debit_credit }}-travel-tab" class="nav-link accounting-clickable" aria-current="false">
{{ A_("Travel") }}
</span>
</li>
<li class="nav-item">
<span id="accounting-description-editor-{{ description_editor.debit_credit }}-bus-tab" class="nav-link accounting-clickable" aria-current="false">
{{ A_("Bus") }}
</span>
</li>
<li class="nav-item">
<span id="accounting-description-editor-{{ description_editor.debit_credit }}-recurring-tab" class="nav-link accounting-clickable" aria-current="false">
{{ A_("Recurring") }}
</span>
</li>
<li class="nav-item">
<span id="accounting-description-editor-{{ description_editor.debit_credit }}-annotation-tab" class="nav-link accounting-clickable" aria-current="false">
{{ A_("Annotation") }}
</span>
</li>
</ul>
{# A general description with a tag #}
<div id="accounting-description-editor-{{ description_editor.debit_credit }}-general-page" aria-current="page" aria-labelledby="accounting-description-editor-{{ description_editor.debit_credit }}-general-tab">
<div class="form-floating mb-2">
<input id="accounting-description-editor-{{ description_editor.debit_credit }}-general-tag" class="form-control" type="text" value="" placeholder=" ">
<label class="form-label" for="accounting-description-editor-{{ description_editor.debit_credit }}-general-tag">{{ A_("Tag") }}</label>
<div id="accounting-description-editor-{{ description_editor.debit_credit }}-general-tag-error" class="invalid-feedback"></div>
</div>
<div class="accounting-description-editor-buttons">
{% for tag in description_editor.general.tags %}
<button class="btn btn-outline-primary accounting-description-editor-{{ description_editor.debit_credit }}-general-btn-tag" type="button" tabindex="-1" data-value="{{ tag.name }}" data-accounts="{{ tag.account_codes|tojson|forceescape }}">
{{ tag }}
</button>
{% endfor %}
</div>
</div>
{# A general trip with the origin and distination #}
<div id="accounting-description-editor-{{ description_editor.debit_credit }}-travel-page" class="d-none" aria-current="false" aria-labelledby="accounting-description-editor-{{ description_editor.debit_credit }}-travel-tab">
<div class="form-floating mb-2">
<input id="accounting-description-editor-{{ description_editor.debit_credit }}-travel-tag" class="form-control" type="text" value="" placeholder=" ">
<label class="form-label" for="accounting-description-editor-{{ description_editor.debit_credit }}-travel-tag">{{ A_("Tag") }}</label>
<div id="accounting-description-editor-{{ description_editor.debit_credit }}-travel-tag-error" class="invalid-feedback"></div>
</div>
<div class="accounting-description-editor-buttons">
{% for tag in description_editor.travel.tags %}
<button class="btn btn-outline-primary accounting-description-editor-{{ description_editor.debit_credit }}-travel-btn-tag" type="button" tabindex="-1" data-value="{{ tag.name }}" data-accounts="{{ tag.account_codes|tojson|forceescape }}">
{{ tag }}
</button>
{% endfor %}
</div>
<div class="d-flex justify-content-between">
<div class="form-floating">
<input id="accounting-description-editor-{{ description_editor.debit_credit }}-travel-from" class="form-control" type="text" value="" placeholder=" ">
<label class="form-label" for="accounting-description-editor-{{ description_editor.debit_credit }}-travel-from">{{ A_("From") }}</label>
<div id="accounting-description-editor-{{ description_editor.debit_credit }}-travel-from-error" class="invalid-feedback"></div>
</div>
<div class="btn-group-vertical ms-1 me-1">
<button class="btn btn-primary accounting-description-editor-{{ description_editor.debit_credit }}-travel-direction accounting-default" type="button" tabindex="-1" data-arrow="&rarr;">&rarr;</button>
<button class="btn btn-outline-primary accounting-description-editor-{{ description_editor.debit_credit }}-travel-direction" type="button" tabindex="-1" data-arrow="&harr;">&harr;</button>
</div>
<div class="form-floating">
<input id="accounting-description-editor-{{ description_editor.debit_credit }}-travel-to" class="form-control" type="text" value="" placeholder=" ">
<label class="form-label" for="accounting-description-editor-{{ description_editor.debit_credit }}-travel-to">{{ A_("To") }}</label>
<div id="accounting-description-editor-{{ description_editor.debit_credit }}-travel-to-error" class="invalid-feedback"></div>
</div>
</div>
</div>
{# A bus trip with the route name or route number, the origin and distination #}
<div id="accounting-description-editor-{{ description_editor.debit_credit }}-bus-page" class="d-none" aria-current="false" aria-labelledby="accounting-description-editor-{{ description_editor.debit_credit }}-bus-tab">
<div class="d-flex justify-content-between mb-2">
<div class="form-floating me-2">
<input id="accounting-description-editor-{{ description_editor.debit_credit }}-bus-tag" class="form-control" type="text" value="" placeholder=" ">
<label class="form-label" for="accounting-description-editor-{{ description_editor.debit_credit }}-bus-tag">{{ A_("Tag") }}</label>
<div id="accounting-description-editor-{{ description_editor.debit_credit }}-bus-tag-error" class="invalid-feedback"></div>
</div>
<div class="form-floating">
<input id="accounting-description-editor-{{ description_editor.debit_credit }}-bus-route" class="form-control" type="text" value="" placeholder=" ">
<label class="form-label" for="accounting-description-editor-{{ description_editor.debit_credit }}-bus-route">{{ A_("Route") }}</label>
<div id="accounting-description-editor-{{ description_editor.debit_credit }}-bus-route-error" class="invalid-feedback"></div>
</div>
</div>
<div class="accounting-description-editor-buttons">
{% for tag in description_editor.bus.tags %}
<button class="btn btn-outline-primary accounting-description-editor-{{ description_editor.debit_credit }}-bus-btn-tag" type="button" tabindex="-1" data-value="{{ tag.name }}" data-accounts="{{ tag.account_codes|tojson|forceescape }}">
{{ tag }}
</button>
{% endfor %}
</div>
<div class="d-flex justify-content-between">
<div class="form-floating me-2">
<input id="accounting-description-editor-{{ description_editor.debit_credit }}-bus-from" class="form-control" type="text" value="" placeholder=" ">
<label class="form-label" for="accounting-description-editor-{{ description_editor.debit_credit }}-bus-from">{{ A_("From") }}</label>
<div id="accounting-description-editor-{{ description_editor.debit_credit }}-bus-from-error" class="invalid-feedback"></div>
</div>
<div class="form-floating">
<input id="accounting-description-editor-{{ description_editor.debit_credit }}-bus-to" class="form-control" type="text" value="" placeholder=" ">
<label class="form-label" for="accounting-description-editor-{{ description_editor.debit_credit }}-bus-to">{{ A_("To") }}</label>
<div id="accounting-description-editor-{{ description_editor.debit_credit }}-bus-to-error" class="invalid-feedback"></div>
</div>
</div>
</div>
{# A recurring transaction #}
<div id="accounting-description-editor-{{ description_editor.debit_credit }}-recurring-page" class="d-none" aria-current="false" aria-labelledby="accounting-description-editor-{{ description_editor.debit_credit }}-recurring-tab">
<div class="accounting-description-editor-buttons">
{% for recurring in description_editor.recurring %}
<button class="btn btn-outline-primary accounting-description-editor-{{ description_editor.debit_credit }}-recurring-item" type="button" tabindex="-1" data-template="{{ recurring.template }}" data-accounts="{{ recurring.account_codes|tojson|forceescape }}">
{{ recurring.name }}
</button>
{% endfor %}
</div>
</div>
{# The annotation #}
<div id="accounting-description-editor-{{ description_editor.debit_credit }}-annotation-page" class="d-none" aria-current="false" aria-labelledby="accounting-description-editor-{{ description_editor.debit_credit }}-annotation-tab">
<div class="form-floating">
<input id="accounting-description-editor-{{ description_editor.debit_credit }}-annotation-number" class="form-control" type="number" min="1" value="" placeholder=" ">
<label class="form-label" for="accounting-description-editor-{{ description_editor.debit_credit }}-annotation-number">{{ A_("The Number of Items") }}</label>
<div id="accounting-description-editor-{{ description_editor.debit_credit }}-annotation-number-error" class="invalid-feedback"></div>
</div>
<div class="form-floating mt-2">
<input id="accounting-description-editor-{{ description_editor.debit_credit }}-annotation-note" class="form-control" type="text" value="" placeholder=" ">
<label class="form-label" for="accounting-description-editor-{{ description_editor.debit_credit }}-annotation-note">{{ A_("Note") }}</label>
<div id="accounting-description-editor-{{ description_editor.debit_credit }}-annotation-note-error" class="invalid-feedback"></div>
</div>
</div>
{# The suggested accounts #}
<div class="mt-3">
{% for account in description_editor.accounts %}
<button class="btn btn-outline-primary d-none accounting-description-editor-{{ description_editor.debit_credit }}-account {% if account.is_need_offset %} accounting-account-is-need-offset {% endif %}" type="button" data-code="{{ account.code }}" data-text="{{ account }}">
{{ account }}
</button>
{% endfor %}
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" type="button" data-bs-toggle="modal" data-bs-target="#accounting-line-item-editor-modal">{{ A_("Cancel") }}</button>
<button id="accounting-description-editor-{{ description_editor.debit_credit }}-btn-save" type="submit" class="btn btn-primary">{{ A_("Save") }}</button>
</div>
</div>
</div>
</div>
</form>

View File

@ -1,6 +1,6 @@
{#
The Mia! Accounting Flask Project
detail-entries-item: The journal entries in the transaction detail
detail-line-items-item: The line items in the journal entry detail
Copyright (c) 2023 imacat.
@ -20,40 +20,40 @@ Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/3/14
#}
{# <ul> For SonarQube not to complain about incorrect HTML #}
{% for entry in entries %}
<li class="list-group-item accounting-transaction-entry">
{% for line_item in line_items %}
<li class="list-group-item accounting-journal-entry-line-item">
<div class="d-flex justify-content-between">
<div>
<div class="small">{{ entry.account }}</div>
{% if entry.summary is not none %}
<div>{{ entry.summary }}</div>
<div class="small">{{ line_item.account }}</div>
{% if line_item.description is not none %}
<div>{{ line_item.description }}</div>
{% endif %}
{% if entry.original_entry %}
<div class="fst-italic small accounting-original-entry">
<a href="{{ url_for("accounting.transaction.detail", txn=entry.original_entry.transaction)|accounting_append_next }}">
{{ A_("Offset %(entry)s", entry=entry.original_entry) }}
{% if line_item.original_line_item %}
<div class="fst-italic small accounting-original-line-item">
<a href="{{ url_for("accounting.journal-entry.detail", journal_entry=line_item.original_line_item.journal_entry)|accounting_append_next }}">
{{ A_("Offset %(item)s", item=line_item.original_line_item) }}
</a>
</div>
{% endif %}
{% if entry.is_need_offset %}
<div class="fst-italic small accounting-offset-entries">
{% if entry.offsets %}
{% if line_item.is_need_offset %}
<div class="fst-italic small accounting-offset-line-items">
{% if line_item.offsets %}
<div class="d-flex justify-content-between">
<div>{{ A_("Offsets") }}</div>
<ul class="ms-2 ps-0">
{% for offset in entry.offsets %}
{% for offset in line_item.offsets %}
<li>
<a href="{{ url_for("accounting.transaction.detail", txn=offset.transaction)|accounting_append_next }}">
{{ offset.transaction.date|accounting_format_date }} {{ offset.amount|accounting_format_amount }}
<a href="{{ url_for("accounting.journal-entry.detail", journal_entry=offset.journal_entry)|accounting_append_next }}">
{{ offset.journal_entry.date|accounting_format_date }} {{ offset.amount|accounting_format_amount }}
</a>
</li>
{% endfor %}
</ul>
</div>
{% if entry.balance %}
{% if line_item.balance %}
<div class="d-flex justify-content-between">
<div>{{ A_("Net balance") }}</div>
<div>{{ entry.balance|accounting_format_amount }}</div>
<div>{{ line_item.balance|accounting_format_amount }}</div>
</div>
{% else %}
<div class="d-flex justify-content-between">
@ -68,7 +68,7 @@ First written: 2023/3/14
</div>
{% endif %}
</div>
<div>{{ entry.amount|accounting_format_amount }}</div>
<div>{{ line_item.amount|accounting_format_amount }}</div>
</div>
</li>
{% endfor %}

View File

@ -25,32 +25,32 @@ First written: 2023/2/26
{% block content %}
<div class="btn-group mb-3">
<a class="btn btn-primary" href="{{ url_for("accounting.report.default")|accounting_or_next }}">
<div class="mb-3 accounting-toolbar">
<a class="btn btn-primary d-none d-md-inline" href="{{ url_for("accounting.report.default")|accounting_or_next }}">
<i class="fa-solid fa-circle-chevron-left"></i>
{{ A_("Back") }}
</a>
{% if accounting_can_edit() %}
<a class="btn btn-primary d-none d-md-inline" href="{{ url_for("accounting.transaction.edit", txn=obj)|accounting_inherit_next }}">
<i class="fa-solid fa-gear"></i>
{{ A_("Settings") }}
<a class="btn btn-primary d-none d-md-inline" href="{{ url_for("accounting.journal-entry.edit", journal_entry=obj)|accounting_inherit_next }}">
<i class="fa-solid fa-pen-to-square"></i>
{{ A_("Edit") }}
</a>
{% endif %}
<a class="btn btn-primary" href="{{ url_for("accounting.transaction.order", txn_date=obj.date)|accounting_append_next }}">
<a class="btn btn-primary" href="{{ url_for("accounting.journal-entry.order", journal_entry_date=obj.date)|accounting_append_next }}">
<i class="fa-solid fa-bars-staggered"></i>
{{ A_("Order") }}
<span class="d-none d-md-inline">{{ A_("Order") }}</span>
</a>
{% if accounting_can_edit() %}
{% block to_transfer %}{% endblock %}
{% block as_trasfer %}{% endblock %}
{% if obj.can_delete %}
<button class="btn btn-danger" type="button" data-bs-toggle="modal" data-bs-target="#accounting-delete-modal">
<i class="fa-solid fa-trash"></i>
{{ A_("Delete") }}
<span class="d-none d-md-inline">{{ A_("Delete") }}</span>
</button>
{% else %}
<button class="btn btn-secondary" type="button" disabled="disabled">
<i class="fa-solid fa-trash"></i>
{{ A_("Delete") }}
<span class="d-none d-md-inline">{{ A_("Delete") }}</span>
</button>
{% endif %}
{% endif %}
@ -58,14 +58,14 @@ First written: 2023/2/26
{% if accounting_can_edit() %}
<div class="d-md-none accounting-material-fab">
<a class="btn btn-primary" href="{{ url_for("accounting.transaction.edit", txn=obj)|accounting_inherit_next }}">
<a class="btn btn-primary" href="{{ url_for("accounting.journal-entry.edit", journal_entry=obj)|accounting_inherit_next }}">
<i class="fa-solid fa-pen-to-square"></i>
</a>
</div>
{% endif %}
{% if accounting_can_edit() and obj.can_delete %}
<form action="{{ url_for("accounting.transaction.delete", txn=obj) }}" method="post">
<form action="{{ url_for("accounting.journal-entry.delete", journal_entry=obj) }}" method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
{% if request.args.next %}
<input type="hidden" name="next" value="{{ request.args.next }}">
@ -74,11 +74,11 @@ First written: 2023/2/26
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-5" id="accounting-delete-modal-label">{{ A_("Delete Transaction Confirmation") }}</h1>
<h1 class="modal-title fs-5" id="accounting-delete-modal-label">{{ A_("Confirm Delete Journal Entry") }}</h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{{ A_("Close") }}"></button>
</div>
<div class="modal-body">
{{ A_("Do you really want to delete this transaction?") }}
{{ A_("Do you really want to delete this journal entry?") }}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ A_("Cancel") }}</button>
@ -99,13 +99,13 @@ First written: 2023/2/26
{{ obj.date|accounting_format_date }}
</div>
{% block transaction_currencies %}{% endblock %}
{% block journal_entry_currencies %}{% endblock %}
{% if obj.note %}
<div class="card mb-3">
<div class="card-body">
<i class="far fa-comment-dots"></i>
{{ obj.note|accounting_txn_text2html|safe }}
{{ obj.note|accounting_journal_entry_text2html|safe }}
</div>
</div>
{% endif %}

View File

@ -0,0 +1,47 @@
{#
The Mia! Accounting Flask Project
form-currency.html: The currency sub-form in the journal entry 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/3/21
#}
<div id="accounting-currency-{{ currency_index }}" class="mb-3 accounting-currency" data-index="{{ currency_index }}">
<input id="accounting-currency-{{ currency_index }}-no" type="hidden" name="currency-{{ currency_index }}-no" value="{{ currency_index }}">
<input id="accounting-currency-{{ currency_index }}-code" type="hidden" name="currency-{{ currency_index }}-code" value="{{ currency_code_data }}">
<div id="accounting-currency-{{ currency_index }}-control" class="form-control accounting-currency-control {% if currency_errors %} is-invalid {% endif %}">
<div class="d-flex justify-content-between mt-2 mb-3">
<div class="form-floating accounting-currency-content">
<select id="accounting-currency-{{ currency_index }}-code-select" class="form-select {% if currency_code_errors %} is-invalid {% endif %}" {% if currency_code_is_locked %} disabled="disabled" {% endif %}>
{% for currency in accounting_currency_options() %}
<option value="{{ currency.code }}" {% if currency.code == currency_code_data %} selected="selected" {% endif %}>{{ currency }}</option>
{% endfor %}
</select>
<label class="form-label" for="accounting-currency-{{ currency_index }}-code-select">{{ A_("Currency") }}</label>
<div id="accounting-currency-{{ currency_index }}-code-error" class="invalid-feedback">{% if currency_code_errors %}{{ currency_code_errors[0] }}{% endif %}</div>
</div>
<div>
<button id="accounting-currency-{{ currency_index }}-delete" class="btn btn-danger rounded-circle {% if only_one_currency_form %} d-none {% endif %}" type="button" data-target="accounting-currency-{{ currency_index }}">
<i class="fas fa-minus"></i>
</button>
</div>
</div>
{% block line_items %}{% endblock %}
</div>
<div id="accounting-currency-{{ currency_index }}-error" class="invalid-feedback">{% if currency_errors %}{{ currency_errors[0] }}{% endif %}</div>
</div>

View File

@ -0,0 +1,49 @@
{#
The Mia! Accounting Flask Project
form-debit-credit.html: The debit or credit line items in the journal entry 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/3/21
#}
<div class="mb-2">
<div id="accounting-currency-{{ currency_index }}-{{ debit_credit }}" class="form-control accounting-material-text-field accounting-not-empty {% if debit_errors %} is-invalid {% endif %}">
<label class="form-label" for="accounting-currency-{{ currency_index }}-{{ debit_credit }}">{{ header }}</label>
<ul id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-list" class="list-group accounting-line-item-list">
{% for line_item_form in line_item_forms %}
{% with currency_index = currency_index,
line_item_index = loop.index,
only_one_line_item_form = line_item_forms|length == 1,
form = line_item_form.form %}
{% include "accounting/journal-entry/include/form-line-item.html" %}
{% endwith %}
{% endfor %}
</ul>
<div class="d-flex justify-content-between mb-2">
<div>{{ A_("Total") }}</div>
<div><span id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-total" class="badge rounded-pill bg-primary">{{ debit_credit_total }}</span></div>
</div>
<div>
<button id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-add-line-item" class="btn btn-primary" type="button" data-currency-index="{{ currency_index }}" data-debit-credit="{{ debit_credit }}" data-bs-toggle="modal" data-bs-target="#accounting-line-item-editor-modal">
<i class="fas fa-plus"></i>
{{ A_("New") }}
</button>
</div>
</div>
<div id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-error" class="invalid-feedback">{% if debit_credit_errors %}{{ debit_credit_errors[0] }}{% endif %}</div>
</div>

View File

@ -0,0 +1,74 @@
{#
The Mia! Accounting Flask Project
form-line-item.html: The line item sub-form in the journal entry 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/25
#}
{# <ul> For SonarQube not to complain about incorrect HTML #}
<li id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}" class="list-group-item list-group-item-action d-flex justify-content-between accounting-currency-{{ currency_index }}-{{ debit_credit }} {% if form.offsets %} accounting-matched-line-item {% endif %}" data-currency-index="{{ currency_index }}" data-debit-credit="{{ debit_credit }}" data-line-item-index="{{ line_item_index }}" {% if form.is_need_offset %} data-is-need-offset="true" {% endif %}>
{% if form.id.data %}
<input type="hidden" name="currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}-id" value="{{ form.id.data }}">
{% endif %}
<input id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}-no" type="hidden" name="currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}-no" value="{{ line_item_index }}">
<input id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}-original-line-item-id" class="accounting-original-line-item-id" type="hidden" name="currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}-original_line_item_id" value="{{ form.original_line_item_id.data|accounting_default }}" data-date="{{ form.original_line_item_date|accounting_default }}" data-text="{{ form.original_line_item_text|accounting_default }}">
<input id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}-account-code" type="hidden" name="currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}-account_code" value="{{ form.account_code.data|accounting_default }}" data-text="{{ form.account_text }}">
<input id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}-description" type="hidden" name="currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}-description" value="{{ form.description.data|accounting_default }}">
<input id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}-amount" type="hidden" name="currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}-amount" value="{{ form.amount.data|accounting_journal_entry_format_amount_input }}" data-min="{{ form.offset_total|accounting_default("0") }}">
<div class="accounting-line-item-content">
<div id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}-control" class="form-control clickable d-flex justify-content-between {% if form.all_errors %} is-invalid {% endif %}" data-bs-toggle="modal" data-bs-target="#accounting-line-item-editor-modal">
<div>
<div id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}-account-text" class="small">{{ form.account_text }}</div>
<div id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}-description-text">{{ form.description.data|accounting_default }}</div>
<div id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}-original-line-item-text" class="fst-italic small accounting-original-line-item {% if not form.original_line_item_id.data %} d-none {% endif %}">
{% if form.original_line_item_id.data %}{{ A_("Offset %(item)s", item=form.original_line_item_text|accounting_default) }}{% endif %}
</div>
<div id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}-offsets" class="fst-italic small accounting-offset-line-items {% if not form.is_need_offset %} d-none {% endif %}">
{% if form.offsets %}
<div class="d-flex justify-content-between {% if not form.offsets %} d-none {% endif %}">
<div>{{ A_("Offsets") }}</div>
<ul class="ms-2 ps-0">
{% for offset in form.offsets %}
<li>{{ offset.journal_entry.date|accounting_format_date }} {{ offset.amount|accounting_format_amount }}</li>
{% endfor %}
</ul>
</div>
{% if form.net_balance == 0 %}
<div>{{ A_("Fully offset") }}</div>
{% else %}
<div class="d-flex justify-content-between">
<div>{{ A_("Net balance") }}</div>
<div>{{ form.net_balance|accounting_format_amount }}</div>
</div>
{% endif %}
{% else %}
{{ A_("Unmatched") }}
{% endif %}
</div>
</div>
<div><span id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}-amount-text" class="badge rounded-pill bg-primary">{{ form.amount.data|accounting_format_amount }}</span></div>
</div>
<div id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}-error" class="invalid-feedback">{% if form.all_errors %}{{ form.all_errors[0] }}{% endif %}</div>
</div>
<div>
<button id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}-delete" class="btn btn-danger rounded-circle {% if only_one_form or form.offsets %} d-none {% endif %}" type="button" data-target="accounting-currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}">
<i class="fas fa-minus"></i>
</button>
</div>
</li>
{# </ul> For SonarQube not to complain about incorrect HTML #}

View File

@ -1,6 +1,6 @@
{#
The Mia! Accounting Flask Project
form.html: The transfer transaction form
form.html: The base journal entry form
Copyright (c) 2023 imacat.
@ -23,23 +23,23 @@ First written: 2023/2/26
{% block accounting_scripts %}
<script src="{{ url_for("accounting.static", filename="js/drag-and-drop-reorder.js") }}"></script>
<script src="{{ url_for("accounting.static", filename="js/transaction-form.js") }}"></script>
<script src="{{ url_for("accounting.static", filename="js/journal-entry-editor.js") }}"></script>
<script src="{{ url_for("accounting.static", filename="js/journal-entry-form.js") }}"></script>
<script src="{{ url_for("accounting.static", filename="js/journal-entry-line-item-editor.js") }}"></script>
<script src="{{ url_for("accounting.static", filename="js/account-selector.js") }}"></script>
<script src="{{ url_for("accounting.static", filename="js/original-entry-selector.js") }}"></script>
<script src="{{ url_for("accounting.static", filename="js/summary-editor.js") }}"></script>
<script src="{{ url_for("accounting.static", filename="js/original-line-item-selector.js") }}"></script>
<script src="{{ url_for("accounting.static", filename="js/description-editor.js") }}"></script>
{% endblock %}
{% block content %}
<div class="btn-group mb-3">
<div class="mb-3 d-none d-md-block">
<a class="btn btn-primary" role="button" href="{% block back_url %}{% endblock %}">
<i class="fa-solid fa-circle-chevron-left"></i>
{{ A_("Back") }}
<span class="d-none d-md-inline">{{ A_("Back") }}</span>
</a>
</div>
<form id="accounting-form" action="{% block action_url %}{% endblock %}" method="post" data-currency-template="{{ currency_template }}" data-entry-template="{{ entry_template }}">
<form id="accounting-form" action="{% block action_url %}{% endblock %}" method="post" data-currency-template="{{ currency_template }}" data-line-item-template="{{ line_item_template }}">
{{ form.csrf_token }}
{% if request.args.next %}
<input type="hidden" name="next" value="{{ request.args.next }}">
@ -88,8 +88,8 @@ First written: 2023/2/26
</div>
</form>
{% include "accounting/transaction/include/journal-entry-editor-modal.html" %}
{% include "accounting/journal-entry/include/journal-entry-line-item-editor-modal.html" %}
{% block form_modals %}{% endblock %}
{% include "accounting/transaction/include/original-entry-selector-modal.html" %}
{% include "accounting/journal-entry/include/original-line-item-selector-modal.html" %}
{% endblock %}

View File

@ -0,0 +1,76 @@
{#
The Mia! Accounting Flask Project
journal-entry-line-item-editor-modal: The modal of the journal entry line item editor
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/25
#}
<form id="accounting-line-item-editor">
<div id="accounting-line-item-editor-modal" class="modal fade" tabindex="-1" aria-labelledby="accounting-line-item-editor-modal-label" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-5" id="accounting-line-item-editor-modal-label">{{ A_("Line Item Content") }}</h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{{ A_("Close") }}"></button>
</div>
<div class="modal-body">
<div id="accounting-line-item-editor-original-line-item-container" class="d-flex justify-content-between mb-3">
<div class="accounting-line-item-editor-original-line-item-content">
<div id="accounting-line-item-editor-original-line-item-control" class="form-control accounting-clickable accounting-material-text-field" data-bs-toggle="modal" data-bs-target="#accounting-original-line-item-selector-modal">
<label class="form-label" for="accounting-line-item-editor-original-line-item">{{ A_("Original Line Item") }}</label>
<div id="accounting-line-item-editor-original-line-item"></div>
</div>
<div id="accounting-line-item-editor-original-line-item-error" class="invalid-feedback"></div>
</div>
<div>
<button id="accounting-line-item-editor-original-line-item-delete" class="btn btn-danger rounded-circle" type="button">
<i class="fas fa-minus"></i>
</button>
</div>
</div>
<div class="mb-3">
<div id="accounting-line-item-editor-description-control" class="form-control accounting-clickable accounting-material-text-field" data-bs-toggle="modal" data-bs-target="">
<label class="form-label" for="accounting-line-item-editor-description">{{ A_("Description") }}</label>
<div id="accounting-line-item-editor-description"></div>
</div>
<div id="accounting-line-item-editor-description-error" class="invalid-feedback"></div>
</div>
<div class="mb-3">
<div id="accounting-line-item-editor-account-control" class="form-control accounting-clickable accounting-material-text-field" data-bs-toggle="modal" data-bs-target="">
<label class="form-label" for="accounting-line-item-editor-account">{{ A_("Account") }}</label>
<div id="accounting-line-item-editor-account"></div>
</div>
<div id="accounting-line-item-editor-account-error" class="invalid-feedback"></div>
</div>
<div class="form-floating mb-3">
<input id="accounting-line-item-editor-amount" class="form-control" type="number" value="" min="0" max="" step="0.01" placeholder=" " required="required">
<label for="accounting-line-item-editor-amount">{{ A_("Amount") }}</label>
<div id="accounting-line-item-editor-amount-error" class="invalid-feedback"></div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ A_("Cancel") }}</button>
<button type="submit" class="btn btn-primary">{{ A_("Save") }}</button>
</div>
</div>
</div>
</div>
</form>

View File

@ -0,0 +1,57 @@
{#
The Mia! Accounting Flask Project
order-journal-entry.html: The journal entry in the journal entry order page
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/3/21
#}
<a class="small w-100 accounting-journal-entry-order-item" href="{{ url_for("accounting.journal-entry.detail", journal_entry=journal_entry)|accounting_append_next }}">
<div>
{{ journal_entry.date|accounting_format_date }}
{% if journal_entry.is_cash_disbursement %}
{{ A_("Cash Disbursement") }}
{% elif journal_entry.is_cash_receipt %}
{{ A_("Cash Receipt") }}
{% else %}
{{ A_("Transfer") }}
{% endif %}
</div>
{% for currency in journal_entry.currencies %}
<div class="d-flex justify-content-between accounting-journal-entry-order-item-currency">
<div>
{% if not journal_entry.is_cash_receipt %}
{% for line_item in currency.debit %}
<div>{{ line_item.description|accounting_default }}</div>
{% endfor %}
{% endif %}
{% if not journal_entry.is_cash_disbursement %}
{% for line_item in currency.credit %}
<div class="accounting-mobile-journal-credit">{{ line_item.description|accounting_default }}</div>
{% endfor %}
{% endif %}
</div>
<div>
<span class="badge bg-info rounded-pill">
{% if currency.code != accounting_default_currency_code() %}
{{ currency.code }}
{% endif %}
{{ currency.debit_total|accounting_format_amount }}
</span>
</div>
</div>
{% endfor %}
</a>

View File

@ -0,0 +1,56 @@
{#
The Mia! Accounting Flask Project
original-line-item-selector-modal.html: The modal of the original line item selector
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/25
#}
<div id="accounting-original-line-item-selector-modal" class="modal fade" tabindex="-1" aria-labelledby="accounting-original-line-item-selector-modal-label" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-5" id="accounting-original-line-item-selector-modal-label">{{ A_("Select Original Line Item") }}</h1>
<button type="button" class="btn-close" data-bs-toggle="modal" data-bs-target="#accounting-line-item-editor-modal" aria-label="{{ A_("Close") }}"></button>
</div>
<div class="modal-body">
<div class="input-group mb-2">
<input id="accounting-original-line-item-selector-query" class="form-control form-control-sm" type="search" placeholder=" " required="required">
<label class="input-group-text" for="accounting-original-line-item-selector-query">
<i class="fa-solid fa-magnifying-glass"></i>
{{ A_("Search") }}
</label>
</div>
<ul id="accounting-original-line-item-selector-option-list" class="list-group accounting-selector-list">
{% for line_item in form.original_line_item_options %}
<li id="accounting-original-line-item-selector-option-{{ line_item.id }}" class="list-group-item d-flex justify-content-between accounting-clickable accounting-original-line-item-selector-option" data-id="{{ line_item.id }}" data-date="{{ line_item.journal_entry.date }}" data-debit-credit="{{ "debit" if line_item.is_debit else "credit" }}" data-currency-code="{{ line_item.currency.code }}" data-account-code="{{ line_item.account_code }}" data-account-text="{{ line_item.account }}" data-description="{{ line_item.description|accounting_default }}" data-net-balance="{{ line_item.net_balance|accounting_journal_entry_format_amount_input }}" data-text="{{ line_item }}" data-query-values="{{ line_item.query_values|tojson|forceescape }}" data-bs-toggle="modal" data-bs-target="#accounting-line-item-editor-modal">
<div>{{ line_item.journal_entry.date|accounting_format_date }} {{ line_item.description|accounting_default }}</div>
<div>
<span class="badge bg-primary rounded-pill">
<span id="accounting-original-line-item-selector-option-{{ line_item.id }}-net-balance">{{ line_item.net_balance|accounting_format_amount }}</span>
/ {{ line_item.amount|accounting_format_amount }}
</span>
</div>
</li>
{% endfor %}
</ul>
<p id="accounting-original-line-item-selector-option-no-result" class="d-none">{{ A_("There is no data.") }}</p>
</div>
</div>
</div>
</div>

View File

@ -1,6 +1,6 @@
{#
The Mia! Accounting Flask Project
order.html: The order of the transactions in a same day
order.html: The order of the journal entries in a same day
Copyright (c) 2023 imacat.
@ -23,14 +23,14 @@ First written: 2023/2/26
{% block accounting_scripts %}
<script src="{{ url_for("accounting.static", filename="js/drag-and-drop-reorder.js") }}"></script>
<script src="{{ url_for("accounting.static", filename="js/transaction-order.js") }}"></script>
<script src="{{ url_for("accounting.static", filename="js/journal-entry-order.js") }}"></script>
{% endblock %}
{% block header %}{% block title %}{{ A_("Transactions on %(date)s", date=date) }}{% endblock %}{% endblock %}
{% block header %}{% block title %}{{ A_("Journal Entries on %(date)s", date=date) }}{% endblock %}{% endblock %}
{% block content %}
<div class="btn-group mb-3">
<div class="mb-3 d-none d-md-block">
<a class="btn btn-primary" href="{{ url_for("accounting.report.default")|accounting_or_next }}">
<i class="fa-solid fa-circle-chevron-left"></i>
{{ A_("Back") }}
@ -38,7 +38,7 @@ First written: 2023/2/26
</div>
{% if list|length > 1 and accounting_can_edit() %}
<form action="{{ url_for("accounting.transaction.sort", txn_date=date) }}" method="post">
<form action="{{ url_for("accounting.journal-entry.sort", journal_entry_date=date) }}" method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
{% if request.args.next %}
<input type="hidden" name="next" value="{{ request.args.next }}">
@ -47,9 +47,9 @@ First written: 2023/2/26
{% for item in list %}
<li class="list-group-item d-flex justify-content-between" data-id="{{ item.id }}">
<input id="accounting-order-{{ item.id }}-no" type="hidden" name="{{ item.id }}-no" value="{{ loop.index }}">
<div>
{{ item }}
</div>
{% with journal_entry = item %}
{% include "accounting/journal-entry/include/order-journal-entry.html" %}
{% endwith %}
<i class="fa-solid fa-bars"></i>
</li>
{% endfor %}
@ -72,7 +72,9 @@ First written: 2023/2/26
<ul class="list-group mb-3">
{% for item in list %}
<li class="list-group-item">
{{ item }}
{% with journal_entry = item %}
{% include "accounting/journal-entry/include/order-journal-entry.html" %}
{% endwith %}
</li>
{% endfor %}
</ul>

View File

@ -1,6 +1,6 @@
{#
The Mia! Accounting Flask Project
create.html: The transfer transaction creation form
create.html: The cash receipt journal entry creation form
Copyright (c) 2023 imacat.
@ -19,10 +19,10 @@ create.html: The transfer transaction creation form
Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/2/25
#}
{% extends "accounting/transaction/transfer/include/form.html" %}
{% extends "accounting/journal-entry/receipt/include/form.html" %}
{% block header %}{% block title %}{{ A_("Add a New Transfer Transaction") }}{% endblock %}{% endblock %}
{% block header %}{% block title %}{{ A_("Add a New Cash Receipt Journal Entry") }}{% endblock %}{% endblock %}
{% block back_url %}{{ request.args.get("next") or url_for("accounting.report.default") }}{% endblock %}
{% block action_url %}{{ url_for("accounting.transaction.store", txn_type=txn_type) }}{% endblock %}
{% block action_url %}{{ url_for("accounting.journal-entry.store", journal_entry_type=journal_entry_type) }}{% endblock %}

View File

@ -1,6 +1,6 @@
{#
The Mia! Accounting Flask Project
detail.html: The account detail
detail.html: The cash receipt journal entry detail
Copyright (c) 2023 imacat.
@ -19,26 +19,26 @@ detail.html: The account detail
Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/2/26
#}
{% extends "accounting/transaction/include/detail.html" %}
{% extends "accounting/journal-entry/include/detail.html" %}
{% block to_transfer %}
<a class="btn btn-primary" href="{{ url_for("accounting.transaction.edit", txn=obj)|accounting_txn_to_transfer|accounting_inherit_next }}">
{% block as_trasfer %}
<a class="btn btn-primary" href="{{ url_for("accounting.journal-entry.edit", journal_entry=obj)|accounting_journal_entry_to_transfer|accounting_inherit_next }}">
<i class="fa-solid fa-bars-staggered"></i>
{{ A_("To Transfer") }}
<span class="d-none d-md-inline">{{ A_("As Transfer") }}</span>
</a>
{% endblock %}
{% block transaction_currencies %}
{% block journal_entry_currencies %}
{% for currency in obj.currencies %}
<div class="mb-3">
<div class="mb-2 fw-bolder">{{ currency.name }}</div>
<ul class="list-group accounting-list-group-stripped accounting-list-group-hover">
<li class="list-group-item accounting-transaction-entry accounting-transaction-entry-header">{{ A_("Content") }}</li>
{% with entries = currency.debit %}
{% include "accounting/transaction/include/detail-entries.html" %}
<li class="list-group-item accounting-journal-entry-line-item accounting-journal-entry-line-item-header">{{ A_("Content") }}</li>
{% with line_items = currency.credit %}
{% include "accounting/journal-entry/include/detail-line-items.html" %}
{% endwith %}
<li class="list-group-item accounting-transaction-entry accounting-transaction-entry-total">
<li class="list-group-item accounting-journal-entry-line-item accounting-journal-entry-line-item-total">
<div class="d-flex justify-content-between">
<div>{{ A_("Total") }}</div>
<div>{{ currency.debit_total|accounting_format_amount }}</div>

View File

@ -0,0 +1,28 @@
{#
The Mia! Accounting Flask Project
edit.html: The cash receipt journal entry edit 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/25
#}
{% extends "accounting/journal-entry/receipt/include/form.html" %}
{% block header %}{% block title %}{{ A_("Editing %(journal_entry)s", journal_entry=journal_entry) }}{% endblock %}{% endblock %}
{% block back_url %}{{ url_for("accounting.journal-entry.detail", journal_entry=journal_entry)|accounting_inherit_next }}{% endblock %}
{% block action_url %}{{ url_for("accounting.journal-entry.update", journal_entry=journal_entry)|accounting_journal_entry_with_type }}{% endblock %}

View File

@ -1,6 +1,6 @@
{#
The Mia! Accounting Flask Project
edit.html: The cash expense transaction edit form
form-currency.html: The currency sub-form in the cash receipt journal entry form
Copyright (c) 2023 imacat.
@ -19,10 +19,15 @@ edit.html: The cash expense transaction edit form
Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/2/25
#}
{% extends "accounting/transaction/expense/include/form.html" %}
{% extends "accounting/journal-entry/include/form-currency.html" %}
{% block header %}{% block title %}{{ A_("Editing %(txn)s", txn=txn) }}{% endblock %}{% endblock %}
{% block back_url %}{{ url_for("accounting.transaction.detail", txn=txn)|accounting_inherit_next }}{% endblock %}
{% block action_url %}{{ url_for("accounting.transaction.update", txn=txn)|accounting_txn_with_type }}{% endblock %}
{% block line_items %}
{% with currency_index = currency_index,
debit_credit = "credit",
line_item_forms = credit_forms,
header = A_("Content"),
debit_credit_total = credit_total,
debit_credit_errors = credit_errors %}
{% include "accounting/journal-entry/include/form-debit-credit.html" %}
{% endwith %}
{% endblock %}

View File

@ -1,6 +1,6 @@
{#
The Mia! Accounting Flask Project
form.html: The cash income transaction form
form.html: The cash receipt journal entry form
Copyright (c) 2023 imacat.
@ -19,7 +19,7 @@ form.html: The cash income transaction form
Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/2/25
#}
{% extends "accounting/transaction/include/form.html" %}
{% extends "accounting/journal-entry/include/form.html" %}
{% block currency_sub_forms %}
{% if form.currencies %}
@ -32,8 +32,8 @@ First written: 2023/2/25
currency_code_is_locked = currency_form.is_code_locked,
credit_forms = currency_form.credit,
credit_errors = currency_form.credit_errors,
credit_total = currency_form.form.credit_total|accounting_format_amount %}
{% include "accounting/transaction/income/include/form-currency-item.html" %}
credit_total = currency_form.credit_total|accounting_format_amount %}
{% include "accounting/journal-entry/receipt/include/form-currency.html" %}
{% endwith %}
{% endfor %}
{% else %}
@ -41,17 +41,17 @@ First written: 2023/2/25
only_one_currency_form = True,
currency_code_data = accounting_default_currency_code(),
credit_total = "-" %}
{% include "accounting/transaction/income/include/form-currency-item.html" %}
{% include "accounting/journal-entry/receipt/include/form-currency.html" %}
{% endwith %}
{% endif %}
{% endblock %}
{% block form_modals %}
{% with summary_editor = form.summary_editor.credit %}
{% include "accounting/transaction/include/summary-editor-modal.html" %}
{% with description_editor = form.description_editor.credit %}
{% include "accounting/journal-entry/include/description-editor-modal.html" %}
{% endwith %}
{% with entry_type = "credit",
{% with debit_credit = "credit",
account_options = form.credit_account_options %}
{% include "accounting/transaction/include/account-selector-modal.html" %}
{% include "accounting/journal-entry/include/account-selector-modal.html" %}
{% endwith %}
{% endblock %}

View File

@ -1,6 +1,6 @@
{#
The Mia! Accounting Flask Project
create.html: The cash income transaction creation form
create.html: The transfer journal entry creation form
Copyright (c) 2023 imacat.
@ -19,10 +19,10 @@ create.html: The cash income transaction creation form
Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/2/25
#}
{% extends "accounting/transaction/income/include/form.html" %}
{% extends "accounting/journal-entry/transfer/include/form.html" %}
{% block header %}{% block title %}{{ A_("Add a New Cash Income Transaction") }}{% endblock %}{% endblock %}
{% block header %}{% block title %}{{ A_("Add a New Transfer Journal Entry") }}{% endblock %}{% endblock %}
{% block back_url %}{{ request.args.get("next") or url_for("accounting.report.default") }}{% endblock %}
{% block action_url %}{{ url_for("accounting.transaction.store", txn_type=txn_type) }}{% endblock %}
{% block action_url %}{{ url_for("accounting.journal-entry.store", journal_entry_type=journal_entry_type) }}{% endblock %}

View File

@ -1,6 +1,6 @@
{#
The Mia! Accounting Flask Project
detail.html: The account detail
detail.html: The transfer journal entry detail
Copyright (c) 2023 imacat.
@ -19,22 +19,22 @@ detail.html: The account detail
Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/2/26
#}
{% extends "accounting/transaction/include/detail.html" %}
{% extends "accounting/journal-entry/include/detail.html" %}
{% block transaction_currencies %}
{% block journal_entry_currencies %}
{% for currency in obj.currencies %}
<div class="mb-3">
<div class="mb-2 fw-bolder">{{ currency.name }}</div>
<div class="row">
{# The debit entries #}
{# The debit line items #}
<div class="col-sm-6 mb-2">
<ul class="list-group accounting-list-group-stripped accounting-list-group-hover">
<li class="list-group-item accounting-transaction-entry accounting-transaction-entry-header">{{ A_("Debit") }}</li>
{% with entries = currency.debit %}
{% include "accounting/transaction/include/detail-entries.html" %}
<li class="list-group-item accounting-journal-entry-line-item accounting-journal-entry-line-item-header">{{ A_("Debit") }}</li>
{% with line_items = currency.debit %}
{% include "accounting/journal-entry/include/detail-line-items.html" %}
{% endwith %}
<li class="list-group-item accounting-transaction-entry accounting-transaction-entry-total">
<li class="list-group-item accounting-journal-entry-line-item accounting-journal-entry-line-item-total">
<div class="d-flex justify-content-between">
<div>{{ A_("Total") }}</div>
<div>{{ currency.debit_total|accounting_format_amount }}</div>
@ -43,14 +43,14 @@ First written: 2023/2/26
</ul>
</div>
{# The credit entries #}
{# The credit line items #}
<div class="col-sm-6 mb-2">
<ul class="list-group accounting-list-group-stripped accounting-list-group-hover">
<li class="list-group-item accounting-transaction-entry accounting-transaction-entry-header">{{ A_("Credit") }}</li>
{% with entries = currency.credit %}
{% include "accounting/transaction/include/detail-entries.html" %}
<li class="list-group-item accounting-journal-entry-line-item accounting-journal-entry-line-item-header">{{ A_("Credit") }}</li>
{% with line_items = currency.credit %}
{% include "accounting/journal-entry/include/detail-line-items.html" %}
{% endwith %}
<li class="list-group-item accounting-transaction-entry accounting-transaction-entry-total">
<li class="list-group-item accounting-journal-entry-line-item accounting-journal-entry-line-item-total">
<div class="d-flex justify-content-between">
<div>{{ A_("Total") }}</div>
<div>{{ currency.debit_total|accounting_format_amount }}</div>

View File

@ -1,6 +1,6 @@
{#
The Mia! Accounting Flask Project
edit.html: The transfer transaction edit form
edit.html: The transfer journal entry edit form
Copyright (c) 2023 imacat.
@ -19,10 +19,10 @@ edit.html: The transfer transaction edit form
Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/2/25
#}
{% extends "accounting/transaction/transfer/include/form.html" %}
{% extends "accounting/journal-entry/transfer/include/form.html" %}
{% block header %}{% block title %}{{ A_("Editing %(txn)s", txn=txn) }}{% endblock %}{% endblock %}
{% block header %}{% block title %}{{ A_("Editing %(journal_entry)s", journal_entry=journal_entry) }}{% endblock %}{% endblock %}
{% block back_url %}{{ url_for("accounting.transaction.detail", txn=txn)|accounting_inherit_next }}{% endblock %}
{% block back_url %}{{ url_for("accounting.journal-entry.detail", journal_entry=journal_entry)|accounting_inherit_next }}{% endblock %}
{% block action_url %}{{ url_for("accounting.transaction.update", txn=txn)|accounting_txn_with_type }}{% endblock %}
{% block action_url %}{{ url_for("accounting.journal-entry.update", journal_entry=journal_entry)|accounting_journal_entry_with_type }}{% endblock %}

View File

@ -0,0 +1,48 @@
{#
The Mia! Accounting Flask Project
form-currency.html: The currency sub-form in the transfer journal entry 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/25
#}
{% extends "accounting/journal-entry/include/form-currency.html" %}
{% block line_items %}
<div class="row">
<div class="col-sm-6">
{% with currency_index = currency_index,
debit_credit = "debit",
line_item_forms = debit_forms,
header = A_("Debit"),
debit_credit_total = debit_total,
debit_credit_errors = debit_errors %}
{% include "accounting/journal-entry/include/form-debit-credit.html" %}
{% endwith %}
</div>
<div class="col-sm-6">
{% with currency_index = currency_index,
debit_credit = "credit",
line_item_forms = credit_forms,
header = A_("Credit"),
debit_credit_total = credit_total,
debit_credit_errors = credit_errors %}
{% include "accounting/journal-entry/include/form-debit-credit.html" %}
{% endwith %}
</div>
</div>
{% endblock %}

View File

@ -1,6 +1,6 @@
{#
The Mia! Accounting Flask Project
form.html: The transfer transaction form
form.html: The transfer journal entry form
Copyright (c) 2023 imacat.
@ -19,7 +19,7 @@ form.html: The transfer transaction form
Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/2/25
#}
{% extends "accounting/transaction/include/form.html" %}
{% extends "accounting/journal-entry/include/form.html" %}
{% block currency_sub_forms %}
{% if form.currencies %}
@ -32,11 +32,11 @@ First written: 2023/2/25
currency_code_is_locked = currency_form.is_code_locked,
debit_forms = currency_form.debit,
debit_errors = currency_form.debit_errors,
debit_total = currency_form.form.debit_total|accounting_format_amount,
debit_total = currency_form.debit_total|accounting_format_amount,
credit_forms = currency_form.credit,
credit_errors = currency_form.credit_errors,
credit_total = currency_form.form.credit_total|accounting_format_amount %}
{% include "accounting/transaction/transfer/include/form-currency-item.html" %}
credit_total = currency_form.credit_total|accounting_format_amount %}
{% include "accounting/journal-entry/transfer/include/form-currency.html" %}
{% endwith %}
{% endfor %}
{% else %}
@ -45,24 +45,24 @@ First written: 2023/2/25
currency_code_data = accounting_default_currency_code(),
debit_total = "-",
credit_total = "-" %}
{% include "accounting/transaction/transfer/include/form-currency-item.html" %}
{% include "accounting/journal-entry/transfer/include/form-currency.html" %}
{% endwith %}
{% endif %}
{% endblock %}
{% block form_modals %}
{% with summary_editor = form.summary_editor.debit %}
{% include "accounting/transaction/include/summary-editor-modal.html" %}
{% with description_editor = form.description_editor.debit %}
{% include "accounting/journal-entry/include/description-editor-modal.html" %}
{% endwith %}
{% with summary_editor = form.summary_editor.credit %}
{% include "accounting/transaction/include/summary-editor-modal.html" %}
{% with description_editor = form.description_editor.credit %}
{% include "accounting/journal-entry/include/description-editor-modal.html" %}
{% endwith %}
{% with entry_type = "debit",
{% with debit_credit = "debit",
account_options = form.debit_account_options %}
{% include "accounting/transaction/include/account-selector-modal.html" %}
{% include "accounting/journal-entry/include/account-selector-modal.html" %}
{% endwith %}
{% with entry_type = "credit",
{% with debit_credit = "credit",
account_options = form.credit_account_options %}
{% include "accounting/transaction/include/account-selector-modal.html" %}
{% include "accounting/journal-entry/include/account-selector-modal.html" %}
{% endwith %}
{% endblock %}

View File

@ -37,7 +37,7 @@ First written: 2023/3/7
{% endwith %}
</div>
{% include "accounting/report/include/add-txn-material-fab.html" %}
{% include "accounting/report/include/add-journal-entry-material-fab.html" %}
{% include "accounting/report/include/period-chooser.html" %}

View File

@ -1,6 +1,6 @@
{#
The Mia! Accounting Flask Project
add-txn-material-fab.html: The material floating action buttons to add a new transaction
add-journal-entry-material-fab.html: The material floating action buttons to add a new journal entry
Copyright (c) 2023 imacat.
@ -22,13 +22,13 @@ First written: 2023/2/25
{% if accounting_can_edit() %}
<div id="accounting-material-fab-speed-dial" class="d-md-none accounting-material-fab">
<div id="accounting-material-fab-speed-dial-actions" class="d-md-none accounting-material-fab-speed-dial-group">
<a class="btn rounded-pill" href="{{ url_for("accounting.transaction.create", txn_type=report.txn_types.CASH_EXPENSE)|accounting_append_next }}">
{{ A_("Cash expense") }}
<a class="btn rounded-pill" href="{{ url_for("accounting.journal-entry.create", journal_entry_type=report.journal_entry_types.CASH_DISBURSEMENT)|accounting_append_next }}">
{{ A_("Cash Disbursement") }}
</a>
<a class="btn rounded-pill" href="{{ url_for("accounting.transaction.create", txn_type=report.txn_types.CASH_INCOME)|accounting_append_next }}">
{{ A_("Cash income") }}
<a class="btn rounded-pill" href="{{ url_for("accounting.journal-entry.create", journal_entry_type=report.journal_entry_types.CASH_RECEIPT)|accounting_append_next }}">
{{ A_("Cash Receipt") }}
</a>
<a class="btn rounded-pill" href="{{ url_for("accounting.transaction.create", txn_type=report.txn_types.TRANSFER)|accounting_append_next }}">
<a class="btn rounded-pill" href="{{ url_for("accounting.journal-entry.create", journal_entry_type=report.journal_entry_types.TRANSFER)|accounting_append_next }}">
{{ A_("Transfer") }}
</a>
</div>

View File

@ -19,9 +19,9 @@ income-expenses-row-desktop.html: The row in the income and expenses log for the
Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/3/8
#}
<div>{{ entry.date|accounting_format_date }}</div>
<div>{{ entry.account.title|title }}</div>
<div>{{ entry.summary|accounting_default }}</div>
<div class="accounting-amount">{{ entry.income|accounting_format_amount|accounting_default }}</div>
<div class="accounting-amount">{{ entry.expense|accounting_format_amount|accounting_default }}</div>
<div class="accounting-amount {% if entry.balance < 0 %} text-danger {% endif %}">{{ entry.balance|accounting_report_format_amount }}</div>
<div>{{ line_item.date|accounting_format_date }}</div>
<div>{{ line_item.account.title|title }}</div>
<div>{{ line_item.description|accounting_default }}</div>
<div class="accounting-amount">{{ line_item.income|accounting_format_amount|accounting_default }}</div>
<div class="accounting-amount">{{ line_item.expense|accounting_format_amount|accounting_default }}</div>
<div class="accounting-amount {% if line_item.balance < 0 %} text-danger {% endif %}">{{ line_item.balance|accounting_report_format_amount }}</div>

View File

@ -20,31 +20,31 @@ Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/3/5
#}
<div>
{% if entry.date or entry.account %}
{% if line_item.date or line_item.account %}
<div class="text-muted small">
{% if entry.date %}
{{ entry.date|accounting_format_date }}
{% if line_item.date %}
{{ line_item.date|accounting_format_date }}
{% endif %}
{% if entry.account %}
{{ entry.account.title|title }}
{% if line_item.account %}
{{ line_item.account.title|title }}
{% endif %}
</div>
{% endif %}
{% if entry.summary %}
<div>{{ entry.summary }}</div>
{% if line_item.description %}
<div>{{ line_item.description }}</div>
{% endif %}
</div>
<div class="text-nowrap">
{% if entry.income %}
<span class="badge rounded-pill bg-success">+{{ entry.income|accounting_format_amount }}</span>
{% if line_item.income %}
<span class="badge rounded-pill bg-success">+{{ line_item.income|accounting_format_amount }}</span>
{% endif %}
{% if entry.expense %}
<span class="badge rounded-pill bg-warning">-{{ entry.expense|accounting_format_amount }}</span>
{% if line_item.expense %}
<span class="badge rounded-pill bg-warning">-{{ line_item.expense|accounting_format_amount }}</span>
{% endif %}
{% if entry.balance < 0 %}
<span class="badge rounded-pill bg-danger">{{ entry.balance|accounting_format_amount }}</span>
{% if line_item.balance < 0 %}
<span class="badge rounded-pill bg-danger">{{ line_item.balance|accounting_format_amount }}</span>
{% else %}
<span class="badge rounded-pill bg-primary">{{ entry.balance|accounting_format_amount }}</span>
<span class="badge rounded-pill bg-primary">{{ line_item.balance|accounting_format_amount }}</span>
{% endif %}
</div>

View File

@ -19,10 +19,10 @@ ledger-row-desktop.html: The row in the ledger for the desktop computers
Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/3/8
#}
<div>{{ entry.date|accounting_format_date }}</div>
<div>{{ entry.summary|accounting_default }}</div>
<div class="accounting-amount">{{ entry.debit|accounting_format_amount|accounting_default }}</div>
<div class="accounting-amount">{{ entry.credit|accounting_format_amount|accounting_default }}</div>
<div>{{ line_item.date|accounting_format_date }}</div>
<div>{{ line_item.description|accounting_default }}</div>
<div class="accounting-amount">{{ line_item.debit|accounting_format_amount|accounting_default }}</div>
<div class="accounting-amount">{{ line_item.credit|accounting_format_amount|accounting_default }}</div>
{% if report.account.is_real %}
<div class="accounting-amount {% if entry.balance < 0 %} text-danger {% endif %}">{{ entry.balance|accounting_report_format_amount }}</div>
<div class="accounting-amount {% if line_item.balance < 0 %} text-danger {% endif %}">{{ line_item.balance|accounting_report_format_amount }}</div>
{% endif %}

View File

@ -20,24 +20,24 @@ Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/3/5
#}
<div>
{% if entry.date %}
{% if line_item.date %}
<div class="text-muted small">
{{ entry.date|accounting_format_date }}
{{ line_item.date|accounting_format_date }}
</div>
{% endif %}
{% if entry.summary %}
<div>{{ entry.summary }}</div>
{% if line_item.description %}
<div>{{ line_item.description }}</div>
{% endif %}
</div>
<div>
{% if entry.debit %}
<span class="badge rounded-pill bg-success">+{{ entry.debit|accounting_format_amount }}</span>
{% if line_item.debit %}
<span class="badge rounded-pill bg-success">+{{ line_item.debit|accounting_format_amount }}</span>
{% endif %}
{% if entry.credit %}
<span class="badge rounded-pill bg-warning">-{{ entry.credit|accounting_format_amount }}</span>
{% if line_item.credit %}
<span class="badge rounded-pill bg-warning">-{{ line_item.credit|accounting_format_amount }}</span>
{% endif %}
{% if report.account.is_real %}
<span class="badge rounded-pill bg-primary">{{ entry.balance|accounting_format_amount }}</span>
<span class="badge rounded-pill bg-primary">{{ line_item.balance|accounting_format_amount }}</span>
{% endif %}
</div>

View File

@ -55,14 +55,14 @@ First written: 2023/3/4
<div id="accounting-period-chooser-month-page" {% if report.period.is_type_month %} aria-current="page" {% else %} class="d-none" aria-current="false" {% endif %} aria-labelledby="accounting-period-chooser-month-tab">
<div>
<a class="btn {% if report.period.is_this_month %} btn-primary {% else %} btn-outline-primary {% endif %}" role="button" href="{{ report.period_chooser.this_month_url }}">
{{ A_("This month") }}
{{ A_("This Month") }}
</a>
{% if report.period_chooser.has_last_month %}
<a class="btn {% if report.period.is_last_month %} btn-primary {% else %} btn-outline-primary {% endif %}" role="button" href="{{ report.period_chooser.last_month_url }}">
{{ A_("Last month") }}
{{ A_("Last Month") }}
</a>
<a class="btn {% if report.period.is_since_last_month %} btn-primary {% else %} btn-outline-primary {% endif %}" role="button" href="{{ report.period_chooser.since_last_month_url }}">
{{ A_("Since last month") }}
{{ A_("Since Last Month") }}
</a>
{% endif %}
</div>
@ -74,11 +74,11 @@ First written: 2023/3/4
{# The year periods #}
<div id="accounting-period-chooser-year-page" {% if report.period.is_a_year %} aria-current="page" {% else %} class="d-none" aria-current="false" {% endif %} aria-labelledby="accounting-period-chooser-year-tab">
<a class="btn {% if report.period.is_this_year %} btn-primary {% else %} btn-outline-primary {% endif %}" role="button" href="{{ report.period_chooser.this_year_url }}">
{{ A_("This year") }}
{{ A_("This Year") }}
</a>
{% if report.period_chooser.has_last_year %}
<a class="btn {% if report.period.is_last_year %} btn-primary {% else %} btn-outline-primary {% endif %}" role="button" href="{{ report.period_chooser.last_year_url }}">
{{ A_("Last year") }}
{{ A_("Last Year") }}
</a>
{% endif %}
{% if report.period_chooser.available_years %}

View File

@ -27,17 +27,17 @@ First written: 2023/3/8
</button>
<ul class="dropdown-menu">
<li>
<a class="dropdown-item" href="{{ url_for("accounting.transaction.create", txn_type=report.txn_types.CASH_EXPENSE)|accounting_append_next }}">
{{ A_("Cash Expense") }}
<a class="dropdown-item" href="{{ url_for("accounting.journal-entry.create", journal_entry_type=report.journal_entry_types.CASH_DISBURSEMENT)|accounting_append_next }}">
{{ A_("Cash Disbursement") }}
</a>
</li>
<li>
<a class="dropdown-item" href="{{ url_for("accounting.transaction.create", txn_type=report.txn_types.CASH_INCOME)|accounting_append_next }}">
{{ A_("Cash Income") }}
<a class="dropdown-item" href="{{ url_for("accounting.journal-entry.create", journal_entry_type=report.journal_entry_types.CASH_RECEIPT)|accounting_append_next }}">
{{ A_("Cash Receipt") }}
</a>
</li>
<li>
<a class="dropdown-item" href="{{ url_for("accounting.transaction.create", txn_type=report.txn_types.TRANSFER)|accounting_append_next }}">
<a class="dropdown-item" href="{{ url_for("accounting.journal-entry.create", journal_entry_type=report.journal_entry_types.TRANSFER)|accounting_append_next }}">
{{ A_("Transfer") }}
</a>
</li>
@ -45,11 +45,11 @@ First written: 2023/3/8
</div>
{% endif %}
<div class="btn-group" role="group">
<button class="btn btn-primary dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
<button id="accounting-choose-report" class="btn btn-primary dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
<i class="fa-solid fa-book"></i>
<span class="d-none d-md-inline">{{ report.report_chooser.current_report }}</span>
<span class="d-none d-md-inline">{{ A_("Report") }}</span>
</button>
<ul class="dropdown-menu" aria-label="{{ A_("Report") }}">
<ul class="dropdown-menu" aria-labelledby="accounting-choose-report">
{% for report in report.report_chooser %}
<li>
<a class="dropdown-item {% if report.is_active %} active {% endif %}" href="{{ report.url }}">
@ -68,11 +68,11 @@ First written: 2023/3/8
</div>
{% if use_currency_chooser %}
<div class="btn-group" role="group">
<button class="btn btn-primary dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
<button id="accounting-choose-currency" class="btn btn-primary dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
<i class="fa-solid fa-money-bill-wave"></i>
<span class="d-none d-md-inline">{{ report.currency.name|title }}</span>
<span class="d-none d-md-inline">{{ A_("Currency") }}</span>
</button>
<ul class="dropdown-menu" aria-label="{{ A_("Currency") }}">
<ul class="dropdown-menu" aria-labelledby="accounting-choose-currency">
{% for currency in report.currency_options %}
<li>
<a class="dropdown-item {% if currency.is_active %} active {% endif %}" href="{{ currency.url }}">
@ -85,11 +85,11 @@ First written: 2023/3/8
{% endif %}
{% if use_account_chooser %}
<div class="btn-group" role="group">
<button class="btn btn-primary dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
<button id="accounting-choose-account" class="btn btn-primary dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
<i class="fa-solid fa-clipboard"></i>
<span class="d-none d-md-inline">{{ report.account.title|title }}</span>
<span class="d-none d-md-inline">{{ A_("Account") }}</span>
</button>
<ul class="dropdown-menu" aria-label="{{ A_("Account") }}">
<ul class="dropdown-menu" aria-labelledby="accounting-choose-account">
{% for account in report.account_options %}
<li>
<a class="dropdown-item {% if account.is_active %} active {% endif %}" href="{{ account.url }}">
@ -103,7 +103,7 @@ First written: 2023/3/8
{% if use_period_chooser %}
<button class="btn btn-primary" type="button" data-bs-toggle="modal" data-bs-target="#accounting-period-chooser-modal">
<i class="fa-solid fa-calendar-day"></i>
<span class="d-none d-md-inline">{{ report.period.desc|title }}</span>
<span class="d-none d-md-inline">{{ A_("Period") }}</span>
</button>
{% endif %}
{% if report.has_data %}

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