Added the recurring transactions.
This commit is contained in:
		| @@ -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: | ||||
|   | ||||
| @@ -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. | ||||
|      * | ||||
|   | ||||
| @@ -156,7 +156,11 @@ First written: 2023/2/28 | ||||
|  | ||||
|           {# 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"> | ||||
|             {# TODO: To be done #} | ||||
|             {% 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> | ||||
|  | ||||
|           {# The annotation #} | ||||
|   | ||||
		Reference in New Issue
	
	Block a user