diff --git a/src/accounting/journal_entry/utils/description_editor.py b/src/accounting/journal_entry/utils/description_editor.py index 6d100f1..7063aa0 100644 --- a/src/accounting/journal_entry/utils/description_editor.py +++ b/src/accounting/journal_entry/utils/description_editor.py @@ -17,9 +17,11 @@ """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, JournalEntryLineItem @@ -143,6 +145,29 @@ class DescriptionType: return sorted(self.__tag_dict.values(), key=lambda x: -x.freq) +class DescriptionRecurring: + """A recurring transaction.""" + + def __init__(self, name: str, template: str, account: Account): + """Constructs a recurring transaction. + + :param name: The name. + :param template: The template. + :param account: The account. + """ + 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.""" @@ -163,6 +188,8 @@ class DescriptionDebitCredit: 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: @@ -193,6 +220,10 @@ class DescriptionDebitCredit: 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])] @@ -207,6 +238,7 @@ class DescriptionEditor: self.credit: DescriptionDebitCredit = DescriptionDebitCredit("credit") """The credit tags.""" self.__init_tags() + self.__init_recurring() def __init_tags(self): """Initializes the tags. @@ -243,6 +275,49 @@ class DescriptionEditor: 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 "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["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: diff --git a/src/accounting/static/js/description-editor.js b/src/accounting/static/js/description-editor.js index acec5ff..8fc2449 100644 --- a/src/accounting/static/js/description-editor.js +++ b/src/accounting/static/js/description-editor.js @@ -32,7 +32,7 @@ class DescriptionEditor { * The line item editor * @type {JournalEntryLineItemEditor} */ - #lineItemEditor; + lineItemEditor; /** * The description editor form @@ -113,7 +113,7 @@ class DescriptionEditor { * @param debitCredit {string} either "debit" or "credit" */ constructor(lineItemEditor, debitCredit) { - this.#lineItemEditor = lineItemEditor; + this.lineItemEditor = lineItemEditor; this.debitCredit = debitCredit; this.prefix = "accounting-description-editor-" + debitCredit; this.#form = document.getElementById(this.prefix); @@ -132,7 +132,7 @@ class DescriptionEditor { this.currentTab = this.tabPlanes.general; this.#initializeSuggestedAccounts(); this.description.onchange = () => this.#onDescriptionChange(); - this.#offsetButton.onclick = () => this.#lineItemEditor.originalLineItemSelector.onOpen(); + this.#offsetButton.onclick = () => this.lineItemEditor.originalLineItemSelector.onOpen(); this.#form.onsubmit = () => { if (this.currentTab.validate()) { this.#submit(); @@ -147,7 +147,7 @@ class DescriptionEditor { */ #onDescriptionChange() { this.description.value = this.description.value.trim(); - for (const tabPlane of [this.tabPlanes.bus, this.tabPlanes.travel, this.tabPlanes.general]) { + 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 DescriptionEditor { /** * 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); } /** @@ -215,9 +219,9 @@ class DescriptionEditor { #submit() { bootstrap.Modal.getOrCreateInstance(this.#modal).hide(); if (this.#selectedAccount !== null) { - this.#lineItemEditor.saveDescriptionWithAccount(this.description.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.#lineItemEditor.saveDescription(this.description.value); + this.lineItemEditor.saveDescription(this.description.value); } } @@ -227,7 +231,7 @@ class DescriptionEditor { */ onOpen() { this.#reset(); - this.description.value = this.#lineItemEditor.description === null? "": this.#lineItemEditor.description; + this.description.value = this.lineItemEditor.description === null? "": this.lineItemEditor.description; this.#onDescriptionChange(); } @@ -418,7 +422,7 @@ class TagTabPlane extends TabPlane { } } if (!isMatched) { - this.editor.filterSuggestedAccounts(null); + this.editor.clearSuggestedAccounts(); } this.validateTag(); } @@ -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(); } /** @@ -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; } @@ -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; } @@ -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; } @@ -992,10 +973,16 @@ class BusTripTab extends TagTabPlane { class RecurringTransactionTab extends TabPlane { /** - * The transaction buttons + * The month names + * @type {string[]} + */ + #monthNames; + + /** + * The buttons of the recurring items * @type {HTMLButtonElement[]} */ - #transactions; + #itemButtons; // noinspection JSValidateTypes /** @@ -1006,8 +993,44 @@ class RecurringTransactionTab extends TabPlane { */ 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.#transactions = Array.from(document.getElementsByClassName(this.prefix + "-transaction")); + 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]); } /** @@ -1026,9 +1049,9 @@ class RecurringTransactionTab extends TabPlane { * @override */ reset() { - for (const transaction of this.#transactions) { - transaction.classList.remove("btn-primary"); - transaction.classList.add("btn-outline-primary"); + for (const itemButton of this.#itemButtons) { + itemButton.classList.remove("btn-primary"); + itemButton.classList.add("btn-outline-primary"); } } @@ -1039,9 +1062,32 @@ class RecurringTransactionTab extends TabPlane { * @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. * diff --git a/src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html b/src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html index 4a58832..0033cd5 100644 --- a/src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html +++ b/src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html @@ -156,7 +156,11 @@ First written: 2023/2/28 {# A recurring transaction #}
- {# TODO: To be done #} + {% for recurring in description_editor.recurring %} + + {% endfor %}
{# The annotation #} diff --git a/tests/test_site/__init__.py b/tests/test_site/__init__.py index 182d312..fb82e9d 100644 --- a/tests/test_site/__init__.py +++ b/tests/test_site/__init__.py @@ -50,6 +50,16 @@ def create_app(is_testing: bool = False) -> Flask: "SQLALCHEMY_DATABASE_URI": db_uri, "BABEL_DEFAULT_LOCALE": "en", "ALL_LINGUAS": "zh_Hant|正體中文,en|English,zh_Hans|简体中文", + "RECURRING": ( + "debit|1314-001|Pension|Pension for {last_month_name}," + "debit|6262-001|Health insurance" + "|Health insurance for {last_month_name}," + "debit|6261-001|Electricity bill" + "|Electricity bill for {last_bimonthly_name}," + "debit|6261-001|Water bill|Water bill for {last_bimonthly_name}," + "debit|6261-001|Gas bill|Gas bill for {last_bimonthly_name}," + "debit|6261-001|Phone bill|Phone bill for {last_month_name}," + "credit|4611-001|Payroll|Payroll for {last_month_name}"), }) if is_testing: app.config["TESTING"] = True