Added the recurring transactions.

This commit is contained in:
依瑪貓 2023-03-21 19:15:43 +08:00
parent 5dccf99a55
commit 3455827c09
4 changed files with 187 additions and 52 deletions

View File

@ -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:

View File

@ -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.
*

View File

@ -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 #}

View File

@ -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