# The Mia! Accounting 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.selectable_debit() if not (x.code[0] == "2" and x.is_need_offset)] in_use: set[int] = set(db.session.scalars( sa.select(JournalEntryLineItem.account_id) .filter(JournalEntryLineItem.is_debit) .group_by(JournalEntryLineItem.account_id)).all()) for account in accounts: account.is_in_use = account.id in in_use return accounts @property def credit_account_options(self) -> list[AccountOption]: """The selectable credit accounts. :return: The selectable credit accounts. """ accounts: list[AccountOption] \ = [AccountOption(x) for x in Account.selectable_credit() if not (x.code[0] == "1" and x.is_need_offset)] in_use: set[int] = set(db.session.scalars( sa.select(JournalEntryLineItem.account_id) .filter(sa.not_(JournalEntryLineItem.is_debit)) .group_by(JournalEntryLineItem.account_id)).all()) for account in accounts: account.is_in_use = account.id in in_use return accounts @property def currencies_errors(self) -> list[str | LazyString]: """Returns the currency errors, without the errors in their sub-forms. :return: The currency errors, without the errors in their sub-forms. """ return [x for x in self.currencies.errors if isinstance(x, str) or isinstance(x, LazyString)] @property def description_editor(self) -> DescriptionEditor: """Returns the description editor. :return: The description editor. """ return DescriptionEditor() @property def original_line_item_options(self) -> list[JournalEntryLineItem]: """Returns the selectable original line items. :return: The selectable original line items. """ if self.__original_line_item_options is None: self.__original_line_item_options \ = get_selectable_original_line_items( {x.id.data for x in self.line_items if x.id.data is not None}, self._is_need_payable, self._is_need_receivable) return self.__original_line_item_options @property def min_date(self) -> dt.date | None: """Returns the minimal available date. :return: The minimal available date. """ original_line_item_id: set[int] \ = {x.original_line_item_id.data for x in self.line_items if x.original_line_item_id.data is not None} if len(original_line_item_id) == 0: return None select: sa.Select = sa.select(sa.func.max(JournalEntry.date))\ .join(JournalEntryLineItem)\ .filter(JournalEntryLineItem.id.in_(original_line_item_id)) return db.session.scalar(select) @property def max_date(self) -> dt.date | None: """Returns the maximum available date. :return: The maximum available date. """ line_item_id: set[int] = {x.id.data for x in self.line_items if x.id.data is not None} select: sa.Select = sa.select(sa.func.min(JournalEntry.date))\ .join(JournalEntryLineItem)\ .filter(JournalEntryLineItem.original_line_item_id .in_(line_item_id)) return db.session.scalar(select) 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