79 Commits

Author SHA1 Message Date
c50b9a2000 Removed the unused tab and page classes from the templates of the summary editor. 2023-03-04 11:52:45 +08:00
af9bd14eed Removed the unused tab ID from the template of the summary editor. 2023-03-04 11:52:45 +08:00
9e1ff16e96 Fixed the aria-labelledby in the template of the summary editor. 2023-03-04 11:52:45 +08:00
f7c1fd77f2 Added blank lines and documentation to the template of the summary editor. 2023-03-04 11:52:45 +08:00
641315537d Replaced the traditional function expressions with ES6 arrow function expressions in the JavaScript for the transaction order, account order, currency form, and the speed dial for the material floating action buttons. 2023-03-04 11:52:45 +08:00
a895bd8560 Replaced the traditional function expressions with ES6 arrow function expressions in the JavaScript for the drop-and-drop reorder. 2023-03-04 11:52:44 +08:00
ca86a08f3e Replaced the traditional function expressions with ES6 arrow function expressions in the JavaScript for the account form. 2023-03-04 11:52:44 +08:00
e118422441 Replaced the traditional function expressions with ES6 arrow function expressions in the JavaScript for the account selector. 2023-03-04 11:52:44 +08:00
b3777cffbf Replaced the traditional function expressions with ES6 arrow function expressions in the JavaScript for the transaction form. 2023-03-04 11:52:44 +08:00
39c9c17007 Replaced the traditional function expressions with ES6 arrow function expressions in the JavaScript for the summary editor to avoid messing up with the "this" object. 2023-03-04 11:52:44 +08:00
3ab4eacf9f Moved the "accounting.transaction.template_globals" module to "accounting.template_globals", for the two template globals will be used in the reports beside the transaction management. 2023-03-04 07:06:03 +08:00
cff3d1b6bd Revised the code order in the init_app function in the "accounting" module. 2023-03-04 07:01:03 +08:00
f41db78831 Revised the summary helper so that when the summary is changed with the tag changed, the on-change callback is run to check the tag button status. 2023-03-04 07:01:03 +08:00
73f7d14e7b Fixed so that the values of the input fields are trimmed before composing the summary when they are changed. 2023-03-04 07:01:03 +08:00
f6ed6b10a7 Revised the summary editor to allow the "*" start character as the multiplication operation in addition to the "×" times character. 2023-03-04 07:01:03 +08:00
b5aaee4d15 Renamed the "number" tab plane to "annotation". 2023-03-04 07:01:03 +08:00
c849d6b3d4 Revised the numer tab plane in the summary editor. 2023-03-04 07:01:03 +08:00
a9908a7df4 Simplified the regular expression in the populate method of the GeneralTagTab class in the summary editor. 2023-03-04 07:01:02 +08:00
063c769158 Renamed the variables in the summary editor. 2023-03-04 07:01:02 +08:00
f8e9871300 Fixed to trim the summary when it is changed in the summary editor. 2023-03-04 07:01:02 +08:00
78a62a9575 Added a "note" field to the summary editor. 2023-03-04 07:01:02 +08:00
85fde6219e Fixed an HTML ID in the summary editor modal. 2023-03-04 07:01:02 +08:00
4eb9346d8d Renamed summary helper to summary editor. 2023-03-04 07:00:46 +08:00
11966a52ba Fixed a variable name in the #initializeAccountQuery method of the JavaScript AccountSelector class. 2023-03-04 06:57:35 +08:00
8cf81b5459 Revised the documentation of the "accounting.account.queries", "accounting.base_account.queries", "accounting.currency.queries", and "accounting.transaction.queries" modules. 2023-03-04 06:57:35 +08:00
cc958a39b3 Moved the format_amount and format_date template filters from the "accounting.transaction.template_filters" module to the "accounting.template_filters" module, and rename the filters from "accounting_txn_format_amount" and "accounting_txn_format_date" to "accounting_format_amount" and "accounting_format_date", respectively. They will not only be used in the transaction management, but also the reports. 2023-03-04 06:57:10 +08:00
9065686cc5 Split the "accounting.transaction.template" module into the "accounting.transaction.template_filters" and "accounting.transaction.template_globals" modules. 2023-03-03 18:28:59 +08:00
9a41cb10a1 Rewrote the summary helper, added the TabPlane classes so that the internal states of the summary helper is stored in the tab plane objects instead of passing the as parameters and variables. 2023-03-03 18:09:36 +08:00
6957e52d0d Renamed the "accounting.account.query", "accounting.base_account.query", "accounting.currency.query", and "accounting.transaction.query" modules to "accounting.account.queries", "accounting.base_account.queries", "accounting.currency.queries", and "accounting.transaction.queries", respectively. There will be more than one query in the next report module. 2023-03-03 01:12:36 +08:00
9cd9e90be0 Renamed the variables in the tests of the AccountTestCase and CurrencyTestCase test cases, for simplicity. 2023-03-01 21:09:14 +08:00
2839dc60b4 Revised the documentation of the PREFIX constant in test_account.py, test_currency.py, and test_transaction.py. 2023-03-01 20:22:27 +08:00
f3548a2327 Advanced to version 0.4.0. 2023-03-01 01:49:08 +08:00
79883d6940 Changed the Sphinx documentation scheme from "nature" to "sphinx_rtd_theme", to prepare for publishing in the future. 2023-03-01 01:48:56 +08:00
b2bc993416 Replaced the #populate method with the #parseAndPopulate method that is used both when starting the summary helper and when the summary input is updated. 2023-03-01 01:45:38 +08:00
453b3f0da5 Renamed the #tagInputOnChange method to #onTagInputChange in the JavaScript summary helper. 2023-03-01 01:31:25 +08:00
63ae3f0746 Replace the is_in_use pseudo property of the Account data model with the AccountOption class, and revised the #getAccountCodeUsedInForm method of the SummaryHelper, to solve the issue that the list of used accounts should be different for debit and credit entries. 2023-03-01 01:28:25 +08:00
da4cc6489f Removed the direction arrows from the tab navigation in the summary helper. 2023-03-01 00:59:40 +08:00
1102a3a4f3 Updated the translation. 2023-03-01 00:51:58 +08:00
1402a12f04 Simplified the logic in the add_txn function in testlib_txn.py. 2023-03-01 00:51:24 +08:00
f049b5d7ee Revised the form data used in the SummeryHelperTestCase test case, to avoid problems with SonarQube. 2023-03-01 00:51:24 +08:00
14ed4ca354 Added the #initializeTagButtons and #tagInputOnChange methods to the JavaScript SummaryHelper to simplify the code. 2023-03-01 00:51:24 +08:00
535ff96ab3 Revised the JavaScript regular expressions used in the summary helper, as suggested by SonarQube for security. 2023-03-01 00:51:24 +08:00
57482f81fc Revised the transaction form to start a new journal entry with the journal entry form instead of the summary helper, because it feels strange when the user want to leave the summary empty. 2023-03-01 00:51:24 +08:00
a31ce3c400 Replaced the function-based JavaScript account selector with the AccountSelector class that does things better. 2023-03-01 00:51:11 +08:00
319f0aed90 Fixed a documentation in the JavaScript summary helper. 2023-02-28 22:54:20 +08:00
826dcf0f86 Revised the documentation of the JavaScript for the summary helper. 2023-02-28 22:47:04 +08:00
b2411aee74 Updated the Sphinx documentation. 2023-02-28 22:44:40 +08:00
731acdced0 Revised the HTML in the summary helper template. 2023-02-28 22:41:56 +08:00
35b3bca1e6 Renamed the variables for the button elements in the summary helper, to be clear. 2023-02-28 22:37:46 +08:00
3c413497ae Split the JavaScript for the account selector from transaction-form.js to account-selector.js, to modularize the complex JavaScript. 2023-02-28 22:33:14 +08:00
1b5e516413 Renamed the HTML ID and class name prefix of the account selector modal, for consistency. 2023-02-28 22:24:12 +08:00
20cb5cecc4 Renamed the accounting-selector-modal class to accounting-account-selector-modal in the account selector. 2023-02-28 22:14:03 +08:00
08dc24605d Replaced the forEach loops with the for-of loops in the JavaScript for the currency form, account form, and the drag-and-drop reorder library functions. 2023-02-28 22:09:39 +08:00
bb7e9e94ee Replaced the forEach loops with the for-of loops whenever appropriate in the JavaScript for the transaction form. 2023-02-28 22:00:19 +08:00
2680a1c872 Merged debit-account-modal.html and credit-account-modal.html into account-selector-modal.html, because they are almost the same. 2023-02-28 21:45:10 +08:00
20a7ce591c Renamed the account_selector_modals block to form_modals in the transaction form templates. 2023-02-28 21:37:08 +08:00
474e844ed9 Revised the loading of the summary helper so that only the required helpers are loaded, but not both the debit and credit helpers. 2023-02-28 21:35:02 +08:00
b34955f2fb Replaced the forEach loops with the for-of loops in the JavaScript summary helper. The for-of loops are more consistent with the other languages and the traditional for loops, and do not mess up with the "this" object. 2023-02-28 20:20:36 +08:00
2bd0f0f14d Fixed the target in the initShow method of the JavaScript summary helper. 2023-02-28 19:13:08 +08:00
8b77d9ff93 Added the suggested accounts to the summary helper. 2023-02-28 19:11:09 +08:00
a9c7360020 Renamed the variables in the #reset method of the JavaScript SummaryHelper class, for consistency. 2023-02-28 17:14:02 +08:00
d02c87602b Added validation to the summary helper. 2023-02-28 16:38:50 +08:00
9f966643b5 Added ARIA labels to the different pages in the summary helper. 2023-02-28 16:38:19 +08:00
5746e2a3d6 Added a missing amount filter to the debit entries of the transaction form. 2023-02-28 15:52:30 +08:00
d5c2231794 Added the summary helper for the transaction form. 2023-02-28 15:49:01 +08:00
fc8e257a10 Added missing documentation to the currencies_errors pseudo property of the TransactionForm form. 2023-02-28 09:36:20 +08:00
2e9bf382fb Revised the documentation of the "accounting.transaction.dispatcher" module. 2023-02-28 09:31:46 +08:00
de48c848da Revised the code in the common account shorts in testlib_txn.py. 2023-02-28 08:24:15 +08:00
9cdcc828a7 Added the add_txn function to testlib_txn.py and applied it in the transaction test cases. 2023-02-28 08:14:23 +08:00
b28d446d07 Advanced to version 0.3.1. 2023-02-28 00:16:20 +08:00
274a38a588 Fixed a localization error in the transaction detail. 2023-02-28 00:16:12 +08:00
fff89a9957 Replaced the direct database add with the relationship append in the JournalEntryCollector class, to fix the PostgreSQL error that the new journal entries are added when the transaction is not added yet. 2023-02-28 00:04:32 +08:00
5613657c8f Fixed the JavaScript filterAccountOptions function in the transaction form so that the accounting list is not hidden when there is no account in use. 2023-02-27 23:00:49 +08:00
26bb16dd40 Revised the translation. 2023-02-27 18:59:50 +08:00
f0d39bb27b Added the action button to convert a cash income or cash expense transaction to a transfer transaction. 2023-02-27 18:59:42 +08:00
4c17310ebf Fixed an error to recognize the current transaction type in the supplied URI in the with_type filter in the "accounting.transaction.template" module. 2023-02-27 18:47:19 +08:00
fd36672877 Revised the imports in the "accounting.transaction.views" module. 2023-02-27 18:44:33 +08:00
d67c57056b Added the accounting_txn_format_amount_input template filter to properly format the decimal amount for the number input fields. 2023-02-27 18:40:54 +08:00
59c55ef574 Fixed the amount display in the template of the journal entry sub-form. 2023-02-27 18:34:02 +08:00
52 changed files with 2968 additions and 791 deletions

View File

@ -36,6 +36,14 @@ accounting.transaction.query module
:undoc-members:
:show-inheritance:
accounting.transaction.summary\_helper module
---------------------------------------------
.. automodule:: accounting.transaction.summary_helper
:members:
:undoc-members:
:show-inheritance:
accounting.transaction.template module
--------------------------------------

View File

@ -13,7 +13,7 @@ sys.path.insert(0, os.path.abspath('../../src/'))
project = 'Mia! Accounting Flask'
copyright = '2023, imacat'
author = 'imacat'
release = '0.0.0'
release = '0.4.0'
# -- General configuration ---------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration
@ -28,5 +28,5 @@ exclude_patterns = []
# -- Options for HTML output -------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output
html_theme = 'nature'
html_theme = 'sphinx_rtd_theme'
html_static_path = ['_static']

View File

@ -17,7 +17,7 @@
[metadata]
name = mia-accounting-flask
version = 0.3.0
version = 0.4.0
author = imacat
author_email = imacat@mail.imacat.idv.tw
description = The Mia! Accounting Flask project.

View File

@ -58,12 +58,25 @@ def init_app(app: Flask, user_utils: AbstractUserUtils,
template_folder="templates",
static_folder="static")
from .template_filters import format_amount, format_date
bp.add_app_template_filter(format_amount, "accounting_format_amount")
bp.add_app_template_filter(format_date, "accounting_format_date")
from .template_globals import currency_options, default_currency_code
bp.add_app_template_global(currency_options,
"accounting_currency_options")
bp.add_app_template_global(default_currency_code,
"accounting_default_currency_code")
from . import locale
locale.init_app(app, bp)
from .utils import permission
permission.init_app(bp, can_view_func, can_edit_func)
from .utils import next_uri
next_uri.init_app(bp)
from . import base_account
base_account.init_app(app, bp)
@ -76,7 +89,4 @@ def init_app(app: Flask, user_utils: AbstractUserUtils,
from . import transaction
transaction.init_app(app, bp)
from .utils import next_uri
next_uri.init_app(bp)
app.register_blueprint(bp)

View File

@ -14,7 +14,7 @@
# 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 account query.
"""The queries for the account management.
"""
import sqlalchemy as sa

View File

@ -33,7 +33,7 @@ from accounting.utils.pagination import Pagination
from accounting.utils.permission import can_view, has_permission, can_edit
from accounting.utils.user import get_current_user_pk
from .forms import AccountForm, sort_accounts_in, AccountReorderForm
from .query import get_account_query
from .queries import get_account_query
bp: Blueprint = Blueprint("account", __name__)
"""The view blueprint for the account management."""

View File

@ -14,7 +14,7 @@
# 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 base account query.
"""The queries for the base account management.
"""
import sqlalchemy as sa

View File

@ -34,7 +34,7 @@ def list_accounts() -> str:
:return: The account list.
"""
from .query import get_base_account_query
from .queries import get_base_account_query
accounts: list[BaseAccount] = get_base_account_query()
pagination: Pagination = Pagination[BaseAccount](accounts)
return render_template("accounting/base-account/list.html",

View File

@ -14,7 +14,7 @@
# 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 currency query.
"""The queries for the currency management.
"""
import sqlalchemy as sa

View File

@ -47,7 +47,7 @@ def list_currencies() -> str:
:return: The currency list.
"""
from .query import get_currency_query
from .queries import get_currency_query
currencies: list[Currency] = get_currency_query()
pagination: Pagination = Pagination[Currency](currencies)
return render_template("accounting/currency/list.html",

View File

@ -203,25 +203,6 @@ class Account(db.Model):
return
self.l10n.append(AccountL10n(locale=current_locale, title=value))
@property
def is_in_use(self) -> bool:
"""Returns whether the account is in use.
:return: True if the account is in use, or False otherwise.
"""
if not hasattr(self, "__is_in_use"):
setattr(self, "__is_in_use", len(self.entries) > 0)
return getattr(self, "__is_in_use")
@is_in_use.setter
def is_in_use(self, is_in_use: bool) -> None:
"""Sets whether the account is in use.
:param is_in_use: True if the account is in use, or False otherwise.
:return: None.
"""
setattr(self, "__is_in_use", is_in_use)
@classmethod
def find_by_code(cls, code: str) -> t.Self | None:
"""Finds an account by its code.

View File

@ -22,7 +22,7 @@
*/
// Initializes the page JavaScript.
document.addEventListener("DOMContentLoaded", function () {
document.addEventListener("DOMContentLoaded", () => {
initializeBaseAccountSelector();
document.getElementById("accounting-base-code")
.onchange = validateBase;
@ -44,23 +44,23 @@ function initializeBaseAccountSelector() {
const baseContent = document.getElementById("accounting-base-content");
const options = Array.from(document.getElementsByClassName("accounting-base-option"));
const btnClear = document.getElementById("accounting-btn-clear-base");
selector.addEventListener("show.bs.modal", function () {
selector.addEventListener("show.bs.modal", () => {
base.classList.add("accounting-not-empty");
options.forEach(function (item) {
item.classList.remove("active");
});
for (const option of options) {
option.classList.remove("active");
}
const selected = document.getElementById("accounting-base-option-" + baseCode.value);
if (selected !== null) {
selected.classList.add("active");
}
});
selector.addEventListener("hidden.bs.modal", function () {
selector.addEventListener("hidden.bs.modal", () => {
if (baseCode.value === "") {
base.classList.remove("accounting-not-empty");
}
});
options.forEach(function (option) {
option.onclick = function () {
for (const option of options) {
option.onclick = () => {
baseCode.value = option.dataset.code;
baseContent.innerText = option.dataset.content;
btnClear.classList.add("btn-danger");
@ -69,8 +69,8 @@ function initializeBaseAccountSelector() {
validateBase();
bootstrap.Modal.getInstance(selector).hide();
};
});
btnClear.onclick = function () {
}
btnClear.onclick = () => {
baseCode.value = "";
baseContent.innerText = "";
btnClear.classList.add("btn-secondary")
@ -92,17 +92,17 @@ function initializeBaseAccountQuery() {
const optionList = document.getElementById("accounting-base-option-list");
const options = Array.from(document.getElementsByClassName("accounting-base-option"));
const queryNoResult = document.getElementById("accounting-base-option-no-result");
query.addEventListener("input", function () {
query.addEventListener("input", () => {
if (query.value === "") {
options.forEach(function (option) {
for (const option of options) {
option.classList.remove("d-none");
});
}
optionList.classList.remove("d-none");
queryNoResult.classList.add("d-none");
return
}
let hasAnyMatched = false;
options.forEach(function (option) {
for (const option of options) {
const queryValues = JSON.parse(option.dataset.queryValues);
let isMatched = false;
for (const queryValue of queryValues) {
@ -117,7 +117,7 @@ function initializeBaseAccountQuery() {
} else {
option.classList.add("d-none");
}
});
}
if (!hasAnyMatched) {
optionList.classList.add("d-none");
queryNoResult.classList.remove("d-none");

View File

@ -22,10 +22,10 @@
*/
// Initializes the page JavaScript.
document.addEventListener("DOMContentLoaded", function () {
document.addEventListener("DOMContentLoaded", () => {
const list = document.getElementById("accounting-order-list");
if (list !== null) {
const onReorder = function () {
const onReorder = () => {
const accounts = Array.from(list.children);
for (let i = 0; i < accounts.length; i++) {
const no = document.getElementById("accounting-order-" + accounts[i].dataset.id + "-no");

View File

@ -0,0 +1,249 @@
/* The Mia! Accounting Flask Project
* transaction-transfer-form.js: The JavaScript for the transfer transaction form
*/
/* 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.
*/
/* Author: imacat@mail.imacat.idv.tw (imacat)
* First written: 2023/2/28
*/
// Initializes the page JavaScript.
document.addEventListener("DOMContentLoaded", () => {
AccountSelector.initialize();
});
/**
* The account selector.
*
*/
class AccountSelector {
/**
* The entry type
* @type {string}
*/
#entryType;
/**
* The prefix of the HTML ID and class
* @type {string}
*/
#prefix;
/**
* Constructs an account selector.
*
* @param modal {HTMLFormElement} the account selector modal
*/
constructor(modal) {
this.#entryType = modal.dataset.entryType;
this.#prefix = "accounting-account-selector-" + modal.dataset.entryType;
this.#init();
}
/**
* Initializes the account selector.
*
*/
#init() {
const formAccountControl = document.getElementById("accounting-entry-form-account-control");
const formAccount = document.getElementById("accounting-entry-form-account");
const more = document.getElementById(this.#prefix + "-more");
const btnClear = document.getElementById(this.#prefix + "-btn-clear");
const options = Array.from(document.getElementsByClassName(this.#prefix + "-option"));
more.onclick = () => {
more.classList.add("d-none");
this.#filterAccountOptions();
};
this.#initializeAccountQuery();
btnClear.onclick = () => {
formAccountControl.classList.remove("accounting-not-empty");
formAccount.innerText = "";
formAccount.dataset.code = "";
formAccount.dataset.text = "";
validateJournalEntryAccount();
};
for (const option of options) {
option.onclick = () => {
formAccountControl.classList.add("accounting-not-empty");
formAccount.innerText = option.dataset.content;
formAccount.dataset.code = option.dataset.code;
formAccount.dataset.text = option.dataset.content;
validateJournalEntryAccount();
};
}
}
/**
* Initializes the query on the account options.
*
*/
#initializeAccountQuery() {
const query = document.getElementById(this.#prefix + "-query");
query.addEventListener("input", () => {
this.#filterAccountOptions();
});
}
/**
* Filters the account options.
*
*/
#filterAccountOptions() {
const query = document.getElementById(this.#prefix + "-query");
const optionList = document.getElementById(this.#prefix + "-option-list");
if (optionList === null) {
console.log(this.#prefix + "-option-list");
}
const options = Array.from(document.getElementsByClassName(this.#prefix + "-option"));
const more = document.getElementById(this.#prefix + "-more");
const queryNoResult = document.getElementById(this.#prefix + "-option-no-result");
const codesInUse = this.#getAccountCodeUsedInForm();
let shouldAnyShow = false;
for (const option of options) {
const shouldShow = this.#shouldAccountOptionShow(option, more, codesInUse, query);
if (shouldShow) {
option.classList.remove("d-none");
shouldAnyShow = true;
} else {
option.classList.add("d-none");
}
}
if (!shouldAnyShow && more.classList.contains("d-none")) {
optionList.classList.add("d-none");
queryNoResult.classList.remove("d-none");
} else {
optionList.classList.remove("d-none");
queryNoResult.classList.add("d-none");
}
}
/**
* Returns the account codes that are used in the form.
*
* @return {string[]} the account codes that are used in the form
*/
#getAccountCodeUsedInForm() {
const accountCodes = Array.from(document.getElementsByClassName("accounting-" + this.#prefix + "-account-code"));
const formAccount = document.getElementById("accounting-entry-form-account");
const inUse = [formAccount.dataset.code];
for (const accountCode of accountCodes) {
inUse.push(accountCode.value);
}
return inUse
}
/**
* Returns whether an account option should show.
*
* @param option {HTMLLIElement} the account option
* @param more {HTMLLIElement} the more account element
* @param inUse {string[]} the account codes that are used in the form
* @param query {HTMLInputElement} the query element, if any
* @return {boolean} true if the account option should show, or false otherwise
*/
#shouldAccountOptionShow(option, more, inUse, query) {
const isQueryMatched = () => {
if (query.value === "") {
return true;
}
const queryValues = JSON.parse(option.dataset.queryValues);
for (const queryValue of queryValues) {
if (queryValue.includes(query.value)) {
return true;
}
}
return false;
};
const isMoreMatched = () => {
if (more.classList.contains("d-none")) {
return true;
}
return option.classList.contains("accounting-account-in-use") || inUse.includes(option.dataset.code);
};
return isMoreMatched() && isQueryMatched();
}
/**
* Initializes the account selector when it is shown.
*
*/
initShow() {
const formAccount = document.getElementById("accounting-entry-form-account");
const query = document.getElementById(this.#prefix + "-query")
const more = document.getElementById(this.#prefix + "-more");
const options = Array.from(document.getElementsByClassName(this.#prefix + "-option"));
const btnClear = document.getElementById(this.#prefix + "-btn-clear");
query.value = "";
more.classList.remove("d-none");
this.#filterAccountOptions();
for (const option of options) {
if (option.dataset.code === formAccount.dataset.code) {
option.classList.add("active");
} else {
option.classList.remove("active");
}
}
if (formAccount.dataset.code === "") {
btnClear.classList.add("btn-secondary");
btnClear.classList.remove("btn-danger");
btnClear.disabled = true;
} else {
btnClear.classList.add("btn-danger");
btnClear.classList.remove("btn-secondary");
btnClear.disabled = false;
}
}
/**
* The account selectors.
* @type {{debit: AccountSelector, credit: AccountSelector}}
*/
static #selectors = {}
/**
* Initializes the account selectors.
*
*/
static initialize() {
const modals = Array.from(document.getElementsByClassName("accounting-account-selector-modal"));
for (const modal of modals) {
const selector = new AccountSelector(modal);
this.#selectors[selector.#entryType] = selector;
}
this.#initializeTransactionForm();
}
/**
* Initializes the transaction form.
*
*/
static #initializeTransactionForm() {
const entryForm = document.getElementById("accounting-entry-form");
const formAccountControl = document.getElementById("accounting-entry-form-account-control");
formAccountControl.onclick = () => this.#selectors[entryForm.dataset.entryType].initShow();
}
/**
* Initializes the account selector for the journal entry form.
*x
*/
static initializeJournalEntryForm() {
const entryForm = document.getElementById("accounting-entry-form");
const formAccountControl = document.getElementById("accounting-entry-form-account-control");
formAccountControl.dataset.bsTarget = "#accounting-account-selector-" + entryForm.dataset.entryType + "-modal";
}
}

View File

@ -22,7 +22,7 @@
*/
// Initializes the page JavaScript.
document.addEventListener("DOMContentLoaded", function () {
document.addEventListener("DOMContentLoaded", () => {
document.getElementById("accounting-code")
.onchange = validateCode;
document.getElementById("accounting-name")
@ -65,9 +65,9 @@ function validateForm() {
*/
function submitFormIfAllAsyncValid() {
let isValid = true;
Object.keys(isAsyncValid).forEach(function (key) {
for (const key of Object.keys(isAsyncValid)) {
isValid = isAsyncValid[key] && isValid;
});
}
if (isValid) {
document.getElementById("accounting-form").submit()
}

View File

@ -42,21 +42,21 @@ function initializeDragAndDropReordering(list, onReorder) {
function initializeMouseDragAndDropReordering(list, onReorder) {
const items = Array.from(list.children);
let dragged = null;
items.forEach(function (item) {
for (const item of items) {
item.draggable = true;
item.addEventListener("dragstart", function () {
item.addEventListener("dragstart", () => {
dragged = item;
dragged.classList.add("accounting-dragged");
});
item.addEventListener("dragover", function () {
item.addEventListener("dragover", () => {
onDragOver(dragged, item);
onReorder();
});
item.addEventListener("dragend", function () {
item.addEventListener("dragend", () => {
dragged.classList.remove("accounting-dragged");
dragged = null;
});
});
}
}
/**
@ -68,20 +68,20 @@ function initializeMouseDragAndDropReordering(list, onReorder) {
*/
function initializeTouchDragAndDropReordering(list, onReorder) {
const items = Array.from(list.children);
items.forEach(function (item) {
item.addEventListener("touchstart", function () {
for (const item of items) {
item.addEventListener("touchstart", () => {
item.classList.add("accounting-dragged");
});
item.addEventListener("touchmove", function (event) {
item.addEventListener("touchmove", (event) => {
const touch = event.targetTouches[0];
const target = document.elementFromPoint(touch.pageX, touch.pageY);
onDragOver(item, target);
onReorder();
});
item.addEventListener("touchend", function () {
item.addEventListener("touchend", () => {
item.classList.remove("accounting-dragged");
});
});
}
}
/**

View File

@ -22,7 +22,7 @@
*/
// Initializes the page JavaScript.
document.addEventListener("DOMContentLoaded", function () {
document.addEventListener("DOMContentLoaded", () => {
initializeMaterialFabSpeedDial();
});
@ -34,7 +34,7 @@ document.addEventListener("DOMContentLoaded", function () {
function initializeMaterialFabSpeedDial() {
const btnFab = document.getElementById("accounting-btn-material-fab-speed-dial");
const fab = document.getElementById(btnFab.dataset.target);
btnFab.onclick = function () {
btnFab.onclick = () => {
if (fab.classList.contains("show")) {
fab.classList.remove("show");
} else {

File diff suppressed because it is too large Load Diff

View File

@ -22,10 +22,9 @@
*/
// Initializes the page JavaScript.
document.addEventListener("DOMContentLoaded", function () {
document.addEventListener("DOMContentLoaded", () => {
initializeCurrencyForms();
initializeJournalEntries();
initializeAccountSelectors();
initializeFormValidation();
});
@ -69,22 +68,22 @@ function initializeCurrencyForms() {
const btnNew = document.getElementById("accounting-btn-new-currency");
const currencyList = document.getElementById("accounting-currency-list");
const deleteButtons = Array.from(document.getElementsByClassName("accounting-btn-delete-currency"));
const onReorder = function () {
const onReorder = () => {
const currencies = Array.from(currencyList.children);
for (let i = 0; i < currencies.length; i++) {
const no = document.getElementById(currencies[i].dataset.prefix + "-no");
no.value = String(i + 1);
}
};
btnNew.onclick = function () {
btnNew.onclick = () => {
const currencies = Array.from(document.getElementsByClassName("accounting-currency"));
let maxIndex = 0;
currencies.forEach(function (currency) {
for (const currency of currencies) {
const index = parseInt(currency.dataset.index);
if (maxIndex < index) {
maxIndex = index;
}
});
}
const newIndex = String(maxIndex + 1);
const html = form.dataset.currencyTemplate
.replaceAll("CURRENCY_INDEX", escapeHtml(newIndex));
@ -108,7 +107,7 @@ function initializeCurrencyForms() {
*/
function initializeBtnDeleteCurrency(button) {
const target = document.getElementById(button.dataset.target);
button.onclick = function () {
button.onclick = () => {
target.parentElement.removeChild(target);
resetDeleteCurrencyButtons();
};
@ -122,9 +121,9 @@ function initializeBtnDeleteCurrency(button) {
function resetDeleteCurrencyButtons() {
const buttons = Array.from(document.getElementsByClassName("accounting-btn-delete-currency"));
if (buttons.length > 1) {
buttons.forEach(function (button) {
for (const button of buttons) {
button.classList.remove("d-none");
});
}
} else {
buttons[0].classList.add("d-none");
}
@ -157,27 +156,32 @@ function initializeNewEntryButton(button) {
const formAccountControl = document.getElementById("accounting-entry-form-account-control");
const formAccount = document.getElementById("accounting-entry-form-account");
const formAccountError = document.getElementById("accounting-entry-form-account-error")
const formSummaryControl = document.getElementById("accounting-entry-form-summary-control");
const formSummary = document.getElementById("accounting-entry-form-summary");
const formSummaryError = document.getElementById("accounting-entry-form-summary-error");
const formAmount = document.getElementById("accounting-entry-form-amount");
const formAmountError = document.getElementById("accounting-entry-form-amount-error");
button.onclick = function () {
button.onclick = () => {
entryForm.dataset.currencyIndex = button.dataset.currencyIndex;
entryForm.dataset.entryType = button.dataset.entryType;
entryForm.dataset.entryIndex = button.dataset.entryIndex;
formAccountControl.classList.remove("accounting-not-empty")
formAccountControl.classList.remove("accounting-not-empty");
formAccountControl.classList.remove("is-invalid");
formAccountControl.dataset.bsTarget = button.dataset.accountModal;
formAccount.innerText = "";
formAccount.dataset.code = "";
formAccount.dataset.text = "";
formAccountError.innerText = "";
formSummary.value = "";
formSummary.classList.remove("is-invalid");
formSummaryControl.dataset.bsTarget = "#accounting-summary-editor-" + button.dataset.entryType + "-modal";
formSummaryControl.classList.remove("accounting-not-empty");
formSummaryControl.classList.remove("is-invalid");
formSummary.dataset.value = "";
formSummary.innerText = ""
formSummaryError.innerText = ""
formAmount.value = "";
formAmount.classList.remove("is-invalid");
formAmountError.innerText = "";
AccountSelector.initializeJournalEntryForm();
SummaryEditor.initializeNewJournalEntry(button.dataset.entryType);
};
}
@ -187,7 +191,7 @@ function initializeNewEntryButton(button) {
* @param entryList {HTMLUListElement} the journal entry list.
*/
function initializeJournalEntryListReorder(entryList) {
initializeDragAndDropReordering(entryList, function () {
initializeDragAndDropReordering(entryList, () => {
const entries = Array.from(entryList.children);
for (let i = 0; i < entries.length; i++) {
const no = document.getElementById(entries[i].dataset.prefix + "-no");
@ -209,9 +213,10 @@ function initializeJournalEntry(entry) {
const control = document.getElementById(entry.dataset.prefix + "-control");
const formAccountControl = document.getElementById("accounting-entry-form-account-control");
const formAccount = document.getElementById("accounting-entry-form-account");
const formSummaryControl = document.getElementById("accounting-entry-form-summary-control");
const formSummary = document.getElementById("accounting-entry-form-summary");
const formAmount = document.getElementById("accounting-entry-form-amount");
control.onclick = function () {
control.onclick = () => {
entryForm.dataset.currencyIndex = entry.dataset.currencyIndex;
entryForm.dataset.entryType = entry.dataset.entryType;
entryForm.dataset.entryIndex = entry.dataset.entryIndex;
@ -220,12 +225,19 @@ function initializeJournalEntry(entry) {
} else {
formAccountControl.classList.add("accounting-not-empty");
}
formAccountControl.dataset.bsTarget = entry.dataset.accountModal;
formAccount.innerText = accountCode.dataset.text;
formAccount.dataset.code = accountCode.value;
formAccount.dataset.text = accountCode.dataset.text;
formSummary.value = summary.value;
formSummaryControl.dataset.bsTarget = "#accounting-summary-editor-" + entry.dataset.entryType + "-modal";
if (summary.value === "") {
formSummaryControl.classList.remove("accounting-not-empty");
} else {
formSummaryControl.classList.add("accounting-not-empty");
}
formSummary.dataset.value = summary.value;
formSummary.innerText = summary.value;
formAmount.value = amount.value;
AccountSelector.initializeJournalEntryForm();
validateJournalEntryForm();
};
}
@ -237,40 +249,10 @@ function initializeJournalEntry(entry) {
*/
function initializeJournalEntryFormModal() {
const entryForm = document.getElementById("accounting-entry-form");
const formAccountControl = document.getElementById("accounting-entry-form-account-control");
const formAccount = document.getElementById("accounting-entry-form-account");
const formSummary = document.getElementById("accounting-entry-form-summary");
const formAmount = document.getElementById("accounting-entry-form-amount");
const modal = document.getElementById("accounting-entry-form-modal");
formAccountControl.onclick = function () {
const prefix = "accounting-" + entryForm.dataset.entryType + "-account";
const query = document.getElementById(prefix + "-selector-query")
const more = document.getElementById(prefix + "-more");
const options = Array.from(document.getElementsByClassName(prefix + "-option"));
const btnClear = document.getElementById(prefix + "-btn-clear");
query.value = "";
more.classList.remove("d-none");
filterAccountOptions(prefix);
options.forEach(function (option) {
if (option.dataset.code === formAccount.dataset.code) {
option.classList.add("active");
} else {
option.classList.remove("active");
}
});
if (formAccount.dataset.code === "") {
btnClear.classList.add("btn-secondary");
btnClear.classList.remove("btn-danger");
btnClear.disabled = true;
} else {
btnClear.classList.add("btn-danger");
btnClear.classList.remove("btn-secondary");
btnClear.disabled = false;
}
};
formSummary.onchange = validateJournalEntrySummary;
formAmount.onchange = validateJournalEntryAmount;
entryForm.onsubmit = function () {
entryForm.onsubmit = () => {
if (validateJournalEntryForm()) {
saveJournalEntryForm();
bootstrap.Modal.getInstance(modal).hide();
@ -297,7 +279,6 @@ function validateJournalEntryForm() {
* Validates the account in the journal entry form modal.
*
* @return {boolean} true if valid, or false otherwise
* @private
*/
function validateJournalEntryAccount() {
const field = document.getElementById("accounting-entry-form-account");
@ -320,10 +301,9 @@ function validateJournalEntryAccount() {
* @private
*/
function validateJournalEntrySummary() {
const field = document.getElementById("accounting-entry-form-summary");
const control = document.getElementById("accounting-entry-form-summary-control");
const error = document.getElementById("accounting-entry-form-summary-error");
field.value = field.value.trim();
field.classList.remove("is-invalid");
control.classList.remove("is-invalid");
error.innerText = "";
return true;
}
@ -366,12 +346,12 @@ function saveJournalEntryForm() {
const entries = Array.from(document.getElementsByClassName("accounting-currency-" + currencyIndex + "-" + entryType));
const entryList = document.getElementById("accounting-currency-" + currencyIndex + "-" + entryType + "-list")
let maxIndex = 0;
entries.forEach(function (entry) {
for (const entry of entries) {
const index = parseInt(entry.dataset.entryIndex);
if (maxIndex < index) {
maxIndex = index;
}
});
}
entryIndex = String(maxIndex + 1);
const html = form.dataset.entryTemplate
.replaceAll("CURRENCY_INDEX", escapeHtml(currencyIndex))
@ -393,8 +373,8 @@ function saveJournalEntryForm() {
accountCode.value = formAccount.dataset.code;
accountCode.dataset.text = formAccount.dataset.text;
accountText.innerText = formAccount.dataset.text;
summary.value = formSummary.value;
summaryText.innerText = formSummary.value;
summary.value = formSummary.dataset.value;
summaryText.innerText = formSummary.dataset.value;
amount.value = formAmount.value;
amountText.innerText = formatDecimal(new Decimal(formAmount.value));
if (entryForm.dataset.entryIndex === "new") {
@ -418,7 +398,7 @@ function initializeDeleteJournalEntryButton(button) {
const currencyIndex = target.dataset.currencyIndex;
const entryType = target.dataset.entryType;
const currency = document.getElementById("accounting-currency-" + currencyIndex);
button.onclick = function () {
button.onclick = () => {
target.parentElement.removeChild(target);
resetDeleteJournalEntryButtons(button.dataset.sameClass);
updateBalance(currencyIndex, entryType);
@ -436,9 +416,9 @@ function initializeDeleteJournalEntryButton(button) {
function resetDeleteJournalEntryButtons(sameClass) {
const buttons = Array.from(document.getElementsByClassName(sameClass));
if (buttons.length > 1) {
buttons.forEach(function (button) {
for (const button of buttons) {
button.classList.remove("d-none");
});
}
} else {
buttons[0].classList.add("d-none");
}
@ -456,147 +436,14 @@ function updateBalance(currencyIndex, entryType) {
const amounts = Array.from(document.getElementsByClassName(prefix + "-amount"));
const totalText = document.getElementById(prefix + "-total");
let total = new Decimal("0");
amounts.forEach(function (amount) {
for (const amount of amounts) {
if (amount.value !== "") {
total = total.plus(new Decimal(amount.value));
}
});
}
totalText.innerText = formatDecimal(total);
}
/**
* Initializes the account selectors.
*
* @private
*/
function initializeAccountSelectors() {
const selectors = Array.from(document.getElementsByClassName("accounting-selector-modal"));
const formAccountControl = document.getElementById("accounting-entry-form-account-control");
const formAccount = document.getElementById("accounting-entry-form-account");
selectors.forEach(function (selector) {
const more = document.getElementById(selector.dataset.prefix + "-more");
const btnClear = document.getElementById(selector.dataset.prefix + "-btn-clear");
const options = Array.from(document.getElementsByClassName(selector.dataset.prefix + "-option"));
more.onclick = function () {
more.classList.add("d-none");
filterAccountOptions(selector.dataset.prefix);
};
initializeAccountQuery(selector);
btnClear.onclick = function () {
formAccountControl.classList.remove("accounting-not-empty");
formAccount.innerText = "";
formAccount.dataset.code = "";
formAccount.dataset.text = "";
validateJournalEntryAccount();
};
options.forEach(function (option) {
option.onclick = function () {
formAccountControl.classList.add("accounting-not-empty");
formAccount.innerText = option.dataset.content;
formAccount.dataset.code = option.dataset.code;
formAccount.dataset.text = option.dataset.content;
validateJournalEntryAccount();
};
});
});
}
/**
* Initializes the query on the account options.
*
* @param selector {HTMLDivElement} the selector modal
* @private
*/
function initializeAccountQuery(selector) {
const query = document.getElementById(selector.dataset.prefix + "-selector-query");
query.addEventListener("input", function () {
filterAccountOptions(selector.dataset.prefix);
});
}
/**
* Filters the account options.
*
* @param prefix {string} the HTML ID and class prefix
* @private
*/
function filterAccountOptions(prefix) {
const query = document.getElementById(prefix + "-selector-query");
const optionList = document.getElementById(prefix + "-option-list");
if (optionList === null) {
console.log(prefix + "-option-list");
}
const options = Array.from(document.getElementsByClassName(prefix + "-option"));
const more = document.getElementById(prefix + "-more");
const queryNoResult = document.getElementById(prefix + "-option-no-result");
const codesInUse = getAccountCodeUsedInForm();
let hasAnyMatched = false;
options.forEach(function (option) {
const isMatched = shouldAccountOptionShow(option, more, codesInUse, query);
if (isMatched) {
option.classList.remove("d-none");
hasAnyMatched = true;
} else {
option.classList.add("d-none");
}
});
if (!hasAnyMatched) {
optionList.classList.add("d-none");
queryNoResult.classList.remove("d-none");
} else {
optionList.classList.remove("d-none");
queryNoResult.classList.add("d-none");
}
}
/**
* Returns whether an account option should show.
*
* @param option {HTMLLIElement} the account option
* @param more {HTMLLIElement} the more account element
* @param inUse {string[]} the account codes that are used in the form
* @param query {HTMLInputElement} the query element, if any
* @return {boolean} true if the account option should show, or false otherwise
* @private
*/
function shouldAccountOptionShow(option, more, inUse, query) {
const isQueryMatched = function () {
if (query.value === "") {
return true;
}
const queryValues = JSON.parse(option.dataset.queryValues);
for (const queryValue of queryValues) {
if (queryValue.includes(query.value)) {
return true;
}
}
return false;
};
const isMoreMatched = function () {
if (more.classList.contains("d-none")) {
return true;
}
return option.classList.contains("accounting-account-in-use") || inUse.includes(option.dataset.code);
};
return isMoreMatched() && isQueryMatched();
}
/**
* Returns the account codes that are used in the form.
*
* @return {string[]} the account codes that are used in the form
* @private
*/
function getAccountCodeUsedInForm() {
const accountCodes = Array.from(document.getElementsByClassName("accounting-account-code"));
const formAccount = document.getElementById("accounting-entry-form-account");
const inUse = [formAccount.dataset.code];
accountCodes.forEach(function (accountCode) {
inUse.push(accountCode.value);
});
return inUse
}
/**
* Initializes the form validation.
*
@ -655,9 +502,9 @@ function validateCurrencies() {
const currencies = Array.from(document.getElementsByClassName("accounting-currency"));
let isValid = true;
isValid = validateCurrenciesReal() && isValid;
currencies.forEach(function (currency) {
for (const currency of currencies) {
isValid = validateCurrency(currency) && isValid;
});
}
return isValid;
}
@ -718,9 +565,9 @@ function validateJournalEntries(currency, entryType) {
const entries = Array.from(document.getElementsByClassName("accounting-currency-" + currencyIndex + "-" + entryType));
let isValid = true;
isValid = validateJournalEntriesReal(currencyIndex, entryType) && isValid;
entries.forEach(function (entry) {
for (const entry of entries) {
isValid = validateJournalEntry(entry) && isValid;
})
}
return isValid;
}
@ -791,17 +638,17 @@ function validateBalance(currency) {
const creditAmounts = Array.from(document.getElementsByClassName(prefix + "-credit-amount"));
if (debit !== null && credit !== null) {
let debitTotal = new Decimal("0");
debitAmounts.forEach(function (amount) {
for (const amount of debitAmounts) {
if (amount.value !== "") {
debitTotal = debitTotal.plus(new Decimal(amount.value));
}
});
}
let creditTotal = new Decimal("0");
creditAmounts.forEach(function (amount) {
for (const amount of creditAmounts) {
if (amount.value !== "") {
creditTotal = creditTotal.plus(new Decimal(amount.value));
}
});
}
if (!debitTotal.equals(creditTotal)) {
control.classList.add("is-invalid");
error.innerText = A_("The totals of the debit and credit amounts do not match.");

View File

@ -22,10 +22,10 @@
*/
// Initializes the page JavaScript.
document.addEventListener("DOMContentLoaded", function () {
document.addEventListener("DOMContentLoaded", () => {
const list = document.getElementById("accounting-order-list");
if (list !== null) {
const onReorder = function () {
const onReorder = () => {
const accounts = Array.from(list.children);
for (let i = 0; i < accounts.length; i++) {
const no = document.getElementById("accounting-order-" + accounts[i].dataset.id + "-no");

View File

@ -14,37 +14,15 @@
# 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 template filters and globals for the transaction management.
"""The template filters.
"""
from datetime import date, timedelta
from decimal import Decimal
from html import escape
from urllib.parse import ParseResult, urlparse, parse_qsl, urlencode, \
urlunparse
from datetime import date, timedelta
from flask import request, current_app
from flask_babel import get_locale
from accounting.locale import gettext
from accounting.models import Currency
def with_type(uri: str) -> str:
"""Adds the transaction type to the URI, if it is specified.
:param uri: The URI.
:return: The result URL, optionally with the transaction type added.
"""
if "as" not in request.args:
return uri
uri_p: ParseResult = urlparse(uri)
params: list[tuple[str, str]] = parse_qsl(uri_p.query)
params = [x for x in params if x[0] != "next"]
params.append(("as", request.args["as"]))
parts: list[str] = list(uri_p)
parts[4] = urlencode(params)
return urlunparse(parts)
def format_amount(value: Decimal | None) -> str:
@ -88,32 +66,3 @@ def format_date(value: date) -> str:
return "{}/{}/{}({})".format(
value.year, value.month, value.day, weekday)
return "{}/{}({})".format(value.month, value.day, weekday)
def text2html(value: str) -> str:
"""Converts plain text into HTML.
:param value: The plain text.
:return: The HTML.
"""
s: str = escape(value)
s = s.replace("\n", "<br>")
s = s.replace(" ", " &nbsp;")
return s
def currency_options() -> str:
"""Returns the currency options.
:return: The currency options.
"""
return Currency.query.order_by(Currency.code).all()
def default_currency_code() -> str:
"""Returns the default currency code.
:return: The default currency code.
"""
with current_app.app_context():
return current_app.config.get("DEFAULT_CURRENCY", "USD")

View File

@ -0,0 +1,39 @@
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/3
# 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 template globals for the transaction management.
"""
from flask import current_app
from accounting.models import Currency
def currency_options() -> str:
"""Returns the currency options.
:return: The currency options.
"""
return Currency.query.order_by(Currency.code).all()
def default_currency_code() -> str:
"""Returns the default currency code.
:return: The default currency code.
"""
with current_app.app_context():
return current_app.config.get("DEFAULT_CURRENCY", "USD")

View File

@ -21,6 +21,13 @@ First written: 2023/2/26
#}
{% extends "accounting/transaction/include/detail.html" %}
{% block to_transfer %}
<a class="btn btn-primary" href="{{ url_for("accounting.transaction.edit", txn=obj)|accounting_txn_to_transfer|accounting_inherit_next }}">
<i class="fa-solid fa-bars-staggered"></i>
{{ A_("To Transfer") }}
</a>
{% endblock %}
{% block transaction_currencies %}
{% for currency in obj.currencies %}
<div class="mb-3">
@ -37,14 +44,14 @@ First written: 2023/2/26
<div>{{ entry.summary }}</div>
{% endif %}
</div>
<div>{{ entry.amount|accounting_txn_format_amount }}</div>
<div>{{ entry.amount|accounting_format_amount }}</div>
</div>
</li>
{% endfor %}
<li class="list-group-item accounting-transaction-entry accounting-transaction-entry-total">
<div class="d-flex justify-content-between">
<div>{{ _("Total") }}</div>
<div>{{ currency.debit_total|accounting_txn_format_amount }}</div>
<div>{{ A_("Total") }}</div>
<div>{{ currency.debit_total|accounting_format_amount }}</div>
</div>
</li>
</ul>

View File

@ -25,7 +25,7 @@ First written: 2023/2/25
<div class="d-flex justify-content-between mt-2 mb-3">
<div class="form-floating accounting-currency-content">
<select id="accounting-currency-{{ currency_index }}-code" class="form-select" name="currency-{{ currency_index }}-code">
{% for currency in accounting_txn_currency_options() %}
{% for currency in accounting_currency_options() %}
<option value="{{ currency.code }}" {% if currency.code == currency_code_data %} selected="selected" {% endif %}>{{ currency }}</option>
{% endfor %}
</select>
@ -55,9 +55,9 @@ First written: 2023/2/25
account_text = entry_form.account_text,
summary_data = "" if entry_form.summary.data is none else entry_form.summary.data,
summary_errors = entry_form.summary.errors,
amount_data = "" if entry_form.amount.data is none else entry_form.amount.data,
amount_data = "" if entry_form.amount.data is none else entry_form.amount.data|accounting_txn_format_amount_input,
amount_errors = entry_form.amount.errors,
amount_text = entry_form.amount.data|accounting_txn_format_amount,
amount_text = entry_form.amount.data|accounting_format_amount,
entry_errors = entry_form.all_errors %}
{% include "accounting/transaction/include/form-entry-item.html" %}
{% endwith %}
@ -70,7 +70,7 @@ First written: 2023/2/25
</div>
<div>
<button class="btn btn-primary accounting-btn-new-entry accounting-currency-{{ currency_index }}-btn-new-entry" type="button" data-currency-index="{{ currency_index }}" data-entry-type="debit" data-entry-index="new" data-account-modal="#accounting-debit-account-selector-modal" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal">
<button class="btn btn-primary accounting-btn-new-entry accounting-currency-{{ currency_index }}-btn-new-entry" type="button" data-currency-index="{{ currency_index }}" data-entry-type="debit" data-entry-index="new" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal">
<i class="fas fa-plus"></i>
{{ A_("New") }}
</button>

View File

@ -31,20 +31,26 @@ First written: 2023/2/25
currency_code_errors = currency_form.code.errors,
debit_forms = currency_form.debit,
debit_errors = currency_form.debit_errors,
debit_total = currency_form.form.debit_total|accounting_txn_format_amount %}
debit_total = currency_form.form.debit_total|accounting_format_amount %}
{% include "accounting/transaction/expense/include/form-currency-item.html" %}
{% endwith %}
{% endfor %}
{% else %}
{% with currency_index = 1,
only_one_currency_form = True,
currency_code_data = accounting_txn_default_currency_code(),
currency_code_data = accounting_default_currency_code(),
debit_total = "-" %}
{% include "accounting/transaction/expense/include/form-currency-item.html" %}
{% endwith %}
{% endif %}
{% endblock %}
{% block account_selector_modals %}
{% include "accounting/transaction/include/debit-account-modal.html" %}
{% block form_modals %}
{% with summary_editor = form.summary_editor.debit %}
{% include "accounting/transaction/include/summary-editor-modal.html" %}
{% endwith %}
{% with entry_type = "debit",
account_options = form.debit_account_options %}
{% include "accounting/transaction/include/account-selector-modal.html" %}
{% endwith %}
{% endblock %}

View File

@ -0,0 +1,54 @@
{#
The Mia! Accounting Flask Project
account-selector-modal.html: The modal for the account selector
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.
Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/2/25
#}
<div id="accounting-account-selector-{{ entry_type }}-modal" class="modal fade accounting-account-selector-modal" data-entry-type="{{ entry_type }}" tabindex="-1" aria-labelledby="accounting-account-selector-{{ entry_type }}-modal-label" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-5" id="accounting-account-selector-{{ entry_type }}-modal-label">{{ A_("Select Account") }}</h1>
<button type="button" class="btn-close" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal" aria-label="{{ A_("Close") }}"></button>
</div>
<div class="modal-body">
<div class="input-group mb-2">
<input id="accounting-account-selector-{{ entry_type }}-query" class="form-control form-control-sm" type="search" placeholder=" " required="required">
<label class="input-group-text" for="accounting-account-selector-{{ entry_type }}-query">
<i class="fa-solid fa-magnifying-glass"></i>
{{ A_("Search") }}
</label>
</div>
<ul id="accounting-account-selector-{{ entry_type }}-option-list" class="list-group accounting-selector-list">
{% for account in account_options %}
<li id="accounting-account-selector-{{ entry_type }}-option-{{ account.code }}" class="list-group-item accounting-clickable accounting-account-selector-{{ entry_type }}-option {% if account.is_in_use %} accounting-account-in-use {% endif %}" data-code="{{ account.code }}" data-content="{{ account }}" data-query-values="{{ account.query_values|tojson|forceescape }}" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal">
{{ account }}
</li>
{% endfor %}
<li id="accounting-account-selector-{{ entry_type }}-more" class="list-group-item accounting-clickable">{{ A_("More…") }}</li>
</ul>
<p id="accounting-account-selector-{{ entry_type }}-option-no-result" class="d-none">{{ A_("There is no data.") }}</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal">{{ A_("Cancel") }}</button>
<button id="accounting-account-selector-{{ entry_type }}-btn-clear" type="button" class="btn btn-danger" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal">{{ A_("Clear") }}</button>
</div>
</div>
</div>
</div>

View File

@ -1,54 +0,0 @@
{#
The Mia! Accounting Flask Project
credit-modals.html: The modals for the credit journal entry sub-form
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.
Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/2/25
#}
<div id="accounting-credit-account-selector-modal" class="modal fade accounting-selector-modal" data-prefix="accounting-credit-account" tabindex="-1" aria-labelledby="accounting-credit-account-selector-modal-label" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-5" id="accounting-credit-account-selector-modal-label">{{ A_("Select Credit Account") }}</h1>
<button type="button" class="btn-close" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal" aria-label="{{ A_("Close") }}"></button>
</div>
<div class="modal-body">
<div class="input-group mb-2">
<input id="accounting-credit-account-selector-query" class="form-control form-control-sm" type="search" placeholder=" " required="required">
<label class="input-group-text" for="accounting-credit-account-selector-query">
<i class="fa-solid fa-magnifying-glass"></i>
{{ A_("Search") }}
</label>
</div>
<ul id="accounting-credit-account-option-list" class="list-group accounting-selector-list">
{% for account in form.credit_account_options %}
<li id="accounting-credit-account-option-{{ account.code }}" class="list-group-item accounting-clickable accounting-credit-account-option {% if account.is_in_use %} accounting-account-in-use {% endif %}" data-code="{{ account.code }}" data-content="{{ account }}" data-query-values="{{ account.query_values|tojson|forceescape }}" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal">
{{ account }}
</li>
{% endfor %}
<li id="accounting-credit-account-more" class="list-group-item accounting-clickable">{{ A_("More…") }}</li>
</ul>
<p id="accounting-credit-account-option-no-result" class="d-none">{{ A_("There is no data.") }}</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal">{{ A_("Cancel") }}</button>
<button id="accounting-credit-account-btn-clear" type="button" class="btn btn-danger" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal">{{ A_("Clear") }}</button>
</div>
</div>
</div>
</div>

View File

@ -1,54 +0,0 @@
{#
The Mia! Accounting Flask Project
credit-modals.html: The modals for the debit journal entry sub-form
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.
Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/2/25
#}
<div id="accounting-debit-account-selector-modal" class="modal fade accounting-selector-modal" data-prefix="accounting-debit-account" tabindex="-1" aria-labelledby="accounting-debit-account-selector-modal-label" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-5" id="accounting-debit-account-selector-modal-label">{{ A_("Select Debit Account") }}</h1>
<button type="button" class="btn-close" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal" aria-label="{{ A_("Close") }}"></button>
</div>
<div class="modal-body">
<div class="input-group mb-2">
<input id="accounting-debit-account-selector-query" class="form-control form-control-sm" type="search" placeholder=" " required="required">
<label class="input-group-text" for="accounting-debit-account-selector-query">
<i class="fa-solid fa-magnifying-glass"></i>
{{ A_("Search") }}
</label>
</div>
<ul id="accounting-debit-account-option-list" class="list-group accounting-selector-list">
{% for account in form.debit_account_options %}
<li id="accounting-debit-account-option-{{ account.code }}" class="list-group-item accounting-clickable accounting-debit-account-option {% if account.is_in_use %} accounting-account-in-use {% endif %}" data-code="{{ account.code }}" data-content="{{ account }}" data-query-values="{{ account.query_values|tojson|forceescape }}" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal">
{{ account }}
</li>
{% endfor %}
<li id="accounting-debit-account-more" class="list-group-item accounting-clickable">{{ A_("More…") }}</li>
</ul>
<p id="accounting-debit-account-option-no-result" class="d-none">{{ A_("There is no data.") }}</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal">{{ A_("Cancel") }}</button>
<button id="accounting-debit-account-btn-clear" type="button" class="btn btn-danger" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal">{{ A_("Clear") }}</button>
</div>
</div>
</div>
</div>

View File

@ -41,6 +41,7 @@ First written: 2023/2/26
{{ A_("Order") }}
</a>
{% if accounting_can_edit() %}
{% block to_transfer %}{% endblock %}
<button class="btn btn-danger" type="button" data-bs-toggle="modal" data-bs-target="#accounting-delete-modal">
<i class="fa-solid fa-trash"></i>
{{ A_("Delete") }}
@ -88,7 +89,7 @@ First written: 2023/2/26
</div>
<div class="mb-3">
{{ obj.date|accounting_txn_format_date }}
{{ obj.date|accounting_format_date }}
</div>
{% block transaction_currencies %}{% endblock %}

View File

@ -36,9 +36,11 @@ First written: 2023/2/25
<div id="accounting-entry-form-account-error" class="invalid-feedback"></div>
</div>
<div class="form-floating mb-3">
<input id="accounting-entry-form-summary" class="form-control" type="text" value="" placeholder=" ">
<label for="accounting-entry-form-summary">{{ A_("Summary") }}</label>
<div class="mb-3">
<div id="accounting-entry-form-summary-control" class="form-control accounting-clickable accounting-material-text-field" data-bs-toggle="modal" data-bs-target="">
<label class="form-label" for="accounting-entry-form-summary">{{ A_("Summary") }}</label>
<div id="accounting-entry-form-summary" data-value=""></div>
</div>
<div id="accounting-entry-form-summary-error" class="invalid-feedback"></div>
</div>

View File

@ -20,12 +20,12 @@ Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/2/25
#}
{# <ul> For SonarQube not to complain about incorrect HTML #}
<li id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}" class="list-group-item list-group-item-action d-flex justify-content-between accounting-entry accounting-currency-{{ currency_index }}-{{ entry_type }}" data-currency-index="{{ currency_index }}" data-entry-type="{{ entry_type }}" data-entry-index="{{ entry_index }}" data-account-modal="#accounting-{{ entry_type }}-account-selector-modal" data-prefix="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}">
<li id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}" class="list-group-item list-group-item-action d-flex justify-content-between accounting-entry accounting-currency-{{ currency_index }}-{{ entry_type }}" data-currency-index="{{ currency_index }}" data-entry-type="{{ entry_type }}" data-entry-index="{{ entry_index }}" data-prefix="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}">
{% if entry_id %}
<input type="hidden" name="currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-eid" value="{{ entry_id }}">
{% endif %}
<input id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-no" type="hidden" name="currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-no" value="{{ entry_index }}">
<input id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-account-code" class="accounting-account-code" type="hidden" name="currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-account_code" value="{{ account_code_data }}" data-text="{{ account_text }}">
<input id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-account-code" class="accounting-{{ entry_type }}-account-code" type="hidden" name="currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-account_code" value="{{ account_code_data }}" data-text="{{ account_text }}">
<input id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-summary" type="hidden" name="currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-summary" value="{{ summary_data }}">
<input id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-amount" class="accounting-currency-{{ currency_index }}-{{ entry_type }}-amount" type="hidden" name="currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-amount" value="{{ amount_data }}">
<div class="accounting-entry-content">
@ -34,7 +34,7 @@ First written: 2023/2/25
<div id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-account-text" class="small">{{ account_text }}</div>
<div id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-summary-text">{{ "" if summary_data is none else summary_data }}</div>
</div>
<div><span id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-amount-text" class="badge rounded-pill bg-primary">{{ amount_data }}</span></div>
<div><span id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-amount-text" class="badge rounded-pill bg-primary">{{ amount_text }}</span></div>
</div>
<div id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-error" class="invalid-feedback">{% if entry_errors %}{{ entry_errors[0] }}{% endif %}</div>
</div>

View File

@ -24,6 +24,8 @@ First written: 2023/2/26
{% block accounting_scripts %}
<script src="{{ url_for("accounting.static", filename="js/drag-and-drop-reorder.js") }}"></script>
<script src="{{ url_for("accounting.static", filename="js/transaction-form.js") }}"></script>
<script src="{{ url_for("accounting.static", filename="js/account-selector.js") }}"></script>
<script src="{{ url_for("accounting.static", filename="js/summary-editor.js") }}"></script>
{% endblock %}
{% block content %}
@ -85,6 +87,6 @@ First written: 2023/2/26
</form>
{% include "accounting/transaction/include/entry-form-modal.html" %}
{% block account_selector_modals %}{% endblock %}
{% block form_modals %}{% endblock %}
{% endblock %}

View File

@ -0,0 +1,190 @@
{#
The Mia! Accounting Flask Project
summary-editor-modal.html: The modal of the summary editor
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.
Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/2/28
#}
<form id="accounting-summary-editor-{{ summary_editor.type }}" class="accounting-summary-editor" data-entry-type="{{ summary_editor.type }}">
<div id="accounting-summary-editor-{{ summary_editor.type }}-modal" class="modal fade" tabindex="-1" aria-labelledby="accounting-summary-editor-{{ summary_editor.type }}-modal-label" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-5" id="accounting-summary-editor-{{ summary_editor.type }}-modal-label">
<label for="accounting-summary-editor-{{ summary_editor.type }}-summary">{{ A_("Summary") }}</label>
</h1>
<button class="btn-close" type="button" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal" aria-label="{{ A_("Close") }}"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<input id="accounting-summary-editor-{{ summary_editor.type }}-summary" class="form-control" type="text" aria-labelledby="accounting-summary-editor-{{ summary_editor.type }}-modal-label">
</div>
{# Tab navigation #}
<ul class="nav nav-tabs mb-2">
<li class="nav-item">
<span id="accounting-summary-editor-{{ summary_editor.type }}-general-tab" class="nav-link active accounting-clickable" aria-current="page">
{{ A_("General") }}
</span>
</li>
<li class="nav-item">
<span id="accounting-summary-editor-{{ summary_editor.type }}-travel-tab" class="nav-link accounting-clickable" aria-current="false">
{{ A_("Travel") }}
</span>
</li>
<li class="nav-item">
<span id="accounting-summary-editor-{{ summary_editor.type }}-bus-tab" class="nav-link accounting-clickable" aria-current="false">
{{ A_("Bus") }}
</span>
</li>
<li class="nav-item">
<span id="accounting-summary-editor-{{ summary_editor.type }}-regular-tab" class="nav-link accounting-clickable" aria-current="false">
{{ A_("Regular") }}
</span>
</li>
<li class="nav-item">
<span id="accounting-summary-editor-{{ summary_editor.type }}-annotation-tab" class="nav-link accounting-clickable" aria-current="false">
{{ A_("Annotation") }}
</span>
</li>
</ul>
{# A general summary with a tag #}
<div id="accounting-summary-editor-{{ summary_editor.type }}-general-page" aria-current="page" aria-labelledby="accounting-summary-editor-{{ summary_editor.type }}-general-tab">
<div class="form-floating mb-2">
<input id="accounting-summary-editor-{{ summary_editor.type }}-general-tag" class="form-control" type="text" value="" placeholder=" ">
<label class="form-label" for="accounting-summary-editor-{{ summary_editor.type }}-general-tag">{{ A_("Tag") }}</label>
<div id="accounting-summary-editor-{{ summary_editor.type }}-general-tag-error" class="invalid-feedback"></div>
</div>
<div>
{% for tag in summary_editor.general.tags %}
<button class="btn btn-outline-primary accounting-summary-editor-{{ summary_editor.type }}-general-btn-tag" type="button" tabindex="-1" data-value="{{ tag.name }}" data-accounts="{{ tag.account_codes|tojson|forceescape }}">
{{ tag }}
</button>
{% endfor %}
</div>
</div>
{# A general trip with the origin and distination #}
<div id="accounting-summary-editor-{{ summary_editor.type }}-travel-page" class="d-none" aria-current="false" aria-labelledby="accounting-summary-editor-{{ summary_editor.type }}-travel-tab">
<div class="form-floating mb-2">
<input id="accounting-summary-editor-{{ summary_editor.type }}-travel-tag" class="form-control" type="text" value="" placeholder=" ">
<label class="form-label" for="accounting-summary-editor-{{ summary_editor.type }}-travel-tag">{{ A_("Tag") }}</label>
<div id="accounting-summary-editor-{{ summary_editor.type }}-travel-tag-error" class="invalid-feedback"></div>
</div>
<div>
{% for tag in summary_editor.travel.tags %}
<button class="btn btn-outline-primary accounting-summary-editor-{{ summary_editor.type }}-travel-btn-tag" type="button" tabindex="-1" data-value="{{ tag.name }}" data-accounts="{{ tag.account_codes|tojson|forceescape }}">
{{ tag }}
</button>
{% endfor %}
</div>
<div class="d-flex justify-content-between mt-2">
<div class="form-floating">
<input id="accounting-summary-editor-{{ summary_editor.type }}-travel-from" class="form-control" type="text" value="" placeholder=" ">
<label class="form-label" for="accounting-summary-editor-{{ summary_editor.type }}-travel-from">{{ A_("From") }}</label>
<div id="accounting-summary-editor-{{ summary_editor.type }}-travel-from-error" class="invalid-feedback"></div>
</div>
<div class="btn-group-vertical ms-1 me-1">
<button class="btn btn-primary accounting-summary-editor-{{ summary_editor.type }}-travel-direction accounting-default" type="button" tabindex="-1" data-arrow="&rarr;">&rarr;</button>
<button class="btn btn-outline-primary accounting-summary-editor-{{ summary_editor.type }}-travel-direction" type="button" tabindex="-1" data-arrow="&harr;">&harr;</button>
</div>
<div class="form-floating">
<input id="accounting-summary-editor-{{ summary_editor.type }}-travel-to" class="form-control" type="text" value="" placeholder=" ">
<label class="form-label" for="accounting-summary-editor-{{ summary_editor.type }}-travel-to">{{ A_("To") }}</label>
<div id="accounting-summary-editor-{{ summary_editor.type }}-travel-to-error" class="invalid-feedback"></div>
</div>
</div>
</div>
{# A bus trip with the route name or route number, the origin and distination #}
<div id="accounting-summary-editor-{{ summary_editor.type }}-bus-page" class="d-none" aria-current="false" aria-labelledby="accounting-summary-editor-{{ summary_editor.type }}-bus-tab">
<div class="d-flex justify-content-between mb-2">
<div class="form-floating me-2">
<input id="accounting-summary-editor-{{ summary_editor.type }}-bus-tag" class="form-control" type="text" value="" placeholder=" ">
<label class="form-label" for="accounting-summary-editor-{{ summary_editor.type }}-bus-tag">{{ A_("Tag") }}</label>
<div id="accounting-summary-editor-{{ summary_editor.type }}-bus-tag-error" class="invalid-feedback"></div>
</div>
<div class="form-floating">
<input id="accounting-summary-editor-{{ summary_editor.type }}-bus-route" class="form-control" type="text" value="" placeholder=" ">
<label class="form-label" for="accounting-summary-editor-{{ summary_editor.type }}-bus-route">{{ A_("Route") }}</label>
<div id="accounting-summary-editor-{{ summary_editor.type }}-bus-route-error" class="invalid-feedback"></div>
</div>
</div>
<div>
{% for tag in summary_editor.bus.tags %}
<button class="btn btn-outline-primary accounting-summary-editor-{{ summary_editor.type }}-bus-btn-tag" type="button" tabindex="-1" data-value="{{ tag.name }}" data-accounts="{{ tag.account_codes|tojson|forceescape }}">
{{ tag }}
</button>
{% endfor %}
</div>
<div class="d-flex justify-content-between mt-2">
<div class="form-floating me-2">
<input id="accounting-summary-editor-{{ summary_editor.type }}-bus-from" class="form-control" type="text" value="" placeholder=" ">
<label class="form-label" for="accounting-summary-editor-{{ summary_editor.type }}-bus-from">{{ A_("From") }}</label>
<div id="accounting-summary-editor-{{ summary_editor.type }}-bus-from-error" class="invalid-feedback"></div>
</div>
<div class="form-floating">
<input id="accounting-summary-editor-{{ summary_editor.type }}-bus-to" class="form-control" type="text" value="" placeholder=" ">
<label class="form-label" for="accounting-summary-editor-{{ summary_editor.type }}-bus-to">{{ A_("To") }}</label>
<div id="accounting-summary-editor-{{ summary_editor.type }}-bus-to-error" class="invalid-feedback"></div>
</div>
</div>
</div>
{# A regular income or payment #}
<div id="accounting-summary-editor-{{ summary_editor.type }}-regular-page" class="d-none" aria-current="false" aria-labelledby="accounting-summary-editor-{{ summary_editor.type }}-regular-tab">
{# TODO: To be done #}
</div>
{# The annotation #}
<div id="accounting-summary-editor-{{ summary_editor.type }}-annotation-page" class="d-none" aria-current="false" aria-labelledby="accounting-summary-editor-{{ summary_editor.type }}-annotation-tab">
<div class="form-floating">
<input id="accounting-summary-editor-{{ summary_editor.type }}-annotation-number" class="form-control" type="number" min="1" value="" placeholder=" ">
<label class="form-label" for="accounting-summary-editor-{{ summary_editor.type }}-annotation-number">{{ A_("The number of items") }}</label>
<div id="accounting-summary-editor-{{ summary_editor.type }}-annotation-number-error" class="invalid-feedback"></div>
</div>
<div class="form-floating mt-2">
<input id="accounting-summary-editor-{{ summary_editor.type }}-annotation-note" class="form-control" type="text" value="" placeholder=" ">
<label class="form-label" for="accounting-summary-editor-{{ summary_editor.type }}-annotation-note">{{ A_("Note") }}</label>
<div id="accounting-summary-editor-{{ summary_editor.type }}-annotation-note-error" class="invalid-feedback"></div>
</div>
</div>
{# The suggested accounts #}
<div class="mt-3">
{% for account in summary_editor.accounts %}
<button class="btn btn-outline-primary d-none accounting-summary-editor-{{ summary_editor.type }}-account" type="button" data-code="{{ account.code }}" data-text="{{ account }}">
{{ account }}
</button>
{% endfor %}
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" type="button" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal">{{ A_("Cancel") }}</button>
<button id="accounting-summary-editor-{{ summary_editor.type }}-btn-save" type="submit" class="btn btn-primary">{{ A_("Save") }}</button>
</div>
</div>
</div>
</div>
</form>

View File

@ -21,6 +21,13 @@ First written: 2023/2/26
#}
{% extends "accounting/transaction/include/detail.html" %}
{% block to_transfer %}
<a class="btn btn-primary" href="{{ url_for("accounting.transaction.edit", txn=obj)|accounting_txn_to_transfer|accounting_inherit_next }}">
<i class="fa-solid fa-bars-staggered"></i>
{{ A_("To Transfer") }}
</a>
{% endblock %}
{% block transaction_currencies %}
{% for currency in obj.currencies %}
<div class="mb-3">
@ -37,14 +44,14 @@ First written: 2023/2/26
<div>{{ entry.summary }}</div>
{% endif %}
</div>
<div>{{ entry.amount|accounting_txn_format_amount }}</div>
<div>{{ entry.amount|accounting_format_amount }}</div>
</div>
</li>
{% endfor %}
<li class="list-group-item accounting-transaction-entry accounting-transaction-entry-total">
<div class="d-flex justify-content-between">
<div>{{ _("Total") }}</div>
<div>{{ currency.debit_total|accounting_txn_format_amount }}</div>
<div>{{ A_("Total") }}</div>
<div>{{ currency.debit_total|accounting_format_amount }}</div>
</div>
</li>
</ul>

View File

@ -25,7 +25,7 @@ First written: 2023/2/25
<div class="d-flex justify-content-between mt-2 mb-3">
<div class="form-floating accounting-currency-content">
<select id="accounting-currency-{{ currency_index }}-code" class="form-select" name="currency-{{ currency_index }}-code">
{% for currency in accounting_txn_currency_options() %}
{% for currency in accounting_currency_options() %}
<option value="{{ currency.code }}" {% if currency.code == currency_code_data %} selected="selected" {% endif %}>{{ currency }}</option>
{% endfor %}
</select>
@ -55,9 +55,9 @@ First written: 2023/2/25
account_text = entry_form.account_text,
summary_data = "" if entry_form.summary.data is none else entry_form.summary.data,
summary_errors = entry_form.summary.errors,
amount_data = "" if entry_form.amount.data is none else entry_form.amount.data,
amount_data = "" if entry_form.amount.data is none else entry_form.amount.data|accounting_txn_format_amount_input,
amount_errors = entry_form.amount.errors,
amount_text = entry_form.amount.data|accounting_txn_format_amount,
amount_text = entry_form.amount.data|accounting_format_amount,
entry_errors = entry_form.all_errors %}
{% include "accounting/transaction/include/form-entry-item.html" %}
{% endwith %}
@ -70,7 +70,7 @@ First written: 2023/2/25
</div>
<div>
<button class="btn btn-primary accounting-btn-new-entry accounting-currency-{{ currency_index }}-btn-new-entry" type="button" data-currency-index="{{ currency_index }}" data-entry-type="credit" data-entry-index="new" data-account-modal="#accounting-credit-account-selector-modal" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal">
<button class="btn btn-primary accounting-btn-new-entry accounting-currency-{{ currency_index }}-btn-new-entry" type="button" data-currency-index="{{ currency_index }}" data-entry-type="credit" data-entry-index="new" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal">
<i class="fas fa-plus"></i>
{{ A_("New") }}
</button>

View File

@ -31,20 +31,26 @@ First written: 2023/2/25
currency_code_errors = currency_form.code.errors,
credit_forms = currency_form.credit,
credit_errors = currency_form.credit_errors,
credit_total = currency_form.form.credit_total|accounting_txn_format_amount %}
credit_total = currency_form.form.credit_total|accounting_format_amount %}
{% include "accounting/transaction/income/include/form-currency-item.html" %}
{% endwith %}
{% endfor %}
{% else %}
{% with currency_index = 1,
only_one_currency_form = True,
currency_code_data = accounting_txn_default_currency_code(),
currency_code_data = accounting_default_currency_code(),
credit_total = "-" %}
{% include "accounting/transaction/income/include/form-currency-item.html" %}
{% endwith %}
{% endif %}
{% endblock %}
{% block account_selector_modals %}
{% include "accounting/transaction/include/credit-account-modal.html" %}
{% block form_modals %}
{% with summary_editor = form.summary_editor.credit %}
{% include "accounting/transaction/include/summary-editor-modal.html" %}
{% endwith %}
{% with entry_type = "credit",
account_options = form.credit_account_options %}
{% include "accounting/transaction/include/account-selector-modal.html" %}
{% endwith %}
{% endblock %}

View File

@ -85,7 +85,7 @@ First written: 2023/2/18
<div class="list-group">
{% for item in list %}
<a class="list-group-item list-group-item-action" href="{{ url_for("accounting.transaction.detail", txn=item)|accounting_append_next }}">
{{ item.date|accounting_txn_format_date }} {{ item }}
{{ item.date|accounting_format_date }} {{ item }}
</a>
{% endfor %}
</div>

View File

@ -40,14 +40,14 @@ First written: 2023/2/26
<div>{{ entry.summary }}</div>
{% endif %}
</div>
<div>{{ entry.amount|accounting_txn_format_amount }}</div>
<div>{{ entry.amount|accounting_format_amount }}</div>
</div>
</li>
{% endfor %}
<li class="list-group-item accounting-transaction-entry accounting-transaction-entry-total">
<div class="d-flex justify-content-between">
<div>{{ _("Total") }}</div>
<div>{{ currency.debit_total|accounting_txn_format_amount }}</div>
<div>{{ A_("Total") }}</div>
<div>{{ currency.debit_total|accounting_format_amount }}</div>
</div>
</li>
</ul>
@ -66,14 +66,14 @@ First written: 2023/2/26
<div>{{ entry.summary }}</div>
{% endif %}
</div>
<div>{{ entry.amount|accounting_txn_format_amount }}</div>
<div>{{ entry.amount|accounting_format_amount }}</div>
</div>
</li>
{% endfor %}
<li class="list-group-item accounting-transaction-entry accounting-transaction-entry-total">
<div class="d-flex justify-content-between">
<div>{{ _("Total") }}</div>
<div>{{ currency.debit_total|accounting_txn_format_amount }}</div>
<div>{{ A_("Total") }}</div>
<div>{{ currency.debit_total|accounting_format_amount }}</div>
</div>
</li>
</ul>

View File

@ -25,7 +25,7 @@ First written: 2023/2/25
<div class="d-flex justify-content-between mt-2 mb-3">
<div class="form-floating accounting-currency-content">
<select id="accounting-currency-{{ currency_index }}-code" class="form-select" name="currency-{{ currency_index }}-code">
{% for currency in accounting_txn_currency_options() %}
{% for currency in accounting_currency_options() %}
<option value="{{ currency.code }}" {% if currency.code == currency_code_data %} selected="selected" {% endif %}>{{ currency }}</option>
{% endfor %}
</select>
@ -57,9 +57,9 @@ First written: 2023/2/25
account_text = entry_form.account_text,
summary_data = "" if entry_form.summary.data is none else entry_form.summary.data,
summary_errors = entry_form.summary.errors,
amount_data = "" if entry_form.amount.data is none else entry_form.amount.data,
amount_data = "" if entry_form.amount.data is none else entry_form.amount.data|accounting_txn_format_amount_input,
amount_errors = entry_form.amount.errors,
amount_text = entry_form.amount.data|accounting_txn_format_amount,
amount_text = entry_form.amount.data|accounting_format_amount,
entry_errors = entry_form.all_errors %}
{% include "accounting/transaction/include/form-entry-item.html" %}
{% endwith %}
@ -72,7 +72,7 @@ First written: 2023/2/25
</div>
<div>
<button class="btn btn-primary accounting-btn-new-entry accounting-currency-{{ currency_index }}-btn-new-entry" type="button" data-currency-index="{{ currency_index }}" data-entry-type="debit" data-entry-index="new" data-account-modal="#accounting-debit-account-selector-modal" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal">
<button class="btn btn-primary accounting-btn-new-entry accounting-currency-{{ currency_index }}-btn-new-entry" type="button" data-currency-index="{{ currency_index }}" data-entry-type="debit" data-entry-index="new" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal">
<i class="fas fa-plus"></i>
{{ A_("New") }}
</button>
@ -97,9 +97,9 @@ First written: 2023/2/25
account_text = entry_form.account_text,
summary_data = "" if entry_form.summary.data is none else entry_form.summary.data,
summary_errors = entry_form.summary.errors,
amount_data = "" if entry_form.amount.data is none else entry_form.amount.data,
amount_data = "" if entry_form.amount.data is none else entry_form.amount.data|accounting_txn_format_amount_input,
amount_errors = entry_form.amount.errors,
amount_text = entry_form.amount.data|accounting_txn_format_amount,
amount_text = entry_form.amount.data|accounting_format_amount,
entry_errors = entry_form.all_errors %}
{% include "accounting/transaction/include/form-entry-item.html" %}
{% endwith %}
@ -112,7 +112,7 @@ First written: 2023/2/25
</div>
<div>
<button class="btn btn-primary accounting-btn-new-entry accounting-currency-{{ currency_index }}-btn-new-entry" type="button" data-currency-index="{{ currency_index }}" data-entry-type="credit" data-entry-index="new" data-account-modal="#accounting-credit-account-selector-modal" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal">
<button class="btn btn-primary accounting-btn-new-entry accounting-currency-{{ currency_index }}-btn-new-entry" type="button" data-currency-index="{{ currency_index }}" data-entry-type="credit" data-entry-index="new" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal">
<i class="fas fa-plus"></i>
{{ A_("New") }}
</button>

View File

@ -31,17 +31,17 @@ First written: 2023/2/25
currency_code_errors = currency_form.code.errors,
debit_forms = currency_form.debit,
debit_errors = currency_form.debit_errors,
debit_total = currency_form.form.debit_total|accounting_txn_format_amount,
debit_total = currency_form.form.debit_total|accounting_format_amount,
credit_forms = currency_form.credit,
credit_errors = currency_form.credit_errors,
credit_total = currency_form.form.credit_total|accounting_txn_format_amount %}
credit_total = currency_form.form.credit_total|accounting_format_amount %}
{% include "accounting/transaction/transfer/include/form-currency-item.html" %}
{% endwith %}
{% endfor %}
{% else %}
{% with currency_index = 1,
only_one_currency_form = True,
currency_code_data = accounting_txn_default_currency_code(),
currency_code_data = accounting_default_currency_code(),
debit_total = "-",
credit_total = "-" %}
{% include "accounting/transaction/transfer/include/form-currency-item.html" %}
@ -49,7 +49,19 @@ First written: 2023/2/25
{% endif %}
{% endblock %}
{% block account_selector_modals %}
{% include "accounting/transaction/include/debit-account-modal.html" %}
{% include "accounting/transaction/include/credit-account-modal.html" %}
{% block form_modals %}
{% with summary_editor = form.summary_editor.debit %}
{% include "accounting/transaction/include/summary-editor-modal.html" %}
{% endwith %}
{% with summary_editor = form.summary_editor.credit %}
{% include "accounting/transaction/include/summary-editor-modal.html" %}
{% endwith %}
{% with entry_type = "debit",
account_options = form.debit_account_options %}
{% include "accounting/transaction/include/account-selector-modal.html" %}
{% endwith %}
{% with entry_type = "credit",
account_options = form.credit_account_options %}
{% include "accounting/transaction/include/account-selector-modal.html" %}
{% endwith %}
{% endblock %}

View File

@ -14,7 +14,7 @@
# 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 transaction type dispatcher.
"""The view dispatcher for different transaction types.
"""
import typing as t
@ -26,7 +26,7 @@ from flask_wtf import FlaskForm
from accounting.models import Transaction
from .forms import TransactionForm, IncomeTransactionForm, \
ExpenseTransactionForm, TransferTransactionForm
from .template import default_currency_code
from accounting.template_globals import default_currency_code
class TransactionType(ABC):

View File

@ -37,6 +37,7 @@ from accounting import db
from accounting.locale import lazy_gettext
from accounting.models import Transaction, Account, JournalEntry, \
TransactionCurrency, Currency
from accounting.transaction.summary_editor import SummaryEditor
from accounting.utils.random_id import new_id
from accounting.utils.strip_text import strip_text, strip_multiline_text
from accounting.utils.user import get_current_user_pk
@ -114,6 +115,35 @@ class IsDebitAccount:
"This account is not for debit entries."))
class AccountOption:
"""An account option."""
def __init__(self, account: Account):
"""Constructs an account option.
:param account: The account.
"""
self.__account: Account = account
self.id: str = account.id
self.code: str = account.code
self.is_in_use: bool = False
def __str__(self) -> str:
"""Returns the string representation of the account option.
:return: The string representation of the account option.
"""
return str(self.__account)
@property
def query_values(self) -> list[str]:
"""Returns the values to be queried.
:return: The values to be queried.
"""
return self.__account.query_values
class JournalEntryForm(FlaskForm):
"""The base form to create or edit a journal entry."""
eid = IntegerField()
@ -284,13 +314,11 @@ class TransactionForm(FlaskForm):
self.__set_date(obj, self.date.data)
obj.note = self.note.data
entries: list[JournalEntry] = obj.entries
collector_cls: t.Type[JournalEntryCollector] = self.collector
collector: collector_cls = collector_cls(self, obj.id, entries,
obj.currencies)
collector: collector_cls = collector_cls(self, obj)
collector.collect()
to_delete: set[int] = {x.id for x in entries
to_delete: set[int] = {x.id for x in obj.entries
if x.id not in collector.to_keep}
if len(to_delete) > 0:
JournalEntry.query.filter(JournalEntry.id.in_(to_delete)).delete()
@ -322,49 +350,54 @@ class TransactionForm(FlaskForm):
obj.no = count + 1
@property
def debit_account_options(self) -> list[Account]:
def debit_account_options(self) -> list[AccountOption]:
"""The selectable debit accounts.
:return: The selectable debit accounts.
"""
accounts: list[Account] = Account.debit()
in_use: set[int] = self.__get_in_use_account_id()
accounts: list[AccountOption] \
= [AccountOption(x) for x in Account.debit()]
in_use: set[int] = set(db.session.scalars(
sa.select(JournalEntry.account_id)
.filter(JournalEntry.is_debit)
.group_by(JournalEntry.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[Account]:
def credit_account_options(self) -> list[AccountOption]:
"""The selectable credit accounts.
:return: The selectable credit accounts.
"""
accounts: list[Account] = Account.credit()
in_use: set[int] = self.__get_in_use_account_id()
accounts: list[AccountOption] \
= [AccountOption(x) for x in Account.credit()]
in_use: set[int] = set(db.session.scalars(
sa.select(JournalEntry.account_id)
.filter(sa.not_(JournalEntry.is_debit))
.group_by(JournalEntry.account_id)).all())
for account in accounts:
account.is_in_use = account.id in in_use
return accounts
def __get_in_use_account_id(self) -> set[int]:
"""Returns the ID of the accounts that are in use.
:return: The ID of the accounts that are in use.
"""
if self.__in_use_account_id is None:
self.__in_use_account_id = set(db.session.scalars(
sa.select(JournalEntry.account_id)
.group_by(JournalEntry.account_id)).all())
return self.__in_use_account_id
@property
def currencies_errors(self) -> list[str | LazyString]:
"""Returns the currency errors, without the errors in their sub-forms.
:return:
: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 summary_editor(self) -> SummaryEditor:
"""Returns the summary editor.
:return: The summary editor.
"""
return SummaryEditor()
T = t.TypeVar("T", bound=TransactionForm)
"""A transaction form variant."""
@ -373,27 +406,24 @@ T = t.TypeVar("T", bound=TransactionForm)
class JournalEntryCollector(t.Generic[T], ABC):
"""The journal entry collector."""
def __init__(self, form: T, txn_id: int, entries: list[JournalEntry],
currencies: list[TransactionCurrency]):
def __init__(self, form: T, obj: Transaction):
"""Constructs the journal entry collector.
:param form: The transaction form.
:param txn_id: The transaction ID.
:param entries: The existing journal entries.
:param currencies: The currencies in the transaction.
:param obj: The transaction.
"""
self.form: T = form
"""The transaction form."""
self.entries: list[JournalEntry] = entries
self.__obj: Transaction = obj
"""The transaction object."""
self.__entries: list[JournalEntry] = list(obj.entries)
"""The existing journal entries."""
self.txn_id: int = txn_id
"""The transaction ID."""
self.__entries_by_id: dict[int, JournalEntry] \
= {x.id: x for x in entries}
= {x.id: x for x in self.__entries}
"""A dictionary from the entry ID to their entries."""
self.__no_by_id: dict[int, int] = {x.id: x.no for x in entries}
self.__no_by_id: dict[int, int] = {x.id: x.no for x in self.__entries}
"""A dictionary from the entry number to their entries."""
self.__currencies: list[TransactionCurrency] = currencies
self.__currencies: list[TransactionCurrency] = obj.currencies
"""The currencies in the transaction."""
self._debit_no: int = 1
"""The number index for the debit entries."""
@ -420,7 +450,6 @@ class JournalEntryCollector(t.Generic[T], ABC):
"""
entry: JournalEntry | None = self.__entries_by_id.get(form.eid.data)
if entry is not None:
self.to_keep.add(entry.id)
entry.currency_code = currency_code
form.populate_obj(entry)
entry.no = no
@ -428,12 +457,12 @@ class JournalEntryCollector(t.Generic[T], ABC):
self.form.is_modified = True
else:
entry = JournalEntry()
entry.transaction_id = self.txn_id
entry.currency_code = currency_code
form.populate_obj(entry)
entry.no = no
db.session.add(entry)
self.__obj.entries.append(entry)
self.form.is_modified = True
self.to_keep.add(entry.id)
def _make_cash_entry(self, forms: list[JournalEntryForm], is_debit: bool,
currency_code: str, no: int) -> None:
@ -447,14 +476,13 @@ class JournalEntryCollector(t.Generic[T], ABC):
:param no: The number of the entry.
:return: None.
"""
candidates: list[JournalEntry] = [x for x in self.entries
candidates: list[JournalEntry] = [x for x in self.__entries
if x.is_debit == is_debit
and x.currency_code == currency_code]
entry: JournalEntry
if len(candidates) > 0:
candidates.sort(key=lambda x: x.no)
entry = candidates[0]
self.to_keep.add(entry.id)
entry.account_id = Account.cash().id
entry.summary = None
entry.amount = sum([x.amount.data for x in forms])
@ -464,15 +492,15 @@ class JournalEntryCollector(t.Generic[T], ABC):
else:
entry = JournalEntry()
entry.id = new_id(JournalEntry)
entry.transaction_id = self.txn_id
entry.is_debit = is_debit
entry.currency_code = currency_code
entry.account_id = Account.cash().id
entry.summary = None
entry.amount = sum([x.amount.data for x in forms])
entry.no = no
db.session.add(entry)
self.__obj.entries.append(entry)
self.form.is_modified = True
self.to_keep.add(entry.id)
def _sort_entry_forms(self, forms: list[JournalEntryForm]) -> None:
"""Sorts the journal entry forms.

View File

@ -14,7 +14,7 @@
# 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 transaction query.
"""The queries for the transaction management.
"""
from datetime import datetime

View File

@ -0,0 +1,255 @@
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/27
# 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 summary editor.
"""
import typing as t
import sqlalchemy as sa
from accounting import db
from accounting.models import Account, JournalEntry
class SummaryAccount:
"""An account for a summary tag."""
def __init__(self, account: Account, freq: int):
"""Constructs an account for a summary tag.
:param account: The account.
:param freq: The frequency of the tag with the account.
"""
self.account: Account = account
"""The account."""
self.id: int = account.id
"""The account ID."""
self.code: str = account.code
"""The account code."""
self.freq: int = freq
"""The frequency of the tag with the account."""
def __str__(self) -> str:
"""Returns the string representation of the account.
:return: The string representation of the account.
"""
return str(self.account)
def add_freq(self, freq: int) -> None:
"""Adds the frequency of an account.
:param freq: The frequency of the tag name with the account.
:return: None.
"""
self.freq = self.freq + freq
class SummaryTag:
"""A summary tag."""
def __init__(self, name: str):
"""Constructs a summary tag.
:param name: The tag name.
"""
self.name: str = name
"""The tag name."""
self.__account_dict: dict[int, SummaryAccount] = {}
"""The accounts that come with the tag, in the order of their
frequency."""
self.freq: int = 0
"""The frequency of the tag."""
def __str__(self) -> str:
"""Returns the string representation of the tag.
:return: The string representation of the tag.
"""
return self.name
def add_account(self, account: Account, freq: int):
"""Adds an account.
:param account: The associated account.
:param freq: The frequency of the tag name with the account.
:return: None.
"""
self.__account_dict[account.id] = SummaryAccount(account, freq)
self.freq = self.freq + freq
@property
def accounts(self) -> list[SummaryAccount]:
"""Returns the accounts by the order of their frequencies.
:return: The accounts by the order of their frequencies.
"""
return sorted(self.__account_dict.values(), key=lambda x: -x.freq)
@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 [x.code for x in self.accounts]
class SummaryType:
"""A summary type"""
def __init__(self, type_id: t.Literal["general", "travel", "bus"]):
"""Constructs a summary type.
:param type_id: The type ID, either "general", "travel", or "bus".
"""
self.id: t.Literal["general", "travel", "bus"] = type_id
"""The type ID."""
self.__tag_dict: dict[str, SummaryTag] = {}
"""A dictionary from the tag name to their corresponding tag."""
def add_tag(self, name: str, account: Account, freq: int) -> None:
"""Adds a tag.
:param name: The tag name.
:param account: The associated account.
:param freq: The frequency of the tag name with the account.
:return: None.
"""
if name not in self.__tag_dict:
self.__tag_dict[name] = SummaryTag(name)
self.__tag_dict[name].add_account(account, freq)
@property
def tags(self) -> list[SummaryTag]:
"""Returns the tags by the order of their frequencies.
:return: The tags by the order of their frequencies.
"""
return sorted(self.__tag_dict.values(), key=lambda x: -x.freq)
class SummaryEntryType:
"""A summary type"""
def __init__(self, entry_type_id: t.Literal["debit", "credit"]):
"""Constructs a summary entry type.
:param entry_type_id: The entry type ID, either "debit" or "credit".
"""
self.type: t.Literal["debit", "credit"] = entry_type_id
"""The entry type."""
self.general: SummaryType = SummaryType("general")
"""The general tags."""
self.travel: SummaryType = SummaryType("travel")
"""The travel tags."""
self.bus: SummaryType = SummaryType("bus")
"""The bus tags."""
self.__type_dict: dict[t.Literal["general", "travel", "bus"],
SummaryType] \
= {x.id: x for x in {self.general, self.travel, self.bus}}
"""A dictionary from the type ID to the corresponding tags."""
def add_tag(self, tag_type: t.Literal["general", "travel", "bus"],
name: str, account: Account, freq: int) -> None:
"""Adds a tag.
:param tag_type: The tag type, either "general", "travel", or "bus".
:param name: The name.
:param account: The associated account.
:param freq: The frequency of the tag name with the account.
:return: None.
"""
self.__type_dict[tag_type].add_tag(name, account, freq)
@property
def accounts(self) -> list[SummaryAccount]:
"""Returns the suggested accounts of all tags in the summary editor in
the entry type, in their frequency order.
:return: The suggested accounts of all tags, in their frequency order.
"""
accounts: dict[int, SummaryAccount] = {}
freq: dict[int, int] = {}
for tag_type in self.__type_dict.values():
for tag in tag_type.tags:
for account in tag.accounts:
accounts[account.id] = account
if account.id not in freq:
freq[account.id] = 0
freq[account.id] \
= freq[account.id] + account.freq
return [accounts[y] for y in sorted(freq.keys(),
key=lambda x: -freq[x])]
class SummaryEditor:
"""The summary editor."""
def __init__(self):
"""Constructs the summary editor."""
self.debit: SummaryEntryType = SummaryEntryType("debit")
"""The debit tags."""
self.credit: SummaryEntryType = SummaryEntryType("credit")
"""The credit tags."""
entry_type: sa.Label = sa.case((JournalEntry.is_debit, "debit"),
else_="credit").label("entry_type")
tag_type: sa.Label = sa.case(
(JournalEntry.summary.like("_%—_%—_%→_%"), "bus"),
(sa.or_(JournalEntry.summary.like("_%—_%→_%"),
JournalEntry.summary.like("_%—_%↔_%")), "travel"),
else_="general").label("tag_type")
tag: sa.Label = get_prefix(JournalEntry.summary, "").label("tag")
select: sa.Select = sa.Select(entry_type, tag_type, tag,
JournalEntry.account_id,
sa.func.count().label("freq"))\
.filter(JournalEntry.summary.is_not(None),
JournalEntry.summary.like("_%—_%"))\
.group_by(entry_type, tag_type, tag, JournalEntry.account_id)
result: list[sa.Row] = db.session.execute(select).all()
accounts: dict[int, Account] \
= {x.id: x for x in Account.query
.filter(Account.id.in_({x.account_id for x in result})).all()}
entry_type_dict: dict[t.Literal["debit", "credit"], SummaryEntryType] \
= {x.type: x for x in {self.debit, self.credit}}
for row in result:
entry_type_dict[row.entry_type].add_tag(
row.tag_type, row.tag, accounts[row.account_id], row.freq)
def get_prefix(string: str | sa.Column, separator: str | sa.Column) \
-> sa.Function:
"""Returns the SQL function to find the prefix of a string.
:param string: The string.
:param separator: The separator.
:return: The position of the substring, starting from 1.
"""
return sa.func.substr(string, 0, get_position(string, separator))
def get_position(string: str | sa.Column, substring: str | sa.Column) \
-> sa.Function:
"""Returns the SQL function to find the position of a substring.
:param string: The string.
:param substring: The substring.
:return: The position of the substring, starting from 1.
"""
if db.engine.name == "postgresql":
return sa.func.strpos(string, substring)
return sa.func.instr(string, substring)

View File

@ -0,0 +1,80 @@
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/25
# 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 template filters for the transaction management.
"""
from decimal import Decimal
from html import escape
from urllib.parse import ParseResult, urlparse, parse_qsl, urlencode, \
urlunparse
from flask import request
def with_type(uri: str) -> str:
"""Adds the transaction type to the URI, if it is specified.
:param uri: The URI.
:return: The result URL, optionally with the transaction type added.
"""
if "as" not in request.args:
return uri
uri_p: ParseResult = urlparse(uri)
params: list[tuple[str, str]] = parse_qsl(uri_p.query)
params = [x for x in params if x[0] != "as"]
params.append(("as", request.args["as"]))
parts: list[str] = list(uri_p)
parts[4] = urlencode(params)
return urlunparse(parts)
def to_transfer(uri: str) -> str:
"""Adds the transfer transaction type to the URI.
:param uri: The URI.
:return: The result URL, with the transfer transaction type added.
"""
uri_p: ParseResult = urlparse(uri)
params: list[tuple[str, str]] = parse_qsl(uri_p.query)
params = [x for x in params if x[0] != "as"]
params.append(("as", "transfer"))
parts: list[str] = list(uri_p)
parts[4] = urlencode(params)
return urlunparse(parts)
def format_amount_input(value: Decimal) -> str:
"""Format an amount for an input value.
:param value: The amount.
:return: The formatted amount text for an input value.
"""
whole: int = int(value)
frac: Decimal = (value - whole).normalize()
return str(whole) + str(frac)[1:]
def text2html(value: str) -> str:
"""Converts plain text into HTML.
:param value: The plain text.
:return: The HTML.
"""
s: str = escape(value)
s = s.replace("\n", "<br>")
s = s.replace(" ", " &nbsp;")
return s

View File

@ -34,20 +34,18 @@ from accounting.utils.pagination import Pagination
from accounting.utils.permission import has_permission, can_view, can_edit
from accounting.utils.user import get_current_user_pk
from .dispatcher import TransactionType, get_txn_type, TXN_TYPE_OBJ
from .template import with_type, format_amount, format_date, text2html, \
currency_options, default_currency_code
from .forms import sort_transactions_in, TransactionReorderForm
from .query import get_transaction_query
from .queries import get_transaction_query
from .template_filters import with_type, to_transfer, format_amount_input, \
text2html
bp: Blueprint = Blueprint("transaction", __name__)
"""The view blueprint for the transaction management."""
bp.add_app_template_filter(with_type, "accounting_txn_with_type")
bp.add_app_template_filter(format_amount, "accounting_txn_format_amount")
bp.add_app_template_filter(format_date, "accounting_txn_format_date")
bp.add_app_template_filter(to_transfer, "accounting_txn_to_transfer")
bp.add_app_template_filter(format_amount_input,
"accounting_txn_format_amount_input")
bp.add_app_template_filter(text2html, "accounting_txn_text2html")
bp.add_app_template_global(currency_options, "accounting_txn_currency_options")
bp.add_app_template_global(default_currency_code,
"accounting_txn_default_currency_code")
@bp.get("", endpoint="list")

View File

@ -8,8 +8,8 @@ msgid ""
msgstr ""
"Project-Id-Version: Mia! Accounting Flask 0.0.0\n"
"Report-Msgid-Bugs-To: imacat@mail.imacat.idv.tw\n"
"POT-Creation-Date: 2023-02-27 15:28+0800\n"
"PO-Revision-Date: 2023-02-27 15:29+0800\n"
"POT-Creation-Date: 2023-03-01 00:51+0800\n"
"PO-Revision-Date: 2023-03-01 00:51+0800\n"
"Last-Translator: imacat <imacat@mail.imacat.idv.tw>\n"
"Language: zh_Hant\n"
"Language-Team: zh_Hant <imacat@mail.imacat.idv.tw>\n"
@ -61,23 +61,23 @@ msgstr "逐筆核銷"
msgid "The account is added successfully"
msgstr "科目加好了。"
#: src/accounting/account/views.py:142
#: src/accounting/account/views.py:141
msgid "The account was not modified."
msgstr "科目未異動。"
#: src/accounting/account/views.py:148
#: src/accounting/account/views.py:146
msgid "The account is updated successfully."
msgstr "科目存好了。"
#: src/accounting/account/views.py:165
#: src/accounting/account/views.py:162
msgid "The account is deleted successfully."
msgstr "科目刪掉了"
#: src/accounting/account/views.py:192 src/accounting/transaction/views.py:210
#: src/accounting/account/views.py:189 src/accounting/transaction/views.py:214
msgid "The order was not modified."
msgstr "順序未異動。"
#: src/accounting/account/views.py:195 src/accounting/transaction/views.py:213
#: src/accounting/account/views.py:192 src/accounting/transaction/views.py:217
msgid "The order is updated successfully."
msgstr "順序存好了。"
@ -110,15 +110,15 @@ msgstr "請填上名稱。"
msgid "The currency is added successfully"
msgstr "貨幣加好了。"
#: src/accounting/currency/views.py:145
#: src/accounting/currency/views.py:144
msgid "The currency was not modified."
msgstr "貨幣未異動。"
#: src/accounting/currency/views.py:151
#: src/accounting/currency/views.py:149
msgid "The currency is updated successfully."
msgstr "貨幣存好了。"
#: src/accounting/currency/views.py:167
#: src/accounting/currency/views.py:164
msgid "The currency is deleted successfully."
msgstr "貨幣刪掉了"
@ -126,33 +126,52 @@ msgstr "貨幣刪掉了"
msgid "Please fill in the title."
msgstr "請填上標題。"
#: src/accounting/static/js/transaction-form.js:308
#: src/accounting/static/js/transaction-form.js:764
#: src/accounting/transaction/forms.py:46
#: src/accounting/static/js/summary-helper.js:441
#: src/accounting/static/js/summary-helper.js:512
msgid "Please fill in the tag."
msgstr "請填上標籤。"
#: src/accounting/static/js/summary-helper.js:460
#: src/accounting/static/js/summary-helper.js:550
msgid "Please fill in the origin."
msgstr "請填上起點。"
#: src/accounting/static/js/summary-helper.js:479
#: src/accounting/static/js/summary-helper.js:569
msgid "Please fill in the destination."
msgstr "請填上終點。"
#: src/accounting/static/js/summary-helper.js:531
msgid "Please fill in the route."
msgstr "請填上路線名稱。"
#: src/accounting/static/js/transaction-form.js:289
#: src/accounting/static/js/transaction-form.js:611
#: src/accounting/transaction/forms.py:47
msgid "Please select the account."
msgstr "請選擇科目。"
#: src/accounting/static/js/transaction-form.js:344
#: src/accounting/static/js/transaction-form.js:769
#: src/accounting/static/js/transaction-form.js:324
#: src/accounting/static/js/transaction-form.js:616
msgid "Please fill in the amount."
msgstr "請填上金額。"
#: src/accounting/static/js/transaction-form.js:641
#: src/accounting/static/js/transaction-form.js:488
msgid "Please fill in the date."
msgstr "請填上日期。"
#: src/accounting/static/js/transaction-form.js:676
#: src/accounting/transaction/forms.py:56
#: src/accounting/static/js/transaction-form.js:523
#: src/accounting/transaction/forms.py:57
msgid "Please add some currencies."
msgstr "請加上貨幣。"
#: src/accounting/static/js/transaction-form.js:742
#: src/accounting/transaction/forms.py:77
#: src/accounting/static/js/transaction-form.js:589
#: src/accounting/transaction/forms.py:78
msgid "Please add some journal entries."
msgstr "請加上分錄。"
#: src/accounting/static/js/transaction-form.js:807
#: src/accounting/transaction/forms.py:670
#: src/accounting/static/js/transaction-form.js:654
#: src/accounting/transaction/forms.py:672
msgid "The totals of the debit and credit amounts do not match."
msgstr "借方貸方合計不符。 "
@ -167,7 +186,7 @@ msgstr "新增科目"
#: src/accounting/templates/accounting/currency/detail.html:31
#: src/accounting/templates/accounting/currency/include/form.html:33
#: src/accounting/templates/accounting/transaction/include/detail.html:31
#: src/accounting/templates/accounting/transaction/include/form.html:34
#: src/accounting/templates/accounting/transaction/include/form.html:36
#: src/accounting/templates/accounting/transaction/order.html:36
msgid "Back"
msgstr "回上頁"
@ -185,7 +204,7 @@ msgstr "次序"
#: src/accounting/templates/accounting/account/detail.html:46
#: src/accounting/templates/accounting/currency/detail.html:42
#: src/accounting/templates/accounting/transaction/include/detail.html:46
#: src/accounting/templates/accounting/transaction/include/detail.html:47
msgid "Delete"
msgstr "刪除"
@ -196,10 +215,10 @@ msgstr "科目刪除確認"
#: src/accounting/templates/accounting/account/detail.html:70
#: src/accounting/templates/accounting/account/include/form.html:91
#: src/accounting/templates/accounting/currency/detail.html:66
#: src/accounting/templates/accounting/transaction/include/credit-account-modal.html:27
#: src/accounting/templates/accounting/transaction/include/debit-account-modal.html:27
#: src/accounting/templates/accounting/transaction/include/detail.html:70
#: src/accounting/templates/accounting/transaction/include/account-selector-modal.html:27
#: src/accounting/templates/accounting/transaction/include/detail.html:71
#: src/accounting/templates/accounting/transaction/include/entry-form-modal.html:28
#: src/accounting/templates/accounting/transaction/include/summary-helper-modal.html:30
msgid "Close"
msgstr "關閉"
@ -210,28 +229,28 @@ msgstr "你確定要刪掉這個科目嗎?"
#: src/accounting/templates/accounting/account/detail.html:76
#: src/accounting/templates/accounting/account/include/form.html:112
#: src/accounting/templates/accounting/currency/detail.html:72
#: src/accounting/templates/accounting/transaction/include/credit-account-modal.html:49
#: src/accounting/templates/accounting/transaction/include/debit-account-modal.html:49
#: src/accounting/templates/accounting/transaction/include/detail.html:76
#: src/accounting/templates/accounting/transaction/include/entry-form-modal.html:52
#: src/accounting/templates/accounting/transaction/include/account-selector-modal.html:49
#: src/accounting/templates/accounting/transaction/include/detail.html:77
#: src/accounting/templates/accounting/transaction/include/entry-form-modal.html:54
#: src/accounting/templates/accounting/transaction/include/summary-helper-modal.html:175
msgid "Cancel"
msgstr "取消"
#: src/accounting/templates/accounting/account/detail.html:77
#: src/accounting/templates/accounting/currency/detail.html:73
#: src/accounting/templates/accounting/transaction/include/detail.html:77
#: src/accounting/templates/accounting/transaction/include/detail.html:78
msgid "Confirm"
msgstr "確定"
#: src/accounting/templates/accounting/account/detail.html:94
#: src/accounting/templates/accounting/currency/detail.html:85
#: src/accounting/templates/accounting/transaction/include/detail.html:106
#: src/accounting/templates/accounting/transaction/include/detail.html:107
msgid "Created"
msgstr "建檔"
#: src/accounting/templates/accounting/account/detail.html:95
#: src/accounting/templates/accounting/currency/detail.html:86
#: src/accounting/templates/accounting/transaction/include/detail.html:107
#: src/accounting/templates/accounting/transaction/include/detail.html:108
msgid "Updated"
msgstr "更新"
@ -255,7 +274,7 @@ msgstr "科目管理"
#: src/accounting/templates/accounting/account/list.html:32
#: src/accounting/templates/accounting/currency/list.html:32
#: src/accounting/templates/accounting/transaction/expense/include/form-currency-item.html:75
#: src/accounting/templates/accounting/transaction/include/form.html:60
#: src/accounting/templates/accounting/transaction/include/form.html:62
#: src/accounting/templates/accounting/transaction/income/include/form-currency-item.html:75
#: src/accounting/templates/accounting/transaction/list.html:37
#: src/accounting/templates/accounting/transaction/transfer/include/form-currency-item.html:77
@ -276,8 +295,7 @@ msgstr "桌機版檢索"
#: src/accounting/templates/accounting/base-account/list.html:34
#: src/accounting/templates/accounting/currency/list.html:40
#: src/accounting/templates/accounting/currency/list.html:52
#: src/accounting/templates/accounting/transaction/include/credit-account-modal.html:34
#: src/accounting/templates/accounting/transaction/include/debit-account-modal.html:34
#: src/accounting/templates/accounting/transaction/include/account-selector-modal.html:34
#: src/accounting/templates/accounting/transaction/list.html:62
#: src/accounting/templates/accounting/transaction/list.html:74
msgid "Search"
@ -294,8 +312,7 @@ msgstr "行動版檢索"
#: src/accounting/templates/accounting/account/order.html:81
#: src/accounting/templates/accounting/base-account/list.html:51
#: src/accounting/templates/accounting/currency/list.html:77
#: src/accounting/templates/accounting/transaction/include/credit-account-modal.html:46
#: src/accounting/templates/accounting/transaction/include/debit-account-modal.html:46
#: src/accounting/templates/accounting/transaction/include/account-selector-modal.html:46
#: src/accounting/templates/accounting/transaction/list.html:93
#: src/accounting/templates/accounting/transaction/order.html:80
msgid "There is no data."
@ -309,8 +326,9 @@ msgstr "%(base)s下的科目"
#: src/accounting/templates/accounting/account/include/form.html:75
#: src/accounting/templates/accounting/account/order.html:62
#: src/accounting/templates/accounting/currency/include/form.html:57
#: src/accounting/templates/accounting/transaction/include/entry-form-modal.html:53
#: src/accounting/templates/accounting/transaction/include/form.html:76
#: src/accounting/templates/accounting/transaction/include/entry-form-modal.html:55
#: src/accounting/templates/accounting/transaction/include/form.html:78
#: src/accounting/templates/accounting/transaction/include/summary-helper-modal.html:176
#: src/accounting/templates/accounting/transaction/order.html:61
msgid "Save"
msgstr "儲存"
@ -337,8 +355,7 @@ msgstr "選擇基本科目"
#: src/accounting/templates/accounting/account/include/form.html:114
#: src/accounting/templates/accounting/account/include/form.html:116
#: src/accounting/templates/accounting/transaction/include/credit-account-modal.html:50
#: src/accounting/templates/accounting/transaction/include/debit-account-modal.html:50
#: src/accounting/templates/accounting/transaction/include/account-selector-modal.html:50
msgid "Clear"
msgstr "清除"
@ -375,23 +392,23 @@ msgstr "代碼"
msgid "Name"
msgstr "名稱"
#: src/accounting/templates/accounting/include/nav.html:26
#: src/accounting/templates/accounting/include/nav.html:27
msgid "Accounting"
msgstr "記帳"
#: src/accounting/templates/accounting/include/nav.html:32
#: src/accounting/templates/accounting/include/nav.html:33
msgid "Transactions"
msgstr "傳票"
#: src/accounting/templates/accounting/include/nav.html:38
#: src/accounting/templates/accounting/include/nav.html:39
msgid "Accounts"
msgstr "科目"
#: src/accounting/templates/accounting/include/nav.html:44
#: src/accounting/templates/accounting/include/nav.html:45
msgid "Base Accounts"
msgstr "基本科目"
#: src/accounting/templates/accounting/include/nav.html:50
#: src/accounting/templates/accounting/include/nav.html:51
msgid "Currencies"
msgstr "貨幣"
@ -425,17 +442,22 @@ msgstr "%(date)s的傳票"
msgid "Add a New Cash Expense Transaction"
msgstr "新增現金支出傳票"
#: src/accounting/templates/accounting/transaction/expense/detail.html:30
#: src/accounting/templates/accounting/transaction/expense/detail.html:27
#: src/accounting/templates/accounting/transaction/income/detail.html:27
msgid "To Transfer"
msgstr "改轉帳"
#: src/accounting/templates/accounting/transaction/expense/detail.html:37
#: src/accounting/templates/accounting/transaction/expense/include/form-currency-item.html:45
#: src/accounting/templates/accounting/transaction/include/form.html:52
#: src/accounting/templates/accounting/transaction/income/detail.html:30
#: src/accounting/templates/accounting/transaction/include/form.html:54
#: src/accounting/templates/accounting/transaction/income/detail.html:37
#: src/accounting/templates/accounting/transaction/income/include/form-currency-item.html:45
msgid "Content"
msgstr "內容"
#: src/accounting/templates/accounting/transaction/expense/detail.html:46
#: src/accounting/templates/accounting/transaction/expense/detail.html:53
#: src/accounting/templates/accounting/transaction/expense/include/form-currency-item.html:68
#: src/accounting/templates/accounting/transaction/income/detail.html:46
#: src/accounting/templates/accounting/transaction/income/detail.html:53
#: src/accounting/templates/accounting/transaction/income/include/form-currency-item.html:68
#: src/accounting/templates/accounting/transaction/transfer/detail.html:49
#: src/accounting/templates/accounting/transaction/transfer/detail.html:75
@ -457,6 +479,14 @@ msgstr "編輯%(txn)s"
msgid "Currency"
msgstr "貨幣"
#: src/accounting/templates/accounting/transaction/include/account-selector-modal.html:26
msgid "Select Account"
msgstr "選擇科目"
#: src/accounting/templates/accounting/transaction/include/account-selector-modal.html:44
msgid "More…"
msgstr "更多…"
#: src/accounting/templates/accounting/transaction/include/add-new-material-fab.html:26
msgid "Cash expense"
msgstr "現金支出"
@ -465,24 +495,11 @@ msgstr "現金支出"
msgid "Cash income"
msgstr "現金收入"
#: src/accounting/templates/accounting/transaction/include/credit-account-modal.html:26
msgid "Select Credit Account"
msgstr "選擇貸方科目科目"
#: src/accounting/templates/accounting/transaction/include/credit-account-modal.html:44
#: src/accounting/templates/accounting/transaction/include/debit-account-modal.html:44
msgid "More…"
msgstr "更多…"
#: src/accounting/templates/accounting/transaction/include/debit-account-modal.html:26
msgid "Select Debit Account"
msgstr "選擇借方科目"
#: src/accounting/templates/accounting/transaction/include/detail.html:69
#: src/accounting/templates/accounting/transaction/include/detail.html:70
msgid "Delete Transaction Confirmation"
msgstr "傳票刪除確認"
#: src/accounting/templates/accounting/transaction/include/detail.html:73
#: src/accounting/templates/accounting/transaction/include/detail.html:74
msgid "Do you really want to delete this transaction?"
msgstr "你確定要刪掉這張傳票嗎?"
@ -495,21 +512,66 @@ msgid "Account"
msgstr "科目"
#: src/accounting/templates/accounting/transaction/include/entry-form-modal.html:41
#: src/accounting/templates/accounting/transaction/include/summary-helper-modal.html:28
msgid "Summary"
msgstr "摘要"
#: src/accounting/templates/accounting/transaction/include/entry-form-modal.html:47
#: src/accounting/templates/accounting/transaction/include/entry-form-modal.html:49
msgid "Amount"
msgstr "金額"
#: src/accounting/templates/accounting/transaction/include/form.html:46
#: src/accounting/templates/accounting/transaction/include/form.html:48
msgid "Date"
msgstr "日期"
#: src/accounting/templates/accounting/transaction/include/form.html:69
#: src/accounting/templates/accounting/transaction/include/form.html:71
msgid "Note"
msgstr "備註"
#: src/accounting/templates/accounting/transaction/include/summary-helper-modal.html:39
msgid "General"
msgstr "一般"
#: src/accounting/templates/accounting/transaction/include/summary-helper-modal.html:44
msgid "Travel"
msgstr "差旅"
#: src/accounting/templates/accounting/transaction/include/summary-helper-modal.html:49
msgid "Bus"
msgstr "公車"
#: src/accounting/templates/accounting/transaction/include/summary-helper-modal.html:54
msgid "Regular"
msgstr "帳單"
#: src/accounting/templates/accounting/transaction/include/summary-helper-modal.html:59
msgid "Number"
msgstr "數量"
#: src/accounting/templates/accounting/transaction/include/summary-helper-modal.html:67
#: src/accounting/templates/accounting/transaction/include/summary-helper-modal.html:84
#: src/accounting/templates/accounting/transaction/include/summary-helper-modal.html:119
msgid "Tag"
msgstr "標籤"
#: src/accounting/templates/accounting/transaction/include/summary-helper-modal.html:99
#: src/accounting/templates/accounting/transaction/include/summary-helper-modal.html:140
msgid "From"
msgstr "從"
#: src/accounting/templates/accounting/transaction/include/summary-helper-modal.html:108
#: src/accounting/templates/accounting/transaction/include/summary-helper-modal.html:145
msgid "To"
msgstr "至"
#: src/accounting/templates/accounting/transaction/include/summary-helper-modal.html:124
msgid "Route"
msgstr "路線"
#: src/accounting/templates/accounting/transaction/include/summary-helper-modal.html:160
msgid "The number of items"
msgstr "數量"
#: src/accounting/templates/accounting/transaction/income/create.html:24
msgid "Add a New Cash Income Transaction"
msgstr "新增現金收入傳票"
@ -528,63 +590,63 @@ msgstr "借方"
msgid "Credit"
msgstr "貸方"
#: src/accounting/transaction/forms.py:44
#: src/accounting/transaction/forms.py:45
msgid "Please select the currency."
msgstr "請選擇貨幣。"
#: src/accounting/transaction/forms.py:67
#: src/accounting/transaction/forms.py:68
msgid "The currency does not exist."
msgstr "沒有這個貨幣。"
#: src/accounting/transaction/forms.py:88
#: src/accounting/transaction/forms.py:89
msgid "The account does not exist."
msgstr "沒有這個科目。"
#: src/accounting/transaction/forms.py:99
#: src/accounting/transaction/forms.py:100
msgid "Please fill in a positive amount."
msgstr "金額請填正數。"
#: src/accounting/transaction/forms.py:113
#: src/accounting/transaction/forms.py:114
msgid "This account is not for debit entries."
msgstr "科目不是借方科目。"
#: src/accounting/transaction/forms.py:200
#: src/accounting/transaction/forms.py:201
msgid "This account is not for credit entries."
msgstr "科目不是貸方科目。"
#: src/accounting/transaction/template.py:71
#: src/accounting/transaction/template.py:97
msgid "Today"
msgstr "今天"
#: src/accounting/transaction/template.py:73
#: src/accounting/transaction/template.py:99
msgid "Yesterday"
msgstr "昨天"
#: src/accounting/transaction/template.py:75
#: src/accounting/transaction/template.py:101
msgid "Tomorrow"
msgstr "明天"
#: src/accounting/transaction/template.py:79
#: src/accounting/transaction/template.py:105
msgid "The day before yesterday"
msgstr "前天"
#: src/accounting/transaction/template.py:81
#: src/accounting/transaction/template.py:107
msgid "The day after tomorrow"
msgstr "後天"
#: src/accounting/transaction/views.py:104
#: src/accounting/transaction/views.py:108
msgid "The transaction is added successfully"
msgstr "傳票加好了。"
#: src/accounting/transaction/views.py:158
#: src/accounting/transaction/views.py:162
msgid "The transaction was not modified."
msgstr "傳票未異動。"
#: src/accounting/transaction/views.py:163
#: src/accounting/transaction/views.py:167
msgid "The transaction is updated successfully."
msgstr "傳票存好了。"
#: src/accounting/transaction/views.py:179
#: src/accounting/transaction/views.py:183
msgid "The transaction is deleted successfully."
msgstr "傳票刪掉了"

View File

@ -62,7 +62,7 @@ stock: AccountData = AccountData("1121", 1, "Stock")
loan: AccountData = AccountData("2112", 1, "Loan")
"""The loan account."""
PREFIX: str = "/accounting/accounts"
"""The URL prefix of the currency management."""
"""The URL prefix for the account management."""
class AccountCommandTestCase(unittest.TestCase):
@ -409,9 +409,9 @@ class AccountTestCase(unittest.TestCase):
f"{stock.base_code}-002",
f"{stock.base_code}-003"})
stock_account: Account = Account.find_by_code(stock.code)
self.assertEqual(stock_account.base_code, stock.base_code)
self.assertEqual(stock_account.title_l10n, stock.title)
account: Account = Account.find_by_code(stock.code)
self.assertEqual(account.base_code, stock.base_code)
self.assertEqual(account.title_l10n, stock.title)
def test_basic_update(self) -> None:
"""Tests the basic rules to update a user.
@ -434,9 +434,9 @@ class AccountTestCase(unittest.TestCase):
self.assertEqual(response.headers["Location"], detail_uri)
with self.app.app_context():
cash_account: Account = Account.find_by_code(cash.code)
self.assertEqual(cash_account.base_code, cash.base_code)
self.assertEqual(cash_account.title_l10n, f"{cash.title}-1")
account: Account = Account.find_by_code(cash.code)
self.assertEqual(account.base_code, cash.base_code)
self.assertEqual(account.title_l10n, f"{cash.title}-1")
# Empty base account code
response = self.client.post(update_uri,
@ -492,7 +492,7 @@ class AccountTestCase(unittest.TestCase):
from accounting.models import Account
detail_uri: str = f"{PREFIX}/{cash.code}"
update_uri: str = f"{PREFIX}/{cash.code}/update"
cash_account: Account
account: Account
response: httpx.Response
response = self.client.post(update_uri,
@ -503,11 +503,11 @@ class AccountTestCase(unittest.TestCase):
self.assertEqual(response.headers["Location"], detail_uri)
with self.app.app_context():
cash_account = Account.find_by_code(cash.code)
self.assertIsNotNone(cash_account)
cash_account.created_at \
= cash_account.created_at - timedelta(seconds=5)
cash_account.updated_at = cash_account.created_at
account = Account.find_by_code(cash.code)
self.assertIsNotNone(account)
account.created_at \
= account.created_at - timedelta(seconds=5)
account.updated_at = account.created_at
db.session.commit()
response = self.client.post(update_uri,
@ -518,10 +518,10 @@ class AccountTestCase(unittest.TestCase):
self.assertEqual(response.headers["Location"], detail_uri)
with self.app.app_context():
cash_account = Account.find_by_code(cash.code)
self.assertIsNotNone(cash_account)
self.assertLess(cash_account.created_at,
cash_account.updated_at)
account = Account.find_by_code(cash.code)
self.assertIsNotNone(account)
self.assertLess(account.created_at,
account.updated_at)
def test_created_updated_by(self) -> None:
"""Tests the created-by and updated-by record.
@ -533,12 +533,13 @@ class AccountTestCase(unittest.TestCase):
client, csrf_token = get_client(self.app, editor2_username)
detail_uri: str = f"{PREFIX}/{cash.code}"
update_uri: str = f"{PREFIX}/{cash.code}/update"
account: Account
response: httpx.Response
with self.app.app_context():
cash_account: Account = Account.find_by_code(cash.code)
self.assertEqual(cash_account.created_by.username, editor_username)
self.assertEqual(cash_account.updated_by.username, editor_username)
account = Account.find_by_code(cash.code)
self.assertEqual(account.created_by.username, editor_username)
self.assertEqual(account.updated_by.username, editor_username)
response = client.post(update_uri,
data={"csrf_token": csrf_token,
@ -548,10 +549,10 @@ class AccountTestCase(unittest.TestCase):
self.assertEqual(response.headers["Location"], detail_uri)
with self.app.app_context():
cash_account: Account = Account.find_by_code(cash.code)
self.assertEqual(cash_account.created_by.username,
account = Account.find_by_code(cash.code)
self.assertEqual(account.created_by.username,
editor_username)
self.assertEqual(cash_account.updated_by.username,
self.assertEqual(account.updated_by.username,
editor2_username)
def test_l10n(self) -> None:
@ -562,12 +563,13 @@ class AccountTestCase(unittest.TestCase):
from accounting.models import Account
detail_uri: str = f"{PREFIX}/{cash.code}"
update_uri: str = f"{PREFIX}/{cash.code}/update"
account: Account
response: httpx.Response
with self.app.app_context():
cash_account: Account = Account.find_by_code(cash.code)
self.assertEqual(cash_account.title_l10n, cash.title)
self.assertEqual(cash_account.l10n, [])
account = Account.find_by_code(cash.code)
self.assertEqual(account.title_l10n, cash.title)
self.assertEqual(account.l10n, [])
set_locale(self.client, self.csrf_token, "zh_Hant")
@ -579,9 +581,9 @@ class AccountTestCase(unittest.TestCase):
self.assertEqual(response.headers["Location"], detail_uri)
with self.app.app_context():
cash_account: Account = Account.find_by_code(cash.code)
self.assertEqual(cash_account.title_l10n, cash.title)
self.assertEqual({(x.locale, x.title) for x in cash_account.l10n},
account = Account.find_by_code(cash.code)
self.assertEqual(account.title_l10n, cash.title)
self.assertEqual({(x.locale, x.title) for x in account.l10n},
{("zh_Hant", f"{cash.title}-zh_Hant")})
set_locale(self.client, self.csrf_token, "en")
@ -594,9 +596,9 @@ class AccountTestCase(unittest.TestCase):
self.assertEqual(response.headers["Location"], detail_uri)
with self.app.app_context():
cash_account: Account = Account.find_by_code(cash.code)
self.assertEqual(cash_account.title_l10n, f"{cash.title}-2")
self.assertEqual({(x.locale, x.title) for x in cash_account.l10n},
account = Account.find_by_code(cash.code)
self.assertEqual(account.title_l10n, f"{cash.title}-2")
self.assertEqual({(x.locale, x.title) for x in account.l10n},
{("zh_Hant", f"{cash.title}-zh_Hant")})
set_locale(self.client, self.csrf_token, "zh_Hant")
@ -609,9 +611,9 @@ class AccountTestCase(unittest.TestCase):
self.assertEqual(response.headers["Location"], detail_uri)
with self.app.app_context():
cash_account: Account = Account.find_by_code(cash.code)
self.assertEqual(cash_account.title_l10n, f"{cash.title}-2")
self.assertEqual({(x.locale, x.title) for x in cash_account.l10n},
account = Account.find_by_code(cash.code)
self.assertEqual(account.title_l10n, f"{cash.title}-2")
self.assertEqual({(x.locale, x.title) for x in account.l10n},
{("zh_Hant", f"{cash.title}-zh_Hant-2")})
def test_delete(self) -> None:

View File

@ -55,7 +55,7 @@ zzc: CurrencyData = CurrencyData("ZZC", "Testing Dollar #C")
zzd: CurrencyData = CurrencyData("ZZD", "Testing Dollar #D")
"""The fourth test currency."""
PREFIX: str = "/accounting/currencies"
"""The URL prefix of the currency management."""
"""The URL prefix for the currency management."""
class CurrencyCommandTestCase(unittest.TestCase):
@ -342,9 +342,9 @@ class CurrencyTestCase(unittest.TestCase):
self.assertEqual({x.code for x in Currency.query.all()},
{zza.code, zzb.code, zzc.code})
zzc_currency: Currency = db.session.get(Currency, zzc.code)
self.assertEqual(zzc_currency.code, zzc.code)
self.assertEqual(zzc_currency.name_l10n, zzc.name)
currency: Currency = db.session.get(Currency, zzc.code)
self.assertEqual(currency.code, zzc.code)
self.assertEqual(currency.name_l10n, zzc.name)
def test_basic_update(self) -> None:
"""Tests the basic rules to update a user.
@ -367,9 +367,9 @@ class CurrencyTestCase(unittest.TestCase):
self.assertEqual(response.headers["Location"], detail_uri)
with self.app.app_context():
zza_currency: Currency = db.session.get(Currency, zza.code)
self.assertEqual(zza_currency.code, zza.code)
self.assertEqual(zza_currency.name_l10n, f"{zza.name}-1")
currency: Currency = db.session.get(Currency, zza.code)
self.assertEqual(currency.code, zza.code)
self.assertEqual(currency.name_l10n, f"{zza.name}-1")
# Empty code
response = self.client.post(update_uri,
@ -433,7 +433,7 @@ class CurrencyTestCase(unittest.TestCase):
from accounting.models import Currency
detail_uri: str = f"{PREFIX}/{zza.code}"
update_uri: str = f"{PREFIX}/{zza.code}/update"
zza_currency: Currency
currency: Currency | None
response: httpx.Response
response = self.client.post(update_uri,
@ -444,11 +444,11 @@ class CurrencyTestCase(unittest.TestCase):
self.assertEqual(response.headers["Location"], detail_uri)
with self.app.app_context():
zza_currency = db.session.get(Currency, zza.code)
self.assertIsNotNone(zza_currency)
zza_currency.created_at \
= zza_currency.created_at - timedelta(seconds=5)
zza_currency.updated_at = zza_currency.created_at
currency = db.session.get(Currency, zza.code)
self.assertIsNotNone(currency)
currency.created_at \
= currency.created_at - timedelta(seconds=5)
currency.updated_at = currency.created_at
db.session.commit()
response = self.client.post(update_uri,
@ -459,10 +459,10 @@ class CurrencyTestCase(unittest.TestCase):
self.assertEqual(response.headers["Location"], detail_uri)
with self.app.app_context():
zza_currency = db.session.get(Currency, zza.code)
self.assertIsNotNone(zza_currency)
self.assertLess(zza_currency.created_at,
zza_currency.updated_at)
currency = db.session.get(Currency, zza.code)
self.assertIsNotNone(currency)
self.assertLess(currency.created_at,
currency.updated_at)
def test_created_updated_by(self) -> None:
"""Tests the created-by and updated-by record.
@ -474,12 +474,13 @@ class CurrencyTestCase(unittest.TestCase):
client, csrf_token = get_client(self.app, editor2_username)
detail_uri: str = f"{PREFIX}/{zza.code}"
update_uri: str = f"{PREFIX}/{zza.code}/update"
currency: Currency
response: httpx.Response
with self.app.app_context():
zza_currency: Currency = db.session.get(Currency, zza.code)
self.assertEqual(zza_currency.created_by.username, editor_username)
self.assertEqual(zza_currency.updated_by.username, editor_username)
currency = db.session.get(Currency, zza.code)
self.assertEqual(currency.created_by.username, editor_username)
self.assertEqual(currency.updated_by.username, editor_username)
response = client.post(update_uri,
data={"csrf_token": csrf_token,
@ -489,9 +490,9 @@ class CurrencyTestCase(unittest.TestCase):
self.assertEqual(response.headers["Location"], detail_uri)
with self.app.app_context():
zza_currency: Currency = db.session.get(Currency, zza.code)
self.assertEqual(zza_currency.created_by.username, editor_username)
self.assertEqual(zza_currency.updated_by.username, editor2_username)
currency = db.session.get(Currency, zza.code)
self.assertEqual(currency.created_by.username, editor_username)
self.assertEqual(currency.updated_by.username, editor2_username)
def test_api_exists(self) -> None:
"""Tests the API to check if a code exists.
@ -522,12 +523,13 @@ class CurrencyTestCase(unittest.TestCase):
from accounting.models import Currency
detail_uri: str = f"{PREFIX}/{zza.code}"
update_uri: str = f"{PREFIX}/{zza.code}/update"
currency: Currency
response: httpx.Response
with self.app.app_context():
zza_currency: Currency = db.session.get(Currency, zza.code)
self.assertEqual(zza_currency.name_l10n, zza.name)
self.assertEqual(zza_currency.l10n, [])
currency = db.session.get(Currency, zza.code)
self.assertEqual(currency.name_l10n, zza.name)
self.assertEqual(currency.l10n, [])
set_locale(self.client, self.csrf_token, "zh_Hant")
@ -539,9 +541,9 @@ class CurrencyTestCase(unittest.TestCase):
self.assertEqual(response.headers["Location"], detail_uri)
with self.app.app_context():
zza_currency: Currency = db.session.get(Currency, zza.code)
self.assertEqual(zza_currency.name_l10n, zza.name)
self.assertEqual({(x.locale, x.name) for x in zza_currency.l10n},
currency = db.session.get(Currency, zza.code)
self.assertEqual(currency.name_l10n, zza.name)
self.assertEqual({(x.locale, x.name) for x in currency.l10n},
{("zh_Hant", f"{zza.name}-zh_Hant")})
set_locale(self.client, self.csrf_token, "en")
@ -554,9 +556,9 @@ class CurrencyTestCase(unittest.TestCase):
self.assertEqual(response.headers["Location"], detail_uri)
with self.app.app_context():
zza_currency: Currency = db.session.get(Currency, zza.code)
self.assertEqual(zza_currency.name_l10n, f"{zza.name}-2")
self.assertEqual({(x.locale, x.name) for x in zza_currency.l10n},
currency = db.session.get(Currency, zza.code)
self.assertEqual(currency.name_l10n, f"{zza.name}-2")
self.assertEqual({(x.locale, x.name) for x in currency.l10n},
{("zh_Hant", f"{zza.name}-zh_Hant")})
set_locale(self.client, self.csrf_token, "zh_Hant")
@ -569,9 +571,9 @@ class CurrencyTestCase(unittest.TestCase):
self.assertEqual(response.headers["Location"], detail_uri)
with self.app.app_context():
zza_currency: Currency = db.session.get(Currency, zza.code)
self.assertEqual(zza_currency.name_l10n, f"{zza.name}-2")
self.assertEqual({(x.locale, x.name) for x in zza_currency.l10n},
currency = db.session.get(Currency, zza.code)
self.assertEqual(currency.name_l10n, f"{zza.name}-2")
self.assertEqual({(x.locale, x.name) for x in currency.l10n},
{("zh_Hant", f"{zza.name}-zh_Hant-2")})
def test_delete(self) -> None:

View File

@ -0,0 +1,327 @@
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/28
# 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 test for the summary editor.
"""
import unittest
from datetime import date
from click.testing import Result
from flask import Flask
from flask.testing import FlaskCliRunner
from test_site import create_app
from testlib import get_client
from testlib_txn import Accounts, NEXT_URI, add_txn
class SummeryEditorTestCase(unittest.TestCase):
"""The summary editor test case."""
def setUp(self) -> None:
"""Sets up the test.
This is run once per test.
:return: None.
"""
self.app: Flask = create_app(is_testing=True)
runner: FlaskCliRunner = self.app.test_cli_runner()
with self.app.app_context():
from accounting.models import BaseAccount, Transaction, \
JournalEntry
result: Result
result = runner.invoke(args="init-db")
self.assertEqual(result.exit_code, 0)
if BaseAccount.query.first() is None:
result = runner.invoke(args="accounting-init-base")
self.assertEqual(result.exit_code, 0)
result = runner.invoke(args=["accounting-init-currencies",
"-u", "editor"])
self.assertEqual(result.exit_code, 0)
result = runner.invoke(args=["accounting-init-accounts",
"-u", "editor"])
self.assertEqual(result.exit_code, 0)
Transaction.query.delete()
JournalEntry.query.delete()
self.client, self.csrf_token = get_client(self.app, "editor")
def test_summary_editor(self) -> None:
"""Test the summary editor.
:return: None.
"""
from accounting.transaction.summary_editor import SummaryEditor
for form in get_form_data(self.csrf_token):
add_txn(self.client, form)
with self.app.app_context():
editor: SummaryEditor = SummaryEditor()
# Debit-General
self.assertEqual(len(editor.debit.general.tags), 2)
self.assertEqual(editor.debit.general.tags[0].name, "Lunch")
self.assertEqual(len(editor.debit.general.tags[0].accounts), 2)
self.assertEqual(editor.debit.general.tags[0].accounts[0].code,
Accounts.MEAL)
self.assertEqual(editor.debit.general.tags[0].accounts[1].code,
Accounts.PAYABLE)
self.assertEqual(editor.debit.general.tags[1].name, "Dinner")
self.assertEqual(len(editor.debit.general.tags[1].accounts), 2)
self.assertEqual(editor.debit.general.tags[1].accounts[0].code,
Accounts.MEAL)
self.assertEqual(editor.debit.general.tags[1].accounts[1].code,
Accounts.PAYABLE)
# Debit-Travel
self.assertEqual(len(editor.debit.travel.tags), 3)
self.assertEqual(editor.debit.travel.tags[0].name, "Bike")
self.assertEqual(len(editor.debit.travel.tags[0].accounts), 1)
self.assertEqual(editor.debit.travel.tags[0].accounts[0].code,
Accounts.TRAVEL)
self.assertEqual(editor.debit.travel.tags[1].name, "Taxi")
self.assertEqual(len(editor.debit.travel.tags[1].accounts), 1)
self.assertEqual(editor.debit.travel.tags[1].accounts[0].code,
Accounts.TRAVEL)
self.assertEqual(editor.debit.travel.tags[2].name, "Airplane")
self.assertEqual(len(editor.debit.travel.tags[2].accounts), 1)
self.assertEqual(editor.debit.travel.tags[2].accounts[0].code,
Accounts.TRAVEL)
# Debit-Bus
self.assertEqual(len(editor.debit.bus.tags), 2)
self.assertEqual(editor.debit.bus.tags[0].name, "Train")
self.assertEqual(len(editor.debit.bus.tags[0].accounts), 1)
self.assertEqual(editor.debit.bus.tags[0].accounts[0].code,
Accounts.TRAVEL)
self.assertEqual(editor.debit.bus.tags[1].name, "Bus")
self.assertEqual(len(editor.debit.bus.tags[1].accounts), 1)
self.assertEqual(editor.debit.bus.tags[1].accounts[0].code,
Accounts.TRAVEL)
# Credit-General
self.assertEqual(len(editor.credit.general.tags), 2)
self.assertEqual(editor.credit.general.tags[0].name, "Lunch")
self.assertEqual(len(editor.credit.general.tags[0].accounts), 3)
self.assertEqual(editor.credit.general.tags[0].accounts[0].code,
Accounts.PAYABLE)
self.assertEqual(editor.credit.general.tags[0].accounts[1].code,
Accounts.BANK)
self.assertEqual(editor.credit.general.tags[0].accounts[2].code,
Accounts.CASH)
self.assertEqual(editor.credit.general.tags[1].name, "Dinner")
self.assertEqual(len(editor.credit.general.tags[1].accounts), 2)
self.assertEqual(editor.credit.general.tags[1].accounts[0].code,
Accounts.BANK)
self.assertEqual(editor.credit.general.tags[1].accounts[1].code,
Accounts.PAYABLE)
# Credit-Travel
self.assertEqual(len(editor.credit.travel.tags), 2)
self.assertEqual(editor.credit.travel.tags[0].name, "Bike")
self.assertEqual(len(editor.credit.travel.tags[0].accounts), 2)
self.assertEqual(editor.credit.travel.tags[0].accounts[0].code,
Accounts.PAYABLE)
self.assertEqual(editor.credit.travel.tags[0].accounts[1].code,
Accounts.PREPAID)
self.assertEqual(editor.credit.travel.tags[1].name, "Taxi")
self.assertEqual(len(editor.credit.travel.tags[1].accounts), 2)
self.assertEqual(editor.credit.travel.tags[1].accounts[0].code,
Accounts.PAYABLE)
self.assertEqual(editor.credit.travel.tags[1].accounts[1].code,
Accounts.CASH)
# Credit-Bus
self.assertEqual(len(editor.credit.bus.tags), 2)
self.assertEqual(editor.credit.bus.tags[0].name, "Train")
self.assertEqual(len(editor.credit.bus.tags[0].accounts), 2)
self.assertEqual(editor.credit.bus.tags[0].accounts[0].code,
Accounts.PREPAID)
self.assertEqual(editor.credit.bus.tags[0].accounts[1].code,
Accounts.PAYABLE)
self.assertEqual(editor.credit.bus.tags[1].name, "Bus")
self.assertEqual(len(editor.credit.bus.tags[1].accounts), 1)
self.assertEqual(editor.credit.bus.tags[1].accounts[0].code,
Accounts.PREPAID)
def get_form_data(csrf_token: str) -> list[dict[str, str]]:
"""Returns the form data for multiple transaction forms.
:param csrf_token: The CSRF token.
:return: A list of the form data.
"""
txn_date: str = date.today().isoformat()
return [{"csrf_token": csrf_token,
"next": NEXT_URI,
"date": txn_date,
"currency-0-code": "USD",
"currency-0-credit-0-account_code": Accounts.SERVICE,
"currency-0-credit-0-summary": " Salary ",
"currency-0-credit-0-amount": "2500"},
{"csrf_token": csrf_token,
"next": NEXT_URI,
"date": txn_date,
"currency-0-code": "USD",
"currency-0-debit-0-account_code": Accounts.MEAL,
"currency-0-debit-0-summary": " Lunch—Fish ",
"currency-0-debit-0-amount": "4.7",
"currency-0-credit-0-account_code": Accounts.BANK,
"currency-0-credit-0-summary": " Lunch—Fish ",
"currency-0-credit-0-amount": "4.7",
"currency-0-debit-1-account_code": Accounts.MEAL,
"currency-0-debit-1-summary": " Lunch—Fries ",
"currency-0-debit-1-amount": "2.15",
"currency-0-credit-1-account_code": Accounts.PAYABLE,
"currency-0-credit-1-summary": " Lunch—Fries ",
"currency-0-credit-1-amount": "2.15",
"currency-0-debit-2-account_code": Accounts.MEAL,
"currency-0-debit-2-summary": " Dinner—Hamburger ",
"currency-0-debit-2-amount": "4.25",
"currency-0-credit-2-account_code": Accounts.BANK,
"currency-0-credit-2-summary": " Dinner—Hamburger ",
"currency-0-credit-2-amount": "4.25"},
{"csrf_token": csrf_token,
"next": NEXT_URI,
"date": txn_date,
"currency-0-code": "USD",
"currency-0-debit-0-account_code": Accounts.MEAL,
"currency-0-debit-0-summary": " Lunch—Salad ",
"currency-0-debit-0-amount": "4.99",
"currency-0-credit-0-account_code": Accounts.CASH,
"currency-0-credit-0-summary": " Lunch—Salad ",
"currency-0-credit-0-amount": "4.99",
"currency-0-debit-1-account_code": Accounts.MEAL,
"currency-0-debit-1-summary": " Dinner—Steak ",
"currency-0-debit-1-amount": "8.28",
"currency-0-credit-1-account_code": Accounts.PAYABLE,
"currency-0-credit-1-summary": " Dinner—Steak ",
"currency-0-credit-1-amount": "8.28"},
{"csrf_token": csrf_token,
"next": NEXT_URI,
"date": txn_date,
"currency-0-code": "USD",
"currency-0-debit-0-account_code": Accounts.MEAL,
"currency-0-debit-0-summary": " Lunch—Pizza ",
"currency-0-debit-0-amount": "5.49",
"currency-0-credit-0-account_code": Accounts.PAYABLE,
"currency-0-credit-0-summary": " Lunch—Pizza ",
"currency-0-credit-0-amount": "5.49",
"currency-0-debit-1-account_code": Accounts.MEAL,
"currency-0-debit-1-summary": " Lunch—Noodles ",
"currency-0-debit-1-amount": "7.47",
"currency-0-credit-1-account_code": Accounts.PAYABLE,
"currency-0-credit-1-summary": " Lunch—Noodles ",
"currency-0-credit-1-amount": "7.47"},
{"csrf_token": csrf_token,
"next": NEXT_URI,
"date": txn_date,
"currency-0-code": "USD",
"currency-0-debit-0-account_code": Accounts.TRAVEL,
"currency-0-debit-0-summary": " Airplane—Lake City↔Hill Town ",
"currency-0-debit-0-amount": "800"},
{"csrf_token": csrf_token,
"next": NEXT_URI,
"date": txn_date,
"currency-0-code": "USD",
"currency-0-debit-0-account_code": Accounts.TRAVEL,
"currency-0-debit-0-summary": " Bus—323—Downtown→Museum ",
"currency-0-debit-0-amount": "2.5",
"currency-0-credit-0-account_code": Accounts.PREPAID,
"currency-0-credit-0-summary": " Bus—323—Downtown→Museum ",
"currency-0-credit-0-amount": "2.5",
"currency-0-debit-1-account_code": Accounts.TRAVEL,
"currency-0-debit-1-summary": " Train—Blue—Museum→Central ",
"currency-0-debit-1-amount": "3.2",
"currency-0-credit-1-account_code": Accounts.PREPAID,
"currency-0-credit-1-summary": " Train—Blue—Museum→Central ",
"currency-0-credit-1-amount": "3.2",
"currency-0-debit-2-account_code": Accounts.TRAVEL,
"currency-0-debit-2-summary": " Train—Green—Central→Mall ",
"currency-0-debit-2-amount": "3.2",
"currency-0-credit-2-account_code": Accounts.PREPAID,
"currency-0-credit-2-summary": " Train—Green—Central→Mall ",
"currency-0-credit-2-amount": "3.2",
"currency-0-debit-3-account_code": Accounts.TRAVEL,
"currency-0-debit-3-summary": " Train—Red—Mall→Museum ",
"currency-0-debit-3-amount": "4.4",
"currency-0-credit-3-account_code": Accounts.PAYABLE,
"currency-0-credit-3-summary": " Train—Red—Mall→Museum ",
"currency-0-credit-3-amount": "4.4"},
{"csrf_token": csrf_token,
"next": NEXT_URI,
"date": txn_date,
"currency-0-code": "USD",
"currency-0-debit-0-account_code": Accounts.TRAVEL,
"currency-0-debit-0-summary": " Taxi—Museum→Office ",
"currency-0-debit-0-amount": "15.5",
"currency-0-credit-0-account_code": Accounts.CASH,
"currency-0-credit-0-summary": " Taxi—Museum→Office ",
"currency-0-credit-0-amount": "15.5",
"currency-0-debit-1-account_code": Accounts.TRAVEL,
"currency-0-debit-1-summary": " Taxi—Office→Restaurant ",
"currency-0-debit-1-amount": "12",
"currency-0-credit-1-account_code": Accounts.PAYABLE,
"currency-0-credit-1-summary": " Taxi—Office→Restaurant ",
"currency-0-credit-1-amount": "12",
"currency-0-debit-2-account_code": Accounts.TRAVEL,
"currency-0-debit-2-summary": " Taxi—Restaurant→City Hall ",
"currency-0-debit-2-amount": "8",
"currency-0-credit-2-account_code": Accounts.PAYABLE,
"currency-0-credit-2-summary": " Taxi—Restaurant→City Hall ",
"currency-0-credit-2-amount": "8",
"currency-0-debit-3-account_code": Accounts.TRAVEL,
"currency-0-debit-3-summary": " Bike—City Hall→Office ",
"currency-0-debit-3-amount": "3.5",
"currency-0-credit-3-account_code": Accounts.PAYABLE,
"currency-0-credit-3-summary": " Bike—City Hall→Office ",
"currency-0-credit-3-amount": "3.5",
"currency-0-debit-4-account_code": Accounts.TRAVEL,
"currency-0-debit-4-summary": " Bike—Restaurant→Office ",
"currency-0-debit-4-amount": "4",
"currency-0-credit-4-account_code": Accounts.PAYABLE,
"currency-0-credit-4-summary": " Bike—Restaurant→Office ",
"currency-0-credit-4-amount": "4",
"currency-0-debit-5-account_code": Accounts.TRAVEL,
"currency-0-debit-5-summary": " Bike—Office→Theatre ",
"currency-0-debit-5-amount": "1.5",
"currency-0-credit-5-account_code": Accounts.PAYABLE,
"currency-0-credit-5-summary": " Bike—Office→Theatre ",
"currency-0-credit-5-amount": "1.5",
"currency-0-debit-6-account_code": Accounts.TRAVEL,
"currency-0-debit-6-summary": " Bike—Theatre→Home ",
"currency-0-debit-6-amount": "5.5",
"currency-0-credit-6-account_code": Accounts.PREPAID,
"currency-0-credit-6-summary": " Bike—Theatre→Home ",
"currency-0-credit-6-amount": "5.5"},
{"csrf_token": csrf_token,
"next": NEXT_URI,
"date": txn_date,
"currency-0-code": "USD",
"currency-0-debit-0-account_code": Accounts.PAYABLE,
"currency-0-debit-0-summary": " Dinner—Steak ",
"currency-0-debit-0-amount": "8.28",
"currency-0-credit-0-account_code": Accounts.BANK,
"currency-0-credit-0-summary": " Dinner—Steak ",
"currency-0-credit-0-amount": "8.28",
"currency-0-debit-1-account_code": Accounts.PAYABLE,
"currency-0-debit-1-summary": " Lunch—Pizza ",
"currency-0-debit-1-amount": "5.49",
"currency-0-credit-1-account_code": Accounts.BANK,
"currency-0-credit-1-summary": " Lunch—Pizza ",
"currency-0-credit-1-amount": "5.49"}]

View File

@ -31,10 +31,10 @@ from testlib import get_client
from testlib_txn import Accounts, get_add_form, get_unchanged_update_form, \
get_update_form, match_txn_detail, set_negative_amount, \
remove_debit_in_a_currency, remove_credit_in_a_currency, NEXT_URI, \
NON_EMPTY_NOTE, EMPTY_NOTE
NON_EMPTY_NOTE, EMPTY_NOTE, add_txn
PREFIX: str = "/accounting/transactions"
"""The URL prefix of the transaction management."""
"""The URL prefix for the transaction management."""
class CashIncomeTransactionTestCase(unittest.TestCase):
@ -75,7 +75,7 @@ class CashIncomeTransactionTestCase(unittest.TestCase):
:return: None.
"""
client, csrf_token = get_client(self.app, "nobody")
txn_id: int = self.__add_txn()
txn_id: int = add_txn(self.client, self.__get_add_form())
add_form: dict[str, str] = self.__get_add_form()
add_form["csrf_token"] = csrf_token
update_form: dict[str, str] = self.__get_update_form(txn_id)
@ -110,7 +110,7 @@ class CashIncomeTransactionTestCase(unittest.TestCase):
:return: None.
"""
client, csrf_token = get_client(self.app, "viewer")
txn_id: int = self.__add_txn()
txn_id: int = add_txn(self.client, self.__get_add_form())
add_form: dict[str, str] = self.__get_add_form()
add_form["csrf_token"] = csrf_token
update_form: dict[str, str] = self.__get_update_form(txn_id)
@ -144,7 +144,7 @@ class CashIncomeTransactionTestCase(unittest.TestCase):
:return: None.
"""
txn_id: int = self.__add_txn()
txn_id: int = add_txn(self.client, self.__get_add_form())
add_form: dict[str, str] = self.__get_add_form()
update_form: dict[str, str] = self.__get_update_form(txn_id)
response: httpx.Response
@ -326,7 +326,7 @@ class CashIncomeTransactionTestCase(unittest.TestCase):
:return: None.
"""
from accounting.models import Transaction, TransactionCurrency
txn_id: int = self.__add_txn()
txn_id: int = add_txn(self.client, self.__get_add_form())
detail_uri: str = f"{PREFIX}/{txn_id}?next=%2F_next"
edit_uri: str = f"{PREFIX}/{txn_id}/edit?next=%2F_next"
update_uri: str = f"{PREFIX}/{txn_id}/update"
@ -479,7 +479,7 @@ class CashIncomeTransactionTestCase(unittest.TestCase):
:return: None.
"""
from accounting.models import Transaction
txn_id: int = self.__add_txn()
txn_id: int = add_txn(self.client, self.__get_add_form())
detail_uri: str = f"{PREFIX}/{txn_id}?next=%2F_next"
update_uri: str = f"{PREFIX}/{txn_id}/update"
txn: Transaction
@ -513,7 +513,7 @@ class CashIncomeTransactionTestCase(unittest.TestCase):
:return: None.
"""
from accounting.models import Transaction
txn_id: int = self.__add_txn()
txn_id: int = add_txn(self.client, self.__get_add_form())
editor_username, editor2_username = "editor", "editor2"
client, csrf_token = get_client(self.app, editor2_username)
detail_uri: str = f"{PREFIX}/{txn_id}?next=%2F_next"
@ -542,7 +542,7 @@ class CashIncomeTransactionTestCase(unittest.TestCase):
:return: None.
"""
txn_id: int = self.__add_txn()
txn_id: int = add_txn(self.client, self.__get_add_form())
detail_uri: str = f"{PREFIX}/{txn_id}?next=%2F_next"
delete_uri: str = f"{PREFIX}/{txn_id}/delete"
response: httpx.Response
@ -562,17 +562,6 @@ class CashIncomeTransactionTestCase(unittest.TestCase):
"next": NEXT_URI})
self.assertEqual(response.status_code, 404)
def __add_txn(self) -> int:
"""Adds a transaction.
:return: The newly-added transaction ID..
"""
store_uri: str = f"{PREFIX}/store/income"
form: dict[str, str] = self.__get_add_form()
response: httpx.Response = self.client.post(store_uri, data=form)
self.assertEqual(response.status_code, 302)
return match_txn_detail(response.headers["Location"])
def __get_add_form(self) -> dict[str, str]:
"""Returns the form data to add a new transaction.
@ -647,7 +636,7 @@ class CashExpenseTransactionTestCase(unittest.TestCase):
:return: None.
"""
client, csrf_token = get_client(self.app, "nobody")
txn_id: int = self.__add_txn()
txn_id: int = add_txn(self.client, self.__get_add_form())
add_form: dict[str, str] = self.__get_add_form()
add_form["csrf_token"] = csrf_token
update_form: dict[str, str] = self.__get_update_form(txn_id)
@ -682,7 +671,7 @@ class CashExpenseTransactionTestCase(unittest.TestCase):
:return: None.
"""
client, csrf_token = get_client(self.app, "viewer")
txn_id: int = self.__add_txn()
txn_id: int = add_txn(self.client, self.__get_add_form())
add_form: dict[str, str] = self.__get_add_form()
add_form["csrf_token"] = csrf_token
update_form: dict[str, str] = self.__get_update_form(txn_id)
@ -716,7 +705,7 @@ class CashExpenseTransactionTestCase(unittest.TestCase):
:return: None.
"""
txn_id: int = self.__add_txn()
txn_id: int = add_txn(self.client, self.__get_add_form())
add_form: dict[str, str] = self.__get_add_form()
update_form: dict[str, str] = self.__get_update_form(txn_id)
response: httpx.Response
@ -901,7 +890,7 @@ class CashExpenseTransactionTestCase(unittest.TestCase):
:return: None.
"""
from accounting.models import Transaction, TransactionCurrency
txn_id: int = self.__add_txn()
txn_id: int = add_txn(self.client, self.__get_add_form())
detail_uri: str = f"{PREFIX}/{txn_id}?next=%2F_next"
edit_uri: str = f"{PREFIX}/{txn_id}/edit?next=%2F_next"
update_uri: str = f"{PREFIX}/{txn_id}/update"
@ -1058,7 +1047,7 @@ class CashExpenseTransactionTestCase(unittest.TestCase):
:return: None.
"""
from accounting.models import Transaction
txn_id: int = self.__add_txn()
txn_id: int = add_txn(self.client, self.__get_add_form())
detail_uri: str = f"{PREFIX}/{txn_id}?next=%2F_next"
update_uri: str = f"{PREFIX}/{txn_id}/update"
txn: Transaction
@ -1092,7 +1081,7 @@ class CashExpenseTransactionTestCase(unittest.TestCase):
:return: None.
"""
from accounting.models import Transaction
txn_id: int = self.__add_txn()
txn_id: int = add_txn(self.client, self.__get_add_form())
editor_username, editor2_username = "editor", "editor2"
client, csrf_token = get_client(self.app, editor2_username)
detail_uri: str = f"{PREFIX}/{txn_id}?next=%2F_next"
@ -1121,7 +1110,7 @@ class CashExpenseTransactionTestCase(unittest.TestCase):
:return: None.
"""
txn_id: int = self.__add_txn()
txn_id: int = add_txn(self.client, self.__get_add_form())
detail_uri: str = f"{PREFIX}/{txn_id}?next=%2F_next"
delete_uri: str = f"{PREFIX}/{txn_id}/delete"
response: httpx.Response
@ -1141,17 +1130,6 @@ class CashExpenseTransactionTestCase(unittest.TestCase):
"next": NEXT_URI})
self.assertEqual(response.status_code, 404)
def __add_txn(self) -> int:
"""Adds a transaction.
:return: The newly-added transaction ID..
"""
store_uri: str = f"{PREFIX}/store/expense"
form: dict[str, str] = self.__get_add_form()
response: httpx.Response = self.client.post(store_uri, data=form)
self.assertEqual(response.status_code, 302)
return match_txn_detail(response.headers["Location"])
def __get_add_form(self) -> dict[str, str]:
"""Returns the form data to add a new transaction.
@ -1226,7 +1204,7 @@ class TransferTransactionTestCase(unittest.TestCase):
:return: None.
"""
client, csrf_token = get_client(self.app, "nobody")
txn_id: int = self.__add_txn()
txn_id: int = add_txn(self.client, self.__get_add_form())
add_form: dict[str, str] = self.__get_add_form()
add_form["csrf_token"] = csrf_token
update_form: dict[str, str] = self.__get_update_form(txn_id)
@ -1261,7 +1239,7 @@ class TransferTransactionTestCase(unittest.TestCase):
:return: None.
"""
client, csrf_token = get_client(self.app, "viewer")
txn_id: int = self.__add_txn()
txn_id: int = add_txn(self.client, self.__get_add_form())
add_form: dict[str, str] = self.__get_add_form()
add_form["csrf_token"] = csrf_token
update_form: dict[str, str] = self.__get_update_form(txn_id)
@ -1295,7 +1273,7 @@ class TransferTransactionTestCase(unittest.TestCase):
:return: None.
"""
txn_id: int = self.__add_txn()
txn_id: int = add_txn(self.client, self.__get_add_form())
add_form: dict[str, str] = self.__get_add_form()
update_form: dict[str, str] = self.__get_update_form(txn_id)
response: httpx.Response
@ -1507,7 +1485,7 @@ class TransferTransactionTestCase(unittest.TestCase):
:return: None.
"""
from accounting.models import Transaction, TransactionCurrency
txn_id: int = self.__add_txn()
txn_id: int = add_txn(self.client, self.__get_add_form())
detail_uri: str = f"{PREFIX}/{txn_id}?next=%2F_next"
edit_uri: str = f"{PREFIX}/{txn_id}/edit?next=%2F_next"
update_uri: str = f"{PREFIX}/{txn_id}/update"
@ -1698,7 +1676,7 @@ class TransferTransactionTestCase(unittest.TestCase):
:return: None.
"""
from accounting.models import Transaction
txn_id: int = self.__add_txn()
txn_id: int = add_txn(self.client, self.__get_add_form())
detail_uri: str = f"{PREFIX}/{txn_id}?next=%2F_next"
update_uri: str = f"{PREFIX}/{txn_id}/update"
txn: Transaction
@ -1732,7 +1710,7 @@ class TransferTransactionTestCase(unittest.TestCase):
:return: None.
"""
from accounting.models import Transaction
txn_id: int = self.__add_txn()
txn_id: int = add_txn(self.client, self.__get_add_form())
editor_username, editor2_username = "editor", "editor2"
client, csrf_token = get_client(self.app, editor2_username)
detail_uri: str = f"{PREFIX}/{txn_id}?next=%2F_next"
@ -1762,7 +1740,7 @@ class TransferTransactionTestCase(unittest.TestCase):
:return: None.
"""
from accounting.models import Transaction, TransactionCurrency
txn_id: int = self.__add_txn()
txn_id: int = add_txn(self.client, self.__get_add_form())
detail_uri: str = f"{PREFIX}/{txn_id}?as=income&next=%2F_next"
update_uri: str = f"{PREFIX}/{txn_id}/update?as=income"
form_0: dict[str, str] = self.__get_update_form(txn_id)
@ -1861,7 +1839,7 @@ class TransferTransactionTestCase(unittest.TestCase):
:return: None.
"""
from accounting.models import Transaction, TransactionCurrency
txn_id: int = self.__add_txn()
txn_id: int = add_txn(self.client, self.__get_add_form())
detail_uri: str = f"{PREFIX}/{txn_id}?as=expense&next=%2F_next"
update_uri: str = f"{PREFIX}/{txn_id}/update?as=expense"
form_0: dict[str, str] = self.__get_update_form(txn_id)
@ -1963,7 +1941,7 @@ class TransferTransactionTestCase(unittest.TestCase):
:return: None.
"""
txn_id: int = self.__add_txn()
txn_id: int = add_txn(self.client, self.__get_add_form())
detail_uri: str = f"{PREFIX}/{txn_id}?next=%2F_next"
delete_uri: str = f"{PREFIX}/{txn_id}/delete"
response: httpx.Response
@ -1983,17 +1961,6 @@ class TransferTransactionTestCase(unittest.TestCase):
"next": NEXT_URI})
self.assertEqual(response.status_code, 404)
def __add_txn(self) -> int:
"""Adds a transaction.
:return: The newly-added transaction ID..
"""
store_uri: str = f"{PREFIX}/store/transfer"
form: dict[str, str] = self.__get_add_form()
response: httpx.Response = self.client.post(store_uri, data=form)
self.assertEqual(response.status_code, 302)
return match_txn_detail(response.headers["Location"])
def __get_add_form(self) -> dict[str, str]:
"""Returns the form data to add a new transaction.
@ -2062,11 +2029,11 @@ class TransactionReorderTestCase(unittest.TestCase):
from accounting.models import Transaction
response: httpx.Response
id_1: int = self.__add_income_txn()
id_2: int = self.__add_expense_txn()
id_3: int = self.__add_transfer_txn()
id_4: int = self.__add_income_txn()
id_5: int = self.__add_expense_txn()
id_1: int = add_txn(self.client, self.__get_add_income_form())
id_2: int = add_txn(self.client, self.__get_add_expense_form())
id_3: int = add_txn(self.client, self.__get_add_transfer_form())
id_4: int = add_txn(self.client, self.__get_add_income_form())
id_5: int = add_txn(self.client, self.__get_add_expense_form())
with self.app.app_context():
txn_1: Transaction = db.session.get(Transaction, id_1)
@ -2108,11 +2075,11 @@ class TransactionReorderTestCase(unittest.TestCase):
from accounting.models import Transaction
response: httpx.Response
id_1: int = self.__add_income_txn()
id_2: int = self.__add_expense_txn()
id_3: int = self.__add_transfer_txn()
id_4: int = self.__add_income_txn()
id_5: int = self.__add_expense_txn()
id_1: int = add_txn(self.client, self.__get_add_income_form())
id_2: int = add_txn(self.client, self.__get_add_expense_form())
id_3: int = add_txn(self.client, self.__get_add_transfer_form())
id_4: int = add_txn(self.client, self.__get_add_income_form())
id_5: int = add_txn(self.client, self.__get_add_expense_form())
with self.app.app_context():
txn_date: date = db.session.get(Transaction, id_1).date
@ -2160,17 +2127,6 @@ class TransactionReorderTestCase(unittest.TestCase):
self.assertEqual(db.session.get(Transaction, id_4).no, 1)
self.assertEqual(db.session.get(Transaction, id_5).no, 5)
def __add_income_txn(self) -> int:
"""Adds a transaction.
:return: The newly-added transaction ID..
"""
store_uri: str = f"{PREFIX}/store/income"
form: dict[str, str] = self.__get_add_income_form()
response: httpx.Response = self.client.post(store_uri, data=form)
self.assertEqual(response.status_code, 302)
return match_txn_detail(response.headers["Location"])
def __get_add_income_form(self) -> dict[str, str]:
"""Returns the form data to add a new transaction.
@ -2180,17 +2136,6 @@ class TransactionReorderTestCase(unittest.TestCase):
form = {x: form[x] for x in form if "-debit-" not in x}
return form
def __add_expense_txn(self) -> int:
"""Adds a transaction.
:return: The newly-added transaction ID..
"""
store_uri: str = f"{PREFIX}/store/expense"
form: dict[str, str] = self.__get_add_expense_form()
response: httpx.Response = self.client.post(store_uri, data=form)
self.assertEqual(response.status_code, 302)
return match_txn_detail(response.headers["Location"])
def __get_add_expense_form(self) -> dict[str, str]:
"""Returns the form data to add a new transaction.
@ -2214,17 +2159,6 @@ class TransactionReorderTestCase(unittest.TestCase):
form = {x: form[x] for x in form if "-credit-" not in x}
return form
def __add_transfer_txn(self) -> int:
"""Adds a transaction.
:return: The newly-added transaction ID..
"""
store_uri: str = f"{PREFIX}/store/transfer"
form: dict[str, str] = self.__get_add_transfer_form()
response: httpx.Response = self.client.post(store_uri, data=form)
self.assertEqual(response.status_code, 302)
return match_txn_detail(response.headers["Location"])
def __get_add_transfer_form(self) -> dict[str, str]:
"""Returns the form data to add a new transaction.

View File

@ -22,6 +22,7 @@ from decimal import Decimal
from datetime import date
from secrets import randbelow
import httpx
from flask import Flask
from test_site import db
@ -38,11 +39,14 @@ class Accounts:
"""The shortcuts to the common accounts."""
CASH: str = "1111-001"
BANK: str = "1113-001"
PREPAID: str = "1258-001"
PAYABLE: str = "2141-001"
SALES: str = "4111-001"
SERVICE: str = "4611-001"
AGENCY: str = "4711-001"
OFFICE: str = "5153-001"
TRAVEL: str = "5154-001"
OFFICE: str = "6153-001"
TRAVEL: str = "6154-001"
MEAL: str = "6172-001"
INTEREST: str = "4111-001"
DONATION: str = "7481-001"
RENT: str = "7482-001"
@ -381,6 +385,25 @@ def __get_currency_prefix(form: dict[str, str], code: str) -> str:
return m.group(1)
def add_txn(client: httpx.Client, form: dict[str, str]) -> int:
"""Adds a transfer transaction.
:param client: The client.
:param form: The form data.
:return: The newly-added transaction ID.
"""
prefix: str = "/accounting/transactions"
txn_type: str = "transfer"
if len({x for x in form if "-debit-" in x}) == 0:
txn_type = "income"
elif len({x for x in form if "-credit-" in x}) == 0:
txn_type = "expense"
store_uri = f"{prefix}/store/{txn_type}"
response: httpx.Response = client.post(store_uri, data=form)
assert response.status_code == 302
return match_txn_detail(response.headers["Location"])
def match_txn_detail(location: str) -> int:
"""Validates if the redirect location is the transaction detail, and
returns the transaction ID on success.