Added the recurring transactions.
This commit is contained in:
parent
5dccf99a55
commit
3455827c09
@ -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 #}
|
||||
|
@ -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
|
||||
|
Loading…
Reference in New Issue
Block a user