57 Commits

Author SHA1 Message Date
5c6bfd8b49 Revised the coding style of the NeedSomeCurrencies validator. 2023-03-14 21:42:02 +08:00
d9ecf51c6d Added the "create_test_app" function in testlib.py to replace "create_app" to prevent common mistakes. Added a get_csrf_token_view route to the application, and changed the get_csrf_token function to retrieve the CSRF token with the route without parsing the HTML for the CSRF token. 2023-03-14 21:28:35 +08:00
5d31eb9172 Removed the unnecessary future annotation import from the "accounting.transaction.forms.transaction" module. 2023-03-14 20:44:06 +08:00
fadce244c5 Revised the type hint and the coding style of the NeedSomeCurrencies validator. 2023-03-14 20:43:28 +08:00
cbe7c6ca6d Added dummy.js to .gitignore and MANIFEST.in for exclusion. 2023-03-14 17:03:22 +08:00
b03938fb2e Added test_temp.py to the exclusion in MANIFEST.in. 2023-03-14 17:03:21 +08:00
8061a23fdc Renamed the AbstractUserUtils class to UserUtilityInterface, and added the can_view and can_edit functions to the UserUtilityInterface interface. There is no need to separately supply two additional can_view and can_edit callbacks. 2023-03-14 17:03:18 +08:00
cd8a480cd0 Revised the documentation of the AbstractUserUtils class. 2023-03-14 17:03:16 +08:00
b8b87714eb Revised the documentation of the JavaScript summary editor. 2023-03-14 17:03:12 +08:00
bf2f96621d Revised so that when the account selector finds the codes from the form, the journal entry editor is used to find the form instead of messing-up with the TransactionForm class and its static method that was a shortcut to the private instance method of the same name. 2023-03-14 17:03:10 +08:00
2d771f04be Fixed so that saving the journal entry from the journal entry editor triggers updating the total of the debit or credit side, which in turn triggers validating the balance if it is on a transfer form. This fixed the problem that deleting a journal entry updates total but not re-validating the balance. 2023-03-14 17:03:06 +08:00
3a12472d4b Fixed the indent in the template of the account selector. 2023-03-14 17:03:01 +08:00
d5a686a5d8 Removed trailing blank spaces from the JavaScript summary editor. 2023-03-14 17:02:58 +08:00
690f89e29a Removed the unused accounting-debit-account-code and accounting-credit-account-code HTML classes. 2023-03-14 17:02:54 +08:00
82a6a53dc4 Revised the account selector to find the account codes from the form through the TransactionForm class, but not finding the codes by itself. 2023-03-14 17:02:52 +08:00
cdd31b1047 Added the missing documentation to the static initialize method of JavaScript TransactionForm class. 2023-03-14 17:02:50 +08:00
5bad949cfa Fixed the documentation of the entryType parameter in the constructor of the JavaScript DebitCreditSideSubForm sub-form. 2023-03-14 17:02:48 +08:00
3826646d06 Reordered the journal entry editor and put the summary first and the account later. 2023-03-14 17:02:46 +08:00
74071e8997 Removed the unused static validateAccount method from the JavaScript journal entry editor. 2023-03-14 17:02:43 +08:00
3ce34803f3 Moved the journal entry editor from the transaction-form.js to a new independent JavaScript file journal-entry-editor.js. 2023-03-14 17:02:41 +08:00
232f73172f Removed the prefix from the journal entry sub-form. 2023-03-14 17:02:39 +08:00
ff1bb7142b Removed the unused data-prefix attribute from the currency sub-forms of the transaction form. 2023-03-14 17:02:37 +08:00
7155bf635a Removed the data-entry-type attribute from the journal entry editor form. The entry type is passed by the object. There is no need to store this information in the HTML anymore. 2023-03-14 17:02:35 +08:00
c306ff8009 Revised the JavaScript journal entry editor and account selector so that the account selector work with the journal entry editor and does not get into the detail of the journal entry editor. 2023-03-14 17:02:32 +08:00
b344abce06 Added the #prefix property to the journal entry editor to simplify the consistency. 2023-03-14 17:02:29 +08:00
b3c666c872 Fixed the addJournalEntry method of the DebitCreditSideSubForm sub-form to re-validate the whole side after a new journal entry is added. 2023-03-14 17:02:14 +08:00
6a671cac84 Revised the JavaScript journal entry editor and summary editor so that the summary editor work with the journal entry editor and does not get into the detail of the journal entry editor. 2023-03-14 16:59:54 +08:00
fe87c3a7de Fixed the documentation of the #side property of the JavaScript JournalEntryEditor class. 2023-03-14 16:59:51 +08:00
2013f8cbd9 Removed the initializeNewJournalEntry method from the JavaScript SummaryEditor. It does not do meaningful things at all. 2023-03-14 16:59:49 +08:00
2325842471 Fixed the documentation of the JavaScript for the transaction form. 2023-03-14 16:59:48 +08:00
c80e58b049 Renamed the journal entry form to journal entry editor, to be clear. 2023-03-14 16:59:46 +08:00
be0ae5eba4 Replaced the function-based JavaScript with the object-oriented TransactionForm, CurrencySubForm, DebitCreditSideSubForm, JournalEntrySubForm, and JournalEntryForm classes for the transaction form. 2023-03-14 16:59:36 +08:00
2b84f64554 Replaced the function-based JavaScript with the object-oriented AccountForm class for the currency form. 2023-03-12 22:15:56 +08:00
0a658a76e8 Replaced the function-based JavaScript with the object-oriented AccountForm class for the account form. 2023-03-12 22:15:54 +08:00
50dc79d865 Added the missing is-invalid class on errors to the currency field in the currency sub-forms of the transaction form. 2023-03-12 16:51:25 +08:00
8e5377a416 Replaced the payable account with the petty-cash account in the SummeryEditorTestCase test case. 2023-03-12 01:34:47 +08:00
4299fd6fbd Revised the code in the JavaScript initializeBaseAccountSelector function in the account form. 2023-03-12 01:34:45 +08:00
1d6a53f7cd Revised the account form so that the if-offset-needed option is only available for real accounts. 2023-03-12 01:34:42 +08:00
bb2993b0c0 Reordered the code in the "accounting.transaction.forms.journal_entry" module. 2023-03-11 20:36:38 +08:00
f6946c1165 Revised the IsBalanced validator so that it no longer need the __future__ annotation. 2023-03-11 19:10:47 +08:00
8e219d8006 Fixed the type hint of the form parameter in the NeedSomeJournalEntries validator. 2023-03-11 19:10:44 +08:00
53565eb9e6 Changed the IsBalanced validator from an inner class inside the TransferCurrencyForm form to an independent class. 2023-03-11 19:10:42 +08:00
965e78d8ad Revised the rule for the accounts that need offset in the accounting-init-accounts console command. 2023-03-11 17:15:08 +08:00
74b81d3e23 Renamed the offset_original_id column to original_entry_id, and the offset_original relationship to original_entry in the JournalEntry data model. 2023-03-11 16:34:30 +08:00
a0fba6387f Added the order to the search report. 2023-03-11 16:34:30 +08:00
d28bdf2064 Revised the parameter order in the template of the currency sub-form of the transaction form. 2023-03-11 16:34:29 +08:00
edf0c00e34 Shortened the names of the #filterAccountOptions, #getAccountCodeUsedInForm, and #shouldAccountOptionShow methods to #filterOptions, #getCodesUsedInForm, and #shouldOptionShow, respectively, in the JavaScript AccountSelector class. 2023-03-11 16:34:29 +08:00
107d161379 Removed a debug output from the JavaScript AccountSelector class. 2023-03-11 16:34:29 +08:00
f2c184f769 Rewrote the JavaScript AccountSelector to store the page elements in the object. 2023-03-11 16:34:28 +08:00
b45986ecfc Fixed the parameter type for the JavaScript AccountSelector class. 2023-03-11 16:34:28 +08:00
a2c2452ec5 Added a missing blank line to the JavaScript AccountSelector class. 2023-03-11 16:34:28 +08:00
5194258b48 Removed the redundant #init method from the JavaScript AccountSelector class. 2023-03-11 16:34:28 +08:00
3fe7eb41ac Removed the unused "__in_use_account_id" property from the TransactionForm form. 2023-03-11 16:34:28 +08:00
7fb9e2f0a1 Added missing documentation to the OptionLink data model in the "accounting.report.utils.option_link" module. 2023-03-11 16:34:28 +08:00
1d443f7b76 Renamed the "accounting.transaction.form" module to "accounting.transaction.forms". It only contains forms now. 2023-03-11 16:34:28 +08:00
6ad4fba9cd Moved the "accounting.transaction.operators", "accounting.transaction.summary_editor" and "accounting.transaction.form.account_option" modules into the "accounting.transaction.utils" module. 2023-03-11 16:34:28 +08:00
3dda6531b5 Split the "accounting.transaction.forms" module into various submodules in the "accounting.transaction.form" module. 2023-03-11 16:33:51 +08:00
45 changed files with 2659 additions and 1749 deletions

1
.gitignore vendored
View File

@ -38,3 +38,4 @@ excludes
*.mo
zh_Hans
test_temp.py
dummy.js

View File

@ -15,6 +15,7 @@
# See the License for the specific language governing permissions and
# limitations under the License.
exclude src/accounting/static/js/dummy.js
include src/accounting/translations/*
include src/accounting/translations/*/LC_MESSAGES/*
include docs/*
@ -22,6 +23,7 @@ include docs/source/*
include docs/source/_static/*
include docs/source/_templates/*
include tests/*
exclude tests/test_temp.py
include tests/test_site/*
include tests/test_site/templates/*
include tests/test_site/translations/*

View File

@ -23,7 +23,7 @@ from pathlib import Path
from flask import Flask, Blueprint
from flask_sqlalchemy import SQLAlchemy
from accounting.utils.user import AbstractUserUtils
from accounting.utils.user import UserUtilityInterface
db: SQLAlchemy = SQLAlchemy()
"""The database instance."""
@ -31,19 +31,13 @@ data_dir: Path = Path(__file__).parent / "data"
"""The data directory."""
def init_app(app: Flask, user_utils: AbstractUserUtils,
url_prefix: str = "/accounting",
can_view_func: t.Callable[[], bool] | None = None,
can_edit_func: t.Callable[[], bool] | None = None) -> None:
def init_app(app: Flask, user_utils: UserUtilityInterface,
url_prefix: str = "/accounting") -> None:
"""Initialize the application.
:param app: The Flask application.
:param user_utils: The user utilities.
:param url_prefix: The URL prefix of the accounting application.
:param can_view_func: A callback that returns whether the current user can
view the accounting data.
:param can_edit_func: A callback that returns whether the current user can
edit the accounting data.
:return: None.
"""
# The database instance must be set before loading everything
@ -73,7 +67,7 @@ def init_app(app: Flask, user_utils: AbstractUserUtils,
locale.init_app(app, bp)
from .utils import permission
permission.init_app(bp, can_view_func, can_edit_func)
permission.init_app(bp, user_utils)
from .utils import next_uri
next_uri.init_app(bp)

View File

@ -18,7 +18,6 @@
"""
import os
import re
from secrets import randbelow
import click
@ -93,14 +92,36 @@ def init_accounts_command(username: str) -> None:
data: list[AccountData] = []
for base in bases_to_add:
l10n: dict[str, str] = {x.locale: x.title for x in base.l10n}
is_offset_needed: bool = True if re.match("^[12]1[34]", base.code) \
else False
is_offset_needed: bool = __is_offset_needed(base.code)
data.append((get_new_id(), base.code, 1, base.title_l10n,
l10n["zh_Hant"], l10n["zh_Hans"], is_offset_needed))
__add_accounting_accounts(data, creator_pk)
click.echo(F"{len(data)} added. Accounting accounts initialized.")
def __is_offset_needed(base_code: str) -> bool:
"""Checks that whether entries in the account need offset.
:param base_code: The code of the base account.
:return: True if entries in the account need offset, or False otherwise.
"""
# Assets
if base_code[0] == "1":
if base_code[:3] in {"113", "114", "118", "184"}:
return True
if base_code in {"1411", "1421", "1431", "1441", "1511", "1521",
"1581", "1611", "1851", ""}:
return True
return False
# Liabilities
if base_code[0] == "2":
if base_code in {"2111", "2114", "2284", "2293"}:
return False
return True
# Only assets and liabilities need offset
return False
def __add_accounting_accounts(data: list[AccountData], creator_pk: int)\
-> None:
"""Adds the accounts.

View File

@ -53,6 +53,21 @@ class BaseAccountAvailable:
"The base account is not available."))
class NoOffsetNominalAccount:
"""The validator to check nominal account is not to be offset."""
def __call__(self, form: FlaskForm, field: BooleanField) -> None:
if not field.data:
return
if not isinstance(form, AccountForm):
return
if form.base_code.data is None:
return
if form.base_code.data[0] not in {"1", "2"}:
raise ValidationError(lazy_gettext(
"A nominal account does not need offset."))
class AccountForm(FlaskForm):
"""The form to create or edit an account."""
base_code = StringField(
@ -66,7 +81,8 @@ class AccountForm(FlaskForm):
filters=[strip_text],
validators=[DataRequired(lazy_gettext("Please fill in the title"))])
"""The title."""
is_offset_needed = BooleanField()
is_offset_needed = BooleanField(
validators=[NoOffsetNominalAccount()])
"""Whether the the entries of this account need offset."""
def populate_obj(self, obj: Account) -> None:
@ -87,7 +103,10 @@ class AccountForm(FlaskForm):
obj.base_code = self.base_code.data
obj.no = count + 1
obj.title = self.title.data
obj.is_offset_needed = self.is_offset_needed.data
if self.base_code.data[0] in {"1", "2"}:
obj.is_offset_needed = self.is_offset_needed.data
else:
obj.is_offset_needed = False
if is_new:
current_user_pk: int = get_current_user_pk()
obj.created_by_id = current_user_pk

View File

@ -597,14 +597,14 @@ class JournalEntry(db.Model):
"""True for a debit entry, or False for a credit entry."""
no = db.Column(db.Integer, nullable=False)
"""The entry number under the transaction and debit or credit."""
offset_original_id = db.Column(db.Integer,
db.ForeignKey(id, onupdate="CASCADE"),
nullable=True)
"""The ID of the original entry to offset."""
offset_original = db.relationship("JournalEntry", back_populates="offsets",
remote_side=id, passive_deletes=True)
"""The original entry to offset."""
offsets = db.relationship("JournalEntry", back_populates="offset_original")
original_entry_id = db.Column(db.Integer,
db.ForeignKey(id, onupdate="CASCADE"),
nullable=True)
"""The ID of the original entry."""
original_entry = db.relationship("JournalEntry", back_populates="offsets",
remote_side=id, passive_deletes=True)
"""The original entry."""
offsets = db.relationship("JournalEntry", back_populates="original_entry")
"""The offset entries."""
currency_code = db.Column(db.String,
db.ForeignKey(Currency.code, onupdate="CASCADE"),

View File

@ -68,7 +68,10 @@ class EntryCollector:
except ArithmeticError:
pass
conditions.append(sa.or_(*sub_conditions))
return JournalEntry.query.filter(*conditions)\
return JournalEntry.query.join(Transaction).filter(*conditions)\
.order_by(Transaction.date,
JournalEntry.is_debit,
JournalEntry.no)\
.options(selectinload(JournalEntry.account),
selectinload(JournalEntry.currency),
selectinload(JournalEntry.transaction)).all()

View File

@ -27,10 +27,15 @@ class OptionLink:
"""Constructs an option link.
:param title: The title.
:param url: The URI.
:param url: The URL.
:param is_active: True if active, or False otherwise
:param fa_icon: The font-awesome icon, if any.
"""
self.title: str = title
"""The title."""
self.url: str = url
"""The URL."""
self.is_active: bool = is_active
"""True if active, or False otherwise."""
self.fa_icon: str | None = fa_icon
"""The font-awesome icon, if any."""

View File

@ -24,161 +24,335 @@
// Initializes the page JavaScript.
document.addEventListener("DOMContentLoaded", () => {
initializeBaseAccountSelector();
document.getElementById("accounting-base-code")
.onchange = validateBase;
document.getElementById("accounting-title")
.onchange = validateTitle;
document.getElementById("accounting-form")
.onsubmit = validateForm;
AccountForm.initialize();
});
/**
* Initializes the base account selector.
* The account form.
*
* @private
*/
function initializeBaseAccountSelector() {
const selector = document.getElementById("accounting-base-selector-modal");
const base = document.getElementById("accounting-base");
const baseCode = document.getElementById("accounting-base-code");
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", () => {
base.classList.add("accounting-not-empty");
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", () => {
if (baseCode.value === "") {
base.classList.remove("accounting-not-empty");
}
});
for (const option of options) {
option.onclick = () => {
baseCode.value = option.dataset.code;
baseContent.innerText = option.dataset.content;
btnClear.classList.add("btn-danger");
btnClear.classList.remove("btn-secondary")
btnClear.disabled = false;
validateBase();
bootstrap.Modal.getInstance(selector).hide();
class AccountForm {
/**
* The base account selector
* @type {BaseAccountSelector}
*/
#baseAccountSelector;
/**
* The form element
* @type {HTMLFormElement}
*/
#formElement;
/**
* The control of the base account
* @type {HTMLDivElement}
*/
#baseControl;
/**
* The input of the base account
* @type {HTMLInputElement}
*/
#baseCode;
/**
* The base account
* @type {HTMLDivElement}
*/
#base;
/**
* The error message for the base account
* @type {HTMLDivElement}
*/
#baseError;
/**
* The title
* @type {HTMLInputElement}
*/
#title;
/**
* The error message of the title
* @type {HTMLDivElement}
*/
#titleError;
/**
* The control of the is-offset-needed option
* @type {HTMLDivElement}
*/
#isOffsetNeededControl;
/**
* The is-offset-needed option
* @type {HTMLInputElement}
*/
#isOffsetNeeded;
/**
* Constructs the account form.
*
*/
constructor() {
this.#baseAccountSelector = new BaseAccountSelector(this);
this.#formElement = document.getElementById("accounting-form");
this.#baseControl = document.getElementById("accounting-base-control");
this.#baseCode = document.getElementById("accounting-base-code");
this.#base = document.getElementById("accounting-base");
this.#baseError = document.getElementById("accounting-base-error");
this.#title = document.getElementById("accounting-title");
this.#titleError = document.getElementById("accounting-title-error");
this.#isOffsetNeededControl = document.getElementById("accounting-is-offset-needed-control");
this.#isOffsetNeeded = document.getElementById("accounting-is-offset-needed");
this.#formElement.onsubmit = () => {
return this.#validateForm();
};
this.#baseControl.onclick = () => {
this.#baseControl.classList.add("accounting-not-empty");
this.#baseAccountSelector.onOpen(this.#baseCode.value);
};
}
btnClear.onclick = () => {
baseCode.value = "";
baseContent.innerText = "";
btnClear.classList.add("btn-secondary")
btnClear.classList.remove("btn-danger");
btnClear.disabled = true;
validateBase();
bootstrap.Modal.getInstance(selector).hide();
/**
* The callback when the base account selector is closed.
*
*/
onBaseAccountSelectorClosed() {
if (this.#baseCode.value === "") {
this.#baseControl.classList.remove("accounting-not-empty");
}
}
/**
* Sets the base account.
*
* @param code {string} the base account code
* @param text {string} the text for the base account
*/
setBaseAccount(code, text) {
this.#baseCode.value = code;
this.#base.innerText = text;
if (["1", "2"].includes(code.substring(0, 1))) {
this.#isOffsetNeededControl.classList.remove("d-none");
this.#isOffsetNeeded.disabled = false;
} else {
this.#isOffsetNeededControl.classList.add("d-none");
this.#isOffsetNeeded.disabled = true;
this.#isOffsetNeeded.checked = false;
}
this.#validateBase();
}
/**
* Clears the base account.
*
*/
clearBaseAccount() {
this.#baseCode.value = "";
this.#base.innerText = "";
this.#validateBase();
}
/**
* Validates the form.
*
* @returns {boolean} true if valid, or false otherwise
*/
#validateForm() {
let isValid = true;
isValid = this.#validateBase() && isValid;
isValid = this.#validateTitle() && isValid;
return isValid;
}
/**
* Validates the base account.
*
* @returns {boolean} true if valid, or false otherwise
*/
#validateBase() {
if (this.#baseCode.value === "") {
this.#baseControl.classList.add("is-invalid");
this.#baseError.innerText = A_("Please select the base account.");
return false;
}
this.#baseControl.classList.remove("is-invalid");
this.#baseError.innerText = "";
return true;
}
/**
* Validates the title.
*
* @returns {boolean} true if valid, or false otherwise
*/
#validateTitle() {
this.#title.value = this.#title.value.trim();
if (this.#title.value === "") {
this.#title.classList.add("is-invalid");
this.#titleError.innerText = A_("Please fill in the title.");
return false;
}
this.#title.classList.remove("is-invalid");
this.#titleError.innerText = "";
return true;
}
/**
* The account form
* @type {AccountForm} the form
*/
static #form;
static initialize() {
this.#form = new AccountForm();
}
initializeBaseAccountQuery();
}
/**
* Initializes the query on the base account options.
* The base account selector.
*
* @private
*/
function initializeBaseAccountQuery() {
const query = document.getElementById("accounting-base-selector-query");
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", () => {
if (query.value === "") {
for (const option of options) {
option.classList.remove("d-none");
}
optionList.classList.remove("d-none");
queryNoResult.classList.add("d-none");
return
class BaseAccountSelector {
/**
* The account form
* @type {AccountForm}
*/
#form;
/**
* The selector modal
* @type {HTMLDivElement}
*/
#modal;
/**
* The query input
* @type {HTMLInputElement}
*/
#query;
/**
* The error message when the query has no result
* @type {HTMLParagraphElement}
*/
#queryNoResult;
/**
* The option list
* @type {HTMLUListElement}
*/
#optionList;
/**
* The options
* @type {HTMLLIElement[]}
*/
#options;
/**
* The button to clear the base account value
* @type {HTMLButtonElement}
*/
#clearButton;
/**
* Constructs the base account selector.
*
* @param form {AccountForm} the form
*/
constructor(form) {
this.#form = form;
this.#modal = document.getElementById("accounting-base-selector-modal");
this.#query = document.getElementById("accounting-base-selector-query");
this.#optionList = document.getElementById("accounting-base-selector-option-list");
// noinspection JSValidateTypes
this.#options = Array.from(document.getElementsByClassName("accounting-base-selector-option"));
this.#clearButton = document.getElementById("accounting-base-selector-clear");
this.#queryNoResult = document.getElementById("accounting-base-selector-option-no-result");
this.#modal.addEventListener("hidden.bs.modal", () => {
this.#form.onBaseAccountSelectorClosed();
});
for (const option of this.#options) {
option.onclick = () => {
this.#form.setBaseAccount(option.dataset.code, option.dataset.content);
};
}
let hasAnyMatched = false;
for (const option of options) {
const queryValues = JSON.parse(option.dataset.queryValues);
let isMatched = false;
for (const queryValue of queryValues) {
if (queryValue.includes(query.value)) {
isMatched = true;
break;
this.#clearButton.onclick = () => {
this.#form.clearBaseAccount();
};
this.#initializeBaseAccountQuery();
}
/**
* Initializes the query.
*
*/
#initializeBaseAccountQuery() {
this.#query.addEventListener("input", () => {
if (this.#query.value === "") {
for (const option of this.#options) {
option.classList.remove("d-none");
}
this.#optionList.classList.remove("d-none");
this.#queryNoResult.classList.add("d-none");
return
}
let hasAnyMatched = false;
for (const option of this.#options) {
const queryValues = JSON.parse(option.dataset.queryValues);
let isMatched = false;
for (const queryValue of queryValues) {
if (queryValue.includes(this.#query.value)) {
isMatched = true;
break;
}
}
if (isMatched) {
option.classList.remove("d-none");
hasAnyMatched = true;
} else {
option.classList.add("d-none");
}
}
if (isMatched) {
option.classList.remove("d-none");
hasAnyMatched = true;
if (!hasAnyMatched) {
this.#optionList.classList.add("d-none");
this.#queryNoResult.classList.remove("d-none");
} else {
option.classList.add("d-none");
this.#optionList.classList.remove("d-none");
this.#queryNoResult.classList.add("d-none");
}
});
}
/**
* The callback when the base account selector is shown.
*
* @param baseCode {string} the active base code
*/
onOpen(baseCode) {
for (const option of this.#options) {
if (option.dataset.code === baseCode) {
option.classList.add("active");
} else {
option.classList.remove("active");
}
}
if (!hasAnyMatched) {
optionList.classList.add("d-none");
queryNoResult.classList.remove("d-none");
if (baseCode === "") {
this.#clearButton.classList.add("btn-secondary")
this.#clearButton.classList.remove("btn-danger");
this.#clearButton.disabled = true;
} else {
optionList.classList.remove("d-none");
queryNoResult.classList.add("d-none");
this.#clearButton.classList.add("btn-danger");
this.#clearButton.classList.remove("btn-secondary")
this.#clearButton.disabled = false;
}
});
}
/**
* Validates the form.
*
* @returns {boolean} true if valid, or false otherwise
* @private
*/
function validateForm() {
let isValid = true;
isValid = validateBase() && isValid;
isValid = validateTitle() && isValid;
return isValid;
}
/**
* Validates the base account.
*
* @returns {boolean} true if valid, or false otherwise
* @private
*/
function validateBase() {
const field = document.getElementById("accounting-base-code");
const error = document.getElementById("accounting-base-code-error");
const displayField = document.getElementById("accounting-base");
field.value = field.value.trim();
if (field.value === "") {
displayField.classList.add("is-invalid");
error.innerText = A_("Please select the base account.");
return false;
}
displayField.classList.remove("is-invalid");
error.innerText = "";
return true;
}
/**
* Validates the title.
*
* @returns {boolean} true if valid, or false otherwise
* @private
*/
function validateTitle() {
const field = document.getElementById("accounting-title");
const error = document.getElementById("accounting-title-error");
field.value = field.value.trim();
if (field.value === "") {
field.classList.add("is-invalid");
error.innerText = A_("Please fill in the title.");
return false;
}
field.classList.remove("is-invalid");
error.innerText = "";
return true;
}

View File

@ -45,78 +45,89 @@ class AccountSelector {
*/
#prefix;
/**
* The button to clear the account
* @type {HTMLButtonElement}
*/
#clearButton
/**
* The query input
* @type {HTMLInputElement}
*/
#query;
/**
* The error message when the query has no result
* @type {HTMLParagraphElement}
*/
#queryNoResult;
/**
* The option list
* @type {HTMLUListElement}
*/
#optionList;
/**
* The options
* @type {HTMLLIElement[]}
*/
#options;
/**
* The more item to show all accounts
* @type {HTMLLIElement}
*/
#more;
/**
* The journal entry editor
* @type {JournalEntryEditor}
*/
#entryEditor;
/**
* Constructs an account selector.
*
* @param modal {HTMLFormElement} the account selector modal
* @param modal {HTMLDivElement} 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.#query = document.getElementById(this.#prefix + "-query");
this.#queryNoResult = document.getElementById(this.#prefix + "-option-no-result");
this.#optionList = document.getElementById(this.#prefix + "-option-list");
// noinspection JSValidateTypes
this.#options = Array.from(document.getElementsByClassName(this.#prefix + "-option"));
this.#more = document.getElementById(this.#prefix + "-more");
this.#clearButton = document.getElementById(this.#prefix + "-btn-clear");
this.#more.onclick = () => {
this.#more.classList.add("d-none");
this.#filterOptions();
};
this.#initializeAccountQuery();
btnClear.onclick = () => {
formAccountControl.classList.remove("accounting-not-empty");
formAccount.innerText = "";
formAccount.dataset.code = "";
formAccount.dataset.text = "";
validateJournalEntryAccount();
this.#clearButton.onclick = () => {
this.#entryEditor.clearAccount();
};
for (const option of options) {
for (const option of this.#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();
this.#entryEditor.saveAccount(option.dataset.code, option.dataset.content);
};
}
}
/**
* Initializes the query on the account options.
*
*/
#initializeAccountQuery() {
const query = document.getElementById(this.#prefix + "-query");
query.addEventListener("input", () => {
this.#filterAccountOptions();
this.#query.addEventListener("input", () => {
this.#filterOptions();
});
}
/**
* Filters the account options.
* Filters the 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();
#filterOptions() {
const codesInUse = this.#getCodesUsedInForm();
let shouldAnyShow = false;
for (const option of options) {
const shouldShow = this.#shouldAccountOptionShow(option, more, codesInUse, query);
for (const option of this.#options) {
const shouldShow = this.#shouldOptionShow(option, this.#more, codesInUse, this.#query);
if (shouldShow) {
option.classList.remove("d-none");
shouldAnyShow = true;
@ -124,12 +135,12 @@ class AccountSelector {
option.classList.add("d-none");
}
}
if (!shouldAnyShow && more.classList.contains("d-none")) {
optionList.classList.add("d-none");
queryNoResult.classList.remove("d-none");
if (!shouldAnyShow && this.#more.classList.contains("d-none")) {
this.#optionList.classList.add("d-none");
this.#queryNoResult.classList.remove("d-none");
} else {
optionList.classList.remove("d-none");
queryNoResult.classList.add("d-none");
this.#optionList.classList.remove("d-none");
this.#queryNoResult.classList.add("d-none");
}
}
@ -138,26 +149,24 @@ class AccountSelector {
*
* @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);
#getCodesUsedInForm() {
const inUse = this.#entryEditor.getTransactionForm().getAccountCodesUsed(this.#entryType);
if (this.#entryEditor.getAccountCode() !== null) {
inUse.push(this.#entryEditor.getAccountCode());
}
return inUse
}
/**
* Returns whether an account option should show.
* Returns whether an option should show.
*
* @param option {HTMLLIElement} the account option
* @param more {HTMLLIElement} the more account element
* @param option {HTMLLIElement} the option
* @param more {HTMLLIElement} the more 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
* @return {boolean} true if the option should show, or false otherwise
*/
#shouldAccountOptionShow(option, more, inUse, query) {
#shouldOptionShow(option, more, inUse, query) {
const isQueryMatched = () => {
if (query.value === "") {
return true;
@ -180,33 +189,30 @@ class AccountSelector {
}
/**
* Initializes the account selector when it is shown.
* The callback when the account selector is shown.
*
* @param entryEditor {JournalEntryEditor} the journal entry editor
*/
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) {
#onOpen(entryEditor) {
this.#entryEditor = entryEditor;
this.#query.value = "";
this.#more.classList.remove("d-none");
this.#filterOptions();
for (const option of this.#options) {
if (option.dataset.code === entryEditor.getAccountCode()) {
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;
if (entryEditor.getAccountCode() === null) {
this.#clearButton.classList.add("btn-secondary");
this.#clearButton.classList.remove("btn-danger");
this.#clearButton.disabled = true;
} else {
btnClear.classList.add("btn-danger");
btnClear.classList.remove("btn-secondary");
btnClear.disabled = false;
this.#clearButton.classList.add("btn-danger");
this.#clearButton.classList.remove("btn-secondary");
this.#clearButton.disabled = false;
}
}
@ -226,25 +232,15 @@ class AccountSelector {
const selector = new AccountSelector(modal);
this.#selectors[selector.#entryType] = selector;
}
this.#initializeTransactionForm();
}
/**
* Initializes the transaction form.
* Starts the account selector.
*
* @param entryEditor {JournalEntryEditor} the journal entry editor
* @param entryType {string} the entry type, either "debit" or "credit"
*/
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";
static start(entryEditor, entryType) {
this.#selectors[entryType].#onOpen(entryEditor);
}
}

View File

@ -24,152 +24,151 @@
// Initializes the page JavaScript.
document.addEventListener("DOMContentLoaded", () => {
document.getElementById("accounting-code")
.onchange = validateCode;
document.getElementById("accounting-name")
.onchange = validateName;
document.getElementById("accounting-form")
.onsubmit = validateForm;
CurrencyForm.initialize();
});
/**
* The asynchronous validation result
* @type {object}
* @private
*/
let isAsyncValid = {};
/**
* Validates the form.
*
* @returns {boolean} true if valid, or false otherwise
* @private
*/
function validateForm() {
isAsyncValid = {
"code": false,
"_sync": false,
};
let isValid = true;
isValid = validateCode() && isValid;
isValid = validateName() && isValid;
isAsyncValid["_sync"] = isValid;
submitFormIfAllAsyncValid();
return false;
}
/**
* Submits the form if the whole form passed the asynchronous
* validations.
* The currency form.
*
* @private
*/
function submitFormIfAllAsyncValid() {
let isValid = true;
for (const key of Object.keys(isAsyncValid)) {
isValid = isAsyncValid[key] && isValid;
}
if (isValid) {
document.getElementById("accounting-form").submit()
}
}
class CurrencyForm {
/**
* Validates the code.
*
* @param changeEvent {Event} the change event, if invoked from onchange
* @returns {boolean} true if valid, or false otherwise
* @private
*/
function validateCode(changeEvent = null) {
const key = "code";
const isSubmission = changeEvent === null;
let hasAsyncValidation = false;
const field = document.getElementById("accounting-code");
const error = document.getElementById("accounting-code-error");
field.value = field.value.trim();
if (field.value === "") {
field.classList.add("is-invalid");
error.innerText = A_("Please fill in the code.");
return false;
}
const blocklist = JSON.parse(field.dataset.blocklist);
if (blocklist.includes(field.value)) {
field.classList.add("is-invalid");
error.innerText = A_("This code is not available.");
return false;
}
if (!field.value.match(/^[A-Z]{3}$/)) {
field.classList.add("is-invalid");
error.innerText = A_("Code can only be composed of 3 upper-cased letters.");
return false;
}
const original = field.dataset.original;
if (original === "" || field.value !== original) {
hasAsyncValidation = true;
validateAsyncCodeIsDuplicated(isSubmission, key);
}
if (!hasAsyncValidation) {
isAsyncValid[key] = true;
field.classList.remove("is-invalid");
error.innerText = "";
}
return true;
}
/**
* The form.
* @type {HTMLFormElement}
*/
#formElement;
/**
* Validates asynchronously whether the code is duplicated.
* The boolean validation result is stored in isAsyncValid[key].
*
* @param isSubmission {boolean} whether this is invoked from a form submission
* @param key {string} the key to store the result in isAsyncValid
* @private
*/
function validateAsyncCodeIsDuplicated(isSubmission, key) {
const field = document.getElementById("accounting-code");
const error = document.getElementById("accounting-code-error");
const url = field.dataset.existsUrl;
const onLoad = function () {
if (this.status === 200) {
const result = JSON.parse(this.responseText);
if (result["exists"]) {
field.classList.add("is-invalid");
error.innerText = A_("Code conflicts with another currency.");
if (isSubmission) {
isAsyncValid[key] = false;
/**
* The code
* @type {HTMLInputElement}
*/
#code;
/**
* The error message of the code
* @type {HTMLDivElement}
*/
#codeError;
/**
* The name
* @type {HTMLInputElement}
*/
#name;
/**
* The error message of the name
* @type {HTMLDivElement}
*/
#nameError;
/**
* Constructs the currency form.
*
*/
constructor() {
this.#formElement = document.getElementById("accounting-form");
this.#code = document.getElementById("accounting-code");
this.#codeError = document.getElementById("accounting-code-error");
this.#name = document.getElementById("accounting-name");
this.#nameError = document.getElementById("accounting-name-error");
this.#code.onchange = () => {
this.#validateCode().then();
};
this.#name.onchange = () => {
this.#validateName();
};
this.#formElement.onsubmit = () => {
this.#validateForm().then((isValid) => {
if (isValid) {
this.#formElement.submit();
}
return;
}
field.classList.remove("is-invalid");
error.innerText = "";
if (isSubmission) {
isAsyncValid[key] = true;
submitFormIfAllAsyncValid();
});
return false;
};
}
/**
* Validates the form.
*
* @returns {Promise<boolean>} true if valid, or false otherwise
*/
async #validateForm() {
let isValid = true;
isValid = await this.#validateCode() && isValid;
isValid = this.#validateName() && isValid;
return isValid;
}
/**
* Validates the code.
*
* @param changeEvent {Event} the change event, if invoked from onchange
* @returns {Promise<boolean>} true if valid, or false otherwise
*/
async #validateCode(changeEvent = null) {
this.#code.value = this.#code.value.trim();
if (this.#code.value === "") {
this.#code.classList.add("is-invalid");
this.#codeError.innerText = A_("Please fill in the code.");
return false;
}
const blocklist = JSON.parse(this.#code.dataset.blocklist);
if (blocklist.includes(this.#code.value)) {
this.#code.classList.add("is-invalid");
this.#codeError.innerText = A_("This code is not available.");
return false;
}
if (!this.#code.value.match(/^[A-Z]{3}$/)) {
this.#code.classList.add("is-invalid");
this.#codeError.innerText = A_("Code can only be composed of 3 upper-cased letters.");
return false;
}
const original = this.#code.dataset.original;
if (original === "" || this.#code.value !== original) {
const response = await fetch(this.#code.dataset.existsUrl + "?q=" + encodeURIComponent(this.#code.value));
const data = await response.json();
if (data["exists"]) {
this.#code.classList.add("is-invalid");
this.#codeError.innerText = A_("Code conflicts with another currency.");
return false;
}
}
};
const request = new XMLHttpRequest();
request.onload = onLoad;
request.open("GET", url + "?q=" + encodeURIComponent(field.value));
request.send();
}
/**
* Validates the name.
*
* @returns {boolean} true if valid, or false otherwise
* @private
*/
function validateName() {
const field = document.getElementById("accounting-name");
const error = document.getElementById("accounting-name-error");
field.value = field.value.trim();
if (field.value === "") {
field.classList.add("is-invalid");
error.innerText = A_("Please fill in the name.");
return false;
this.#code.classList.remove("is-invalid");
this.#codeError.innerText = "";
return true;
}
/**
* Validates the name.
*
* @returns {boolean} true if valid, or false otherwise
*/
#validateName() {
this.#name.value = this.#name.value.trim();
if (this.#name.value === "") {
this.#name.classList.add("is-invalid");
this.#nameError.innerText = A_("Please fill in the name.");
return false;
}
this.#name.classList.remove("is-invalid");
this.#nameError.innerText = "";
return true;
}
/**
* The form
* @type {CurrencyForm}
*/
static #form;
/**
* Initializes the currency form.
*
*/
static initialize() {
this.#form = new CurrencyForm();
}
field.classList.remove("is-invalid");
error.innerText = "";
return true;
}

View File

@ -0,0 +1,386 @@
/* The Mia! Accounting Flask Project
* journal-entry-editor.js: The JavaScript for the journal entry 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/25
*/
"use strict";
// Initializes the page JavaScript.
document.addEventListener("DOMContentLoaded", () => {
JournalEntryEditor.initialize();
});
/**
* The journal entry editor.
*
*/
class JournalEntryEditor {
/**
* The journal entry editor
* @type {HTMLFormElement}
*/
#element;
/**
* The bootstrap modal
* @type {HTMLDivElement}
*/
#modal;
/**
* The entry type, either "debit" or "credit"
* @type {string}
*/
entryType;
/**
* The prefix of the HTML ID and class
* @type {string}
*/
#prefix = "accounting-entry-editor"
/**
* The control of the summary
* @type {HTMLDivElement}
*/
#summaryControl;
/**
* The summary
* @type {HTMLDivElement}
*/
#summary;
/**
* The error message of the summary
* @type {HTMLDivElement}
*/
#summaryError;
/**
* The control of the account
* @type {HTMLDivElement}
*/
#accountControl;
/**
* The account
* @type {HTMLDivElement}
*/
#account;
/**
* The error message of the account
* @type {HTMLDivElement}
*/
#accountError;
/**
* The amount
* @type {HTMLInputElement}
*/
#amount;
/**
* The error message of the amount
* @type {HTMLDivElement}
*/
#amountError;
/**
* The journal entry to edit
* @type {JournalEntrySubForm|null}
*/
#entry;
/**
* The debit or credit entry side sub-form
* @type {DebitCreditSideSubForm}
*/
#side;
/**
* Constructs a new journal entry editor.
*
*/
constructor() {
this.#element = document.getElementById(this.#prefix);
this.#modal = document.getElementById(this.#prefix + "-modal");
this.#summaryControl = document.getElementById(this.#prefix + "-summary-control");
this.#summary = document.getElementById(this.#prefix + "-summary");
this.#summaryError = document.getElementById(this.#prefix + "-summary-error");
this.#accountControl = document.getElementById(this.#prefix + "-account-control");
this.#account = document.getElementById(this.#prefix + "-account");
this.#accountError = document.getElementById(this.#prefix + "-account-error")
this.#amount = document.getElementById(this.#prefix + "-amount");
this.#amountError = document.getElementById(this.#prefix + "-amount-error")
this.#summaryControl.onclick = () => {
SummaryEditor.start(this, this.#summary.dataset.value);
};
this.#accountControl.onclick = () => {
AccountSelector.start(this, this.entryType);
}
this.#element.onsubmit = () => {
if (this.#validate()) {
if (this.#entry === null) {
this.#entry = this.#side.addJournalEntry();
}
this.#entry.save(this.#account.dataset.code, this.#account.dataset.text, this.#summary.dataset.value, this.#amount.value);
bootstrap.Modal.getInstance(this.#modal).hide();
}
return false;
};
}
/**
* Returns the transaction form.
*
* @return {TransactionForm} the transaction form
*/
getTransactionForm() {
return this.#side.currency.form;
}
/**
* Saves the summary from the summary editor.
*
* @param summary {string} the summary
*/
saveSummary(summary) {
if (summary === "") {
this.#summaryControl.classList.remove("accounting-not-empty");
} else {
this.#summaryControl.classList.add("accounting-not-empty");
}
this.#summary.dataset.value = summary;
this.#summary.innerText = summary;
bootstrap.Modal.getOrCreateInstance(this.#modal).show();
}
/**
* Saves the summary with the suggested account from the summary editor.
*
* @param summary {string} the summary
* @param accountCode {string} the account code
* @param accountText {string} the account text
*/
saveSummaryWithAccount(summary, accountCode, accountText) {
this.#accountControl.classList.add("accounting-not-empty");
this.#account.dataset.code = accountCode;
this.#account.dataset.text = accountText;
this.#account.innerText = accountText;
this.#validateAccount();
this.saveSummary(summary)
}
/**
* Returns the account code.
*
* @return {string|null} the account code
*/
getAccountCode() {
return this.#account.dataset.code === "" ? null : this.#account.dataset.code;
}
/**
* Clears the account.
*
*/
clearAccount() {
this.#accountControl.classList.remove("accounting-not-empty");
this.#account.dataset.code = "";
this.#account.dataset.text = "";
this.#account.innerText = "";
this.#validateAccount();
}
/**
* Sets the account.
*
* @param code {string} the account code
* @param text {string} the account text
*/
saveAccount(code, text) {
this.#accountControl.classList.add("accounting-not-empty");
this.#account.dataset.code = code;
this.#account.dataset.text = text;
this.#account.innerText = text;
this.#validateAccount();
}
/**
* Validates the form.
*
* @returns {boolean} true if valid, or false otherwise
*/
#validate() {
let isValid = true;
isValid = this.#validateSummary() && isValid;
isValid = this.#validateAccount() && isValid;
isValid = this.#validateAmount() && isValid
return isValid;
}
/**
* Validates the summary.
*
* @return {boolean} true if valid, or false otherwise
* @private
*/
#validateSummary() {
this.#summary.classList.remove("is-invalid");
this.#summaryError.innerText = "";
return true;
}
/**
* Validates the account.
*
* @return {boolean} true if valid, or false otherwise
*/
#validateAccount() {
if (this.#account.dataset.code === "") {
this.#accountControl.classList.add("is-invalid");
this.#accountError.innerText = A_("Please select the account.");
return false;
}
this.#accountControl.classList.remove("is-invalid");
this.#accountError.innerText = "";
return true;
}
/**
* Validates the amount.
*
* @return {boolean} true if valid, or false otherwise
* @private
*/
#validateAmount() {
this.#amount.value = this.#amount.value.trim();
this.#amount.classList.remove("is-invalid");
if (this.#amount.value === "") {
this.#amount.classList.add("is-invalid");
this.#amountError.innerText = A_("Please fill in the amount.");
return false;
}
this.#amount.classList.remove("is-invalid");
this.#amount.innerText = "";
return true;
}
/**
* Adds a new journal entry.
*
* @param side {DebitCreditSideSubForm} the debit or credit side sub-form
*/
#onAddNew(side) {
this.#entry = null;
this.#side = side;
this.entryType = this.#side.entryType;
this.#element.dataset.entryType = side.entryType;
this.#summaryControl.dataset.bsTarget = "#accounting-summary-editor-" + side.entryType + "-modal";
this.#summaryControl.classList.remove("accounting-not-empty");
this.#summaryControl.classList.remove("is-invalid");
this.#summary.dataset.value = "";
this.#summary.innerText = ""
this.#summaryError.innerText = ""
this.#accountControl.dataset.bsTarget = "#accounting-account-selector-" + side.entryType + "-modal";
this.#accountControl.classList.remove("accounting-not-empty");
this.#accountControl.classList.remove("is-invalid");
this.#account.innerText = "";
this.#account.dataset.code = "";
this.#account.dataset.text = "";
this.#accountError.innerText = "";
this.#amount.value = "";
this.#amount.classList.remove("is-invalid");
this.#amountError.innerText = "";
}
/**
* Edits a journal entry.
*
* @param entry {JournalEntrySubForm} the journal entry sub-form
* @param summary {string} the summary
* @param accountCode {string} the account code
* @param accountText {string} the account text
* @param amount {string} the amount
*/
#onEdit(entry, summary, accountCode, accountText, amount) {
this.#entry = entry;
this.#side = entry.side;
this.entryType = this.#side.entryType;
this.#element.dataset.entryType = entry.entryType;
this.#summaryControl.dataset.bsTarget = "#accounting-summary-editor-" + entry.entryType + "-modal";
if (summary === "") {
this.#summaryControl.classList.remove("accounting-not-empty");
} else {
this.#summaryControl.classList.add("accounting-not-empty");
}
this.#summary.dataset.value = summary;
this.#summary.innerText = summary;
this.#accountControl.dataset.bsTarget = "#accounting-account-selector-" + entry.entryType + "-modal";
if (accountCode === "") {
this.#accountControl.classList.remove("accounting-not-empty");
} else {
this.#accountControl.classList.add("accounting-not-empty");
}
this.#account.innerText = accountText;
this.#account.dataset.code = accountCode;
this.#account.dataset.text = accountText;
this.#amount.value = amount;
}
/**
* The journal entry editor
* @type {JournalEntryEditor}
*/
static #editor;
/**
* Initializes the journal entry editor.
*
*/
static initialize() {
this.#editor = new JournalEntryEditor();
}
/**
* Adds a new journal entry.
*
* @param side {DebitCreditSideSubForm} the debit or credit side sub-form
*/
static addNew(side) {
this.#editor.#onAddNew(side);
}
/**
* Edits a journal entry.
*
* @param entry {JournalEntrySubForm} the journal entry sub-form
* @param summary {string} the summary
* @param accountCode {string} the account code
* @param accountText {string} the account text
* @param amount {string} the amount
*/
static edit(entry, summary, accountCode, accountText, amount) {
this.#editor.#onEdit(entry, summary, accountCode, accountText, amount);
}
}

View File

@ -58,25 +58,25 @@ class SummaryEditor {
#entryType;
/**
* The current tab.
* The current tab
* @type {TabPlane}
*/
currentTab;
/**
* The summary input.
* The summary input
* @type {HTMLInputElement}
*/
summary;
/**
* The number input.
* The number input
* @type {HTMLInputElement}
*/
number;
/**
* The note.
* The note
* @type {HTMLInputElement}
*/
note;
@ -94,34 +94,10 @@ class SummaryEditor {
#selectedAccount = null;
/**
* The modal of the journal entry form
* @type {HTMLDivElement}
* The journal entry editor
* @type {JournalEntryEditor}
*/
#entryFormModal;
/**
* The control of the account on the journal entry form
* @type {HTMLDivElement}
*/
#formAccountControl;
/**
* The account on the journal entry form
* @type {HTMLDivElement}
*/
#formAccount;
/**
* The control of the summary on the journal entry form
* @type {HTMLDivElement}
*/
#formSummaryControl;
/**
* The summary on the journal entry form
* @type {HTMLDivElement}
*/
#formSummary;
#entryEditor;
/**
* The tab planes
@ -145,13 +121,6 @@ class SummaryEditor {
// noinspection JSValidateTypes
this.#accountButtons = Array.from(document.getElementsByClassName(this.prefix + "-account"));
// Things from the entry form
this.#entryFormModal = document.getElementById("accounting-entry-form-modal");
this.#formAccountControl = document.getElementById("accounting-entry-form-account-control");
this.#formAccount = document.getElementById("accounting-entry-form-account");
this.#formSummaryControl = document.getElementById("accounting-entry-form-summary-control");
this.#formSummary = document.getElementById("accounting-entry-form-summary");
for (const cls of [GeneralTagTab, GeneralTripTab, BusTripTab, RegularPaymentTab, AnnotationTab]) {
const tab = new cls(this);
this.tabPlanes[tab.tabId()] = tab;
@ -239,30 +208,24 @@ class SummaryEditor {
*
*/
#submit() {
if (this.summary.value === "") {
this.#formSummaryControl.classList.remove("accounting-not-empty");
} else {
this.#formSummaryControl.classList.add("accounting-not-empty");
}
if (this.#selectedAccount !== null) {
this.#formAccountControl.classList.add("accounting-not-empty");
this.#formAccount.dataset.code = this.#selectedAccount.dataset.code;
this.#formAccount.dataset.text = this.#selectedAccount.dataset.text;
this.#formAccount.innerText = this.#selectedAccount.dataset.text;
}
this.#formSummary.dataset.value = this.summary.value;
this.#formSummary.innerText = this.summary.value;
bootstrap.Modal.getOrCreateInstance(this.#modal).hide();
bootstrap.Modal.getOrCreateInstance(this.#entryFormModal).show();
if (this.#selectedAccount !== null) {
this.#entryEditor.saveSummaryWithAccount(this.summary.value, this.#selectedAccount.dataset.code, this.#selectedAccount.dataset.text);
} else {
this.#entryEditor.saveSummary(this.summary.value);
}
}
/**
* The callback when the summary editor is shown.
*
* @param entryEditor {JournalEntryEditor} the journal entry editor
* @param summary {string} the summary
*/
#onOpen() {
#onOpen(entryEditor, summary) {
this.#entryEditor = entryEditor;
this.#reset();
this.summary.value = this.#formSummary.dataset.value;
this.summary.value = summary;
this.#onSummaryChange();
}
@ -290,22 +253,20 @@ class SummaryEditor {
*/
static initialize() {
const forms = Array.from(document.getElementsByClassName("accounting-summary-editor"));
const entryForm = document.getElementById("accounting-entry-form");
const formSummaryControl = document.getElementById("accounting-entry-form-summary-control");
for (const form of forms) {
const editor = new SummaryEditor(form);
this.#editors[editor.#entryType] = editor;
}
formSummaryControl.onclick = () => this.#editors[entryForm.dataset.entryType].#onOpen()
}
/**
* Initializes the summary editor for a new journal entry.
* The callback when the summary editor is shown.
*
* @param entryType {string} the entry type, either "debit" or "credit"
* @param entryEditor {JournalEntryEditor} the journal entry editor
* @param summary {string} the summary
*/
static initializeNewJournalEntry(entryType) {
this.#editors[entryType].#onOpen();
static start(entryEditor, summary) {
this.#editors[entryEditor.entryType].#onOpen(entryEditor, summary);
}
}

File diff suppressed because it is too large Load Diff

View File

@ -41,9 +41,9 @@ First written: 2023/2/1
{% endif %}
<div class="form-floating mb-3">
<input id="accounting-base-code" type="hidden" name="base_code" value="{{ form.base_code.data|accounting_default }}">
<div id="accounting-base" class="form-control accounting-clickable accounting-material-text-field {% if form.base_code.data %} accounting-not-empty {% endif %} {% if form.base_code.errors %} is-invalid {% endif %}" data-bs-toggle="modal" data-bs-target="#accounting-base-selector-modal">
<div id="accounting-base-control" class="form-control accounting-clickable accounting-material-text-field {% if form.base_code.data %} accounting-not-empty {% endif %} {% if form.base_code.errors %} is-invalid {% endif %}" data-bs-toggle="modal" data-bs-target="#accounting-base-selector-modal">
<label class="form-label" for="accounting-base">{{ A_("Base account") }}</label>
<div id="accounting-base-content">
<div id="accounting-base">
{% if form.base_code.data %}
{% if form.base_code.errors %}
{{ A_("(Unknown)") }}
@ -53,7 +53,7 @@ First written: 2023/2/1
{% endif %}
</div>
</div>
<div id="accounting-base-code-error" class="invalid-feedback">{% if form.base_code.errors %}{{ form.base_code.errors[0] }}{% endif %}</div>
<div id="accounting-base-error" class="invalid-feedback">{% if form.base_code.errors %}{{ form.base_code.errors[0] }}{% endif %}</div>
</div>
<div class="form-floating mb-3">
@ -62,7 +62,7 @@ First written: 2023/2/1
<div id="accounting-title-error" class="invalid-feedback">{% if form.title.errors %}{{ form.title.errors[0] }}{% endif %}</div>
</div>
<div class="form-check form-switch mb-3">
<div id="accounting-is-offset-needed-control" class="form-check form-switch mb-3 {% if form.base_code.data[0] not in ["1", "2"] %} d-none {% endif %}">
<input id="accounting-is-offset-needed" class="form-check-input" type="checkbox" name="is_offset_needed" value="1" {% if form.is_offset_needed.data %} checked="checked" {% endif %}>
<label class="form-check-label" for="accounting-is-offset-needed">
{{ A_("The entries in the account need offset.") }}
@ -99,21 +99,21 @@ First written: 2023/2/1
</label>
</div>
<ul id="accounting-base-option-list" class="list-group accounting-selector-list">
<ul id="accounting-base-selector-option-list" class="list-group accounting-selector-list">
{% for base in form.base_options %}
<li id="accounting-base-option-{{ base.code }}" class="list-group-item accounting-base-option accounting-clickable" data-code="{{ base.code }}" data-content="{{ base }}" data-query-values="{{ base.query_values|tojson|forceescape }}">
{{ base }}
</li>
<li class="list-group-item accounting-clickable accounting-base-selector-option" data-code="{{ base.code }}" data-content="{{ base }}" data-query-values="{{ base.query_values|tojson|forceescape }}" data-bs-dismiss="modal">
{{ base }}
</li>
{% endfor %}
</ul>
<p id="accounting-base-option-no-result" class="d-none">{{ A_("There is no data.") }}</p>
<p id="accounting-base-selector-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-dismiss="modal">{{ A_("Cancel") }}</button>
{% if form.base_code.data %}
<button id="accounting-btn-clear-base" type="button" class="btn btn-danger">{{ A_("Clear") }}</button>
<button id="accounting-base-selector-clear" type="button" class="btn btn-danger" data-bs-dismiss="modal">{{ A_("Clear") }}</button>
{% else %}
<button id="accounting-btn-clear-base" type="button" class="btn btn-secondary" disabled="disabled">{{ A_("Clear") }}</button>
<button id="accounting-base-selector-clear" type="button" class="btn btn-secondary" disabled="disabled" data-bs-dismiss="modal">{{ A_("Clear") }}</button>
{% endif %}
</div>
</div>

View File

@ -19,12 +19,12 @@ currency-sub-form.html: The currency sub-form in the cash expense transaction fo
Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/2/25
#}
<div id="accounting-currency-{{ currency_index }}" class="mb-3 accounting-currency" data-index="{{ currency_index }}" data-prefix="accounting-currency-{{ currency_index }}">
<div id="accounting-currency-{{ currency_index }}" class="mb-3 accounting-currency" data-index="{{ currency_index }}">
<input id="accounting-currency-{{ currency_index }}-no" type="hidden" name="currency-{{ currency_index }}-no" value="{{ currency_index }}">
<div id="accounting-currency-{{ currency_index }}-control" class="form-control accounting-currency-control {% if currency_errors %} is-invalid {% endif %}">
<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">
<select id="accounting-currency-{{ currency_index }}-code" class="form-select {% if currency_code_errors %} is-invalid {% endif %}" name="currency-{{ currency_index }}-code">
{% for currency in accounting_currency_options() %}
<option value="{{ currency.code }}" {% if currency.code == currency_code_data %} selected="selected" {% endif %}>{{ currency }}</option>
{% endfor %}
@ -34,7 +34,7 @@ First written: 2023/2/25
</div>
<div>
<button id="accounting-btn-delete-currency-{{ currency_index }}" class="btn btn-danger rounded-circle accounting-btn-delete-currency {% if only_one_currency_form %} d-none {% endif %}" type="button" data-target="accounting-currency-{{ currency_index }}">
<button id="accounting-currency-{{ currency_index }}-delete" class="btn btn-danger rounded-circle {% if only_one_currency_form %} d-none {% endif %}" type="button" data-target="accounting-currency-{{ currency_index }}">
<i class="fas fa-minus"></i>
</button>
</div>
@ -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-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal">
<button id="accounting-currency-{{ currency_index }}-debit-add-entry" class="btn btn-primary" type="button" data-currency-index="{{ currency_index }}" data-entry-type="debit" data-bs-toggle="modal" data-bs-target="#accounting-entry-editor-modal">
<i class="fas fa-plus"></i>
{{ A_("New") }}
</button>

View File

@ -24,7 +24,7 @@ First written: 2023/2/25
<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>
<button type="button" class="btn-close" data-bs-toggle="modal" data-bs-target="#accounting-entry-editor-modal" aria-label="{{ A_("Close") }}"></button>
</div>
<div class="modal-body">
<div class="input-group mb-2">
@ -37,17 +37,17 @@ First written: 2023/2/25
<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>
<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-editor-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>
<button type="button" class="btn btn-secondary" data-bs-toggle="modal" data-bs-target="#accounting-entry-editor-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-editor-modal">{{ A_("Clear") }}</button>
</div>
</div>
</div>

View File

@ -1,60 +0,0 @@
{#
The Mia! Accounting Flask Project
entry-form-modal.html: The modal of the 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
#}
<form id="accounting-entry-form" data-currency-index="" data-entry-type="" data-entry-index="">
<div id="accounting-entry-form-modal" class="modal fade" tabindex="-1" aria-labelledby="accounting-entry-form-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-entry-form-modal-label">{{ A_("Journal Entry Content") }}</h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{{ A_("Close") }}"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<div id="accounting-entry-form-account-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-account">{{ A_("Account") }}</label>
<div id="accounting-entry-form-account" data-code="" data-text=""></div>
</div>
<div id="accounting-entry-form-account-error" class="invalid-feedback"></div>
</div>
<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>
<div class="form-floating mb-3">
<input id="accounting-entry-form-amount" class="form-control" type="number" value="" min="0.01" max="" step="0.01" placeholder=" " required="required">
<label for="accounting-entry-form-amount">{{ A_("Amount") }}</label>
<div id="accounting-entry-form-amount-error" class="invalid-feedback"></div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ A_("Cancel") }}</button>
<button id="accounting-entry-form-btn-save" type="submit" class="btn btn-primary">{{ A_("Save") }}</button>
</div>
</div>
</div>
</div>
</form>

View File

@ -20,16 +20,16 @@ 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-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 }}">
{% 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-{{ 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 }}-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 }}">
<input id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-amount" type="hidden" name="currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-amount" value="{{ amount_data }}">
<div class="accounting-entry-content">
<div id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-control" class="form-control clickable d-flex justify-content-between accounting-entry-control {% if entry_errors %} is-invalid {% endif %}" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal">
<div id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-control" class="form-control clickable d-flex justify-content-between accounting-entry-control {% if entry_errors %} is-invalid {% endif %}" data-bs-toggle="modal" data-bs-target="#accounting-entry-editor-modal">
<div>
<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">{{ summary_data }}</div>
@ -40,7 +40,7 @@ First written: 2023/2/25
</div>
<div>
<button id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-btn-delete" class="btn btn-danger rounded-circle accounting-btn-delete-entry accounting-currency-{{ currency_index }}-{{ entry_type }}-btn-delete-entry {% if only_one_entry_form %} d-none {% endif %}" type="button" data-target="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}" data-same-class="accounting-currency-{{ currency_index }}-{{ entry_type }}-btn-delete-entry">
<button id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-delete" class="btn btn-danger rounded-circle {% if only_one_entry_form %} d-none {% endif %}" type="button" data-target="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}" data-same-class="accounting-currency-{{ currency_index }}-{{ entry_type }}-btn-delete-entry">
<i class="fas fa-minus"></i>
</button>
</div>

View File

@ -24,6 +24,7 @@ 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/journal-entry-editor.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 %}
@ -57,7 +58,7 @@ First written: 2023/2/26
</div>
<div>
<button id="accounting-btn-new-currency" class="btn btn-primary" type="button">
<button id="accounting-add-currency" class="btn btn-primary" type="button">
<i class="fas fa-plus"></i>
{{ A_("New") }}
</button>
@ -86,7 +87,7 @@ First written: 2023/2/26
</div>
</form>
{% include "accounting/transaction/include/entry-form-modal.html" %}
{% include "accounting/transaction/include/journal-entry-editor-modal.html" %}
{% block form_modals %}{% endblock %}
{% endblock %}

View File

@ -0,0 +1,60 @@
{#
The Mia! Accounting Flask Project
journal-entry-editor-modal.html: The modal of the journal entry 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/25
#}
<form id="accounting-entry-editor">
<div id="accounting-entry-editor-modal" class="modal fade" tabindex="-1" aria-labelledby="accounting-entry-editor-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-entry-editor-modal-label">{{ A_("Journal Entry Content") }}</h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{{ A_("Close") }}"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<div id="accounting-entry-editor-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-editor-summary">{{ A_("Summary") }}</label>
<div id="accounting-entry-editor-summary" data-value=""></div>
</div>
<div id="accounting-entry-editor-summary-error" class="invalid-feedback"></div>
</div>
<div class="mb-3">
<div id="accounting-entry-editor-account-control" class="form-control accounting-clickable accounting-material-text-field" data-bs-toggle="modal" data-bs-target="">
<label class="form-label" for="accounting-entry-editor-account">{{ A_("Account") }}</label>
<div id="accounting-entry-editor-account" data-code="" data-text=""></div>
</div>
<div id="accounting-entry-editor-account-error" class="invalid-feedback"></div>
</div>
<div class="form-floating mb-3">
<input id="accounting-entry-editor-amount" class="form-control" type="number" value="" min="0.01" max="" step="0.01" placeholder=" " required="required">
<label for="accounting-entry-editor-amount">{{ A_("Amount") }}</label>
<div id="accounting-entry-editor-amount-error" class="invalid-feedback"></div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ A_("Cancel") }}</button>
<button type="submit" class="btn btn-primary">{{ A_("Save") }}</button>
</div>
</div>
</div>
</div>
</form>

View File

@ -27,7 +27,7 @@ First written: 2023/2/28
<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>
<button class="btn-close" type="button" data-bs-toggle="modal" data-bs-target="#accounting-entry-editor-modal" aria-label="{{ A_("Close") }}"></button>
</div>
<div class="modal-body">
<div class="mb-3">
@ -181,7 +181,7 @@ First written: 2023/2/28
</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 class="btn btn-secondary" type="button" data-bs-toggle="modal" data-bs-target="#accounting-entry-editor-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>

View File

@ -19,12 +19,12 @@ currency-sub-form.html: The currency sub-form in the cash income transaction for
Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/2/25
#}
<div id="accounting-currency-{{ currency_index }}" class="mb-3 accounting-currency" data-index="{{ currency_index }}" data-prefix="accounting-currency-{{ currency_index }}">
<div id="accounting-currency-{{ currency_index }}" class="mb-3 accounting-currency" data-index="{{ currency_index }}">
<input id="accounting-currency-{{ currency_index }}-no" type="hidden" name="currency-{{ currency_index }}-no" value="{{ currency_index }}">
<div id="accounting-currency-{{ currency_index }}-control" class="form-control accounting-currency-control {% if currency_errors %} is-invalid {% endif %}">
<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">
<select id="accounting-currency-{{ currency_index }}-code" class="form-select {% if currency_code_errors %} is-invalid {% endif %}" name="currency-{{ currency_index }}-code">
{% for currency in accounting_currency_options() %}
<option value="{{ currency.code }}" {% if currency.code == currency_code_data %} selected="selected" {% endif %}>{{ currency }}</option>
{% endfor %}
@ -34,7 +34,7 @@ First written: 2023/2/25
</div>
<div>
<button id="accounting-btn-delete-currency-{{ currency_index }}" class="btn btn-danger rounded-circle accounting-btn-delete-currency {% if only_one_currency_form %} d-none {% endif %}" type="button" data-target="accounting-currency-{{ currency_index }}">
<button id="accounting-currency-{{ currency_index }}-delete" class="btn btn-danger rounded-circle {% if only_one_currency_form %} d-none {% endif %}" type="button" data-target="accounting-currency-{{ currency_index }}">
<i class="fas fa-minus"></i>
</button>
</div>
@ -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-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal">
<button id="accounting-currency-{{ currency_index }}-credit-add-entry" class="btn btn-primary" type="button" data-currency-index="{{ currency_index }}" data-entry-type="credit" data-bs-toggle="modal" data-bs-target="#accounting-entry-editor-modal">
<i class="fas fa-plus"></i>
{{ A_("New") }}
</button>

View File

@ -19,12 +19,12 @@ currency-sub-form.html: The currency sub-form in the transfer transaction form
Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/2/25
#}
<div id="accounting-currency-{{ currency_index }}" class="mb-3 accounting-currency" data-index="{{ currency_index }}" data-prefix="accounting-currency-{{ currency_index }}">
<div id="accounting-currency-{{ currency_index }}" class="mb-3 accounting-currency" data-index="{{ currency_index }}">
<input id="accounting-currency-{{ currency_index }}-no" type="hidden" name="currency-{{ currency_index }}-no" value="{{ currency_index }}">
<div id="accounting-currency-{{ currency_index }}-control" class="form-control accounting-currency-control {% if currency_errors %} is-invalid {% endif %}">
<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">
<select id="accounting-currency-{{ currency_index }}-code" class="form-select {% if currency_code_errors %} is-invalid {% endif %}" name="currency-{{ currency_index }}-code">
{% for currency in accounting_currency_options() %}
<option value="{{ currency.code }}" {% if currency.code == currency_code_data %} selected="selected" {% endif %}>{{ currency }}</option>
{% endfor %}
@ -34,7 +34,7 @@ First written: 2023/2/25
</div>
<div>
<button id="accounting-btn-delete-currency-{{ currency_index }}" class="btn btn-danger rounded-circle accounting-btn-delete-currency {% if only_one_currency_form %} d-none {% endif %}" type="button" data-target="accounting-currency-{{ currency_index }}">
<button id="accounting-currency-{{ currency_index }}-delete" class="btn btn-danger rounded-circle {% if only_one_currency_form %} d-none {% endif %}" type="button" data-target="accounting-currency-{{ currency_index }}">
<i class="fas fa-minus"></i>
</button>
</div>
@ -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-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal">
<button id="accounting-currency-{{ currency_index }}-debit-add-entry" class="btn btn-primary" type="button" data-currency-index="{{ currency_index }}" data-entry-type="debit" data-bs-toggle="modal" data-bs-target="#accounting-entry-editor-modal">
<i class="fas fa-plus"></i>
{{ A_("New") }}
</button>
@ -88,10 +88,10 @@ First written: 2023/2/25
<ul id="accounting-currency-{{ currency_index }}-credit-list" class="list-group accounting-entry-list">
{% for entry_form in credit_forms %}
{% with currency_index = currency_index,
entry_id = entry_form.eid.data,
entry_type = "credit",
entry_index = loop.index,
only_one_entry_form = debit_forms|length == 1,
entry_id = entry_form.eid.data,
account_code_data = entry_form.account_code.data|accounting_default,
account_code_error = entry_form.account_code.errors,
account_text = entry_form.account_text,
@ -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-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal">
<button id="accounting-currency-{{ currency_index }}-credit-add-entry" class="btn btn-primary" type="button" data-currency-index="{{ currency_index }}" data-entry-type="credit" data-bs-toggle="modal" data-bs-target="#accounting-entry-editor-modal">
<i class="fas fa-plus"></i>
{{ A_("New") }}
</button>

View File

@ -0,0 +1,22 @@
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/10
# 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 forms for the transaction management.
"""
from .reorder import sort_transactions_in, TransactionReorderForm
from .transaction import TransactionForm, IncomeTransactionForm, \
ExpenseTransactionForm, TransferTransactionForm

View File

@ -0,0 +1,207 @@
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/10
# 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 currency sub-forms for the transaction management.
"""
from decimal import Decimal
from flask_babel import LazyString
from flask_wtf import FlaskForm
from wtforms import StringField, ValidationError, FieldList, IntegerField, \
BooleanField, FormField
from wtforms.validators import DataRequired
from accounting import db
from accounting.locale import lazy_gettext
from accounting.models import Currency
from accounting.utils.strip_text import strip_text
from .journal_entry import CreditEntryForm, DebitEntryForm
CURRENCY_REQUIRED: DataRequired = DataRequired(
lazy_gettext("Please select the currency."))
"""The validator to check if the currency code is empty."""
class CurrencyExists:
"""The validator to check if the account exists."""
def __call__(self, form: FlaskForm, field: StringField) -> None:
if field.data is None:
return
if db.session.get(Currency, field.data) is None:
raise ValidationError(lazy_gettext(
"The currency does not exist."))
class NeedSomeJournalEntries:
"""The validator to check if there is any journal entry sub-form."""
def __call__(self, form: FlaskForm, field: FieldList) -> None:
if len(field) == 0:
raise ValidationError(lazy_gettext(
"Please add some journal entries."))
class IsBalanced:
"""The validator to check that the total amount of the debit and credit
entries are equal."""
def __call__(self, form: FlaskForm, field: BooleanField) -> None:
if not isinstance(form, TransferCurrencyForm):
return
if len(form.debit) == 0 or len(form.credit) == 0:
return
if form.debit_total != form.credit_total:
raise ValidationError(lazy_gettext(
"The totals of the debit and credit amounts do not match."))
class CurrencyForm(FlaskForm):
"""The form to create or edit a currency in a transaction."""
no = IntegerField()
"""The order in the transaction."""
code = StringField()
"""The currency code."""
whole_form = BooleanField()
"""The pseudo field for the whole form validators."""
class IncomeCurrencyForm(CurrencyForm):
"""The form to create or edit a currency in a cash income transaction."""
no = IntegerField()
"""The order in the transaction."""
code = StringField(
filters=[strip_text],
validators=[CURRENCY_REQUIRED,
CurrencyExists()])
"""The currency code."""
credit = FieldList(FormField(CreditEntryForm),
validators=[NeedSomeJournalEntries()])
"""The credit entries."""
whole_form = BooleanField()
"""The pseudo field for the whole form validators."""
@property
def credit_total(self) -> Decimal:
"""Returns the total amount of the credit journal entries.
:return: The total amount of the credit journal entries.
"""
return sum([x.amount.data for x in self.credit
if x.amount.data is not None])
@property
def credit_errors(self) -> list[str | LazyString]:
"""Returns the credit journal entry errors, without the errors in their
sub-forms.
:return:
"""
return [x for x in self.credit.errors
if isinstance(x, str) or isinstance(x, LazyString)]
class ExpenseCurrencyForm(CurrencyForm):
"""The form to create or edit a currency in a cash expense transaction."""
no = IntegerField()
"""The order in the transaction."""
code = StringField(
filters=[strip_text],
validators=[CURRENCY_REQUIRED,
CurrencyExists()])
"""The currency code."""
debit = FieldList(FormField(DebitEntryForm),
validators=[NeedSomeJournalEntries()])
"""The debit entries."""
whole_form = BooleanField()
"""The pseudo field for the whole form validators."""
@property
def debit_total(self) -> Decimal:
"""Returns the total amount of the debit journal entries.
:return: The total amount of the debit journal entries.
"""
return sum([x.amount.data for x in self.debit
if x.amount.data is not None])
@property
def debit_errors(self) -> list[str | LazyString]:
"""Returns the debit journal entry errors, without the errors in their
sub-forms.
:return:
"""
return [x for x in self.debit.errors
if isinstance(x, str) or isinstance(x, LazyString)]
class TransferCurrencyForm(CurrencyForm):
"""The form to create or edit a currency in a transfer transaction."""
no = IntegerField()
"""The order in the transaction."""
code = StringField(
filters=[strip_text],
validators=[CURRENCY_REQUIRED,
CurrencyExists()])
"""The currency code."""
debit = FieldList(FormField(DebitEntryForm),
validators=[NeedSomeJournalEntries()])
"""The debit entries."""
credit = FieldList(FormField(CreditEntryForm),
validators=[NeedSomeJournalEntries()])
"""The credit entries."""
whole_form = BooleanField(validators=[IsBalanced()])
"""The pseudo field for the whole form validators."""
@property
def debit_total(self) -> Decimal:
"""Returns the total amount of the debit journal entries.
:return: The total amount of the debit journal entries.
"""
return sum([x.amount.data for x in self.debit
if x.amount.data is not None])
@property
def credit_total(self) -> Decimal:
"""Returns the total amount of the credit journal entries.
:return: The total amount of the credit journal entries.
"""
return sum([x.amount.data for x in self.credit
if x.amount.data is not None])
@property
def debit_errors(self) -> list[str | LazyString]:
"""Returns the debit journal entry errors, without the errors in their
sub-forms.
:return:
"""
return [x for x in self.debit.errors
if isinstance(x, str) or isinstance(x, LazyString)]
@property
def credit_errors(self) -> list[str | LazyString]:
"""Returns the credit journal entry errors, without the errors in their
sub-forms.
:return:
"""
return [x for x in self.credit.errors
if isinstance(x, str) or isinstance(x, LazyString)]

View File

@ -0,0 +1,196 @@
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/10
# 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 journal entry sub-forms for the transaction management.
"""
from __future__ import annotations
import re
from flask_babel import LazyString
from flask_wtf import FlaskForm
from wtforms import StringField, ValidationError, DecimalField, IntegerField
from wtforms.validators import DataRequired
from accounting.locale import lazy_gettext
from accounting.models import Account, JournalEntry
from accounting.utils.random_id import new_id
from accounting.utils.strip_text import strip_text
from accounting.utils.user import get_current_user_pk
ACCOUNT_REQUIRED: DataRequired = DataRequired(
lazy_gettext("Please select the account."))
"""The validator to check if the account code is empty."""
class AccountExists:
"""The validator to check if the account exists."""
def __call__(self, form: FlaskForm, field: StringField) -> None:
if field.data is None:
return
if Account.find_by_code(field.data) is None:
raise ValidationError(lazy_gettext(
"The account does not exist."))
class PositiveAmount:
"""The validator to check if the amount is positive."""
def __call__(self, form: FlaskForm, field: DecimalField) -> None:
if field.data is None:
return
if field.data <= 0:
raise ValidationError(lazy_gettext(
"Please fill in a positive amount."))
class IsDebitAccount:
"""The validator to check if the account is for debit journal entries."""
def __call__(self, form: FlaskForm, field: StringField) -> None:
if field.data is None:
return
if re.match(r"^(?:[1235689]|7[5678])", field.data) \
and not field.data.startswith("3351-") \
and not field.data.startswith("3353-"):
return
raise ValidationError(lazy_gettext(
"This account is not for debit entries."))
class IsCreditAccount:
"""The validator to check if the account is for credit journal entries."""
def __call__(self, form: FlaskForm, field: StringField) -> None:
if field.data is None:
return
if re.match(r"^(?:[123489]|7[1234])", field.data) \
and not field.data.startswith("3351-") \
and not field.data.startswith("3353-"):
return
raise ValidationError(lazy_gettext(
"This account is not for credit entries."))
class JournalEntryForm(FlaskForm):
"""The base form to create or edit a journal entry."""
eid = IntegerField()
"""The existing journal entry ID."""
no = IntegerField()
"""The order in the currency."""
account_code = StringField()
"""The account code."""
amount = DecimalField()
"""The amount."""
@property
def account_text(self) -> str:
"""Returns the text representation of the account.
:return: The text representation of the account.
"""
if self.account_code.data is None:
return ""
account: Account | None = Account.find_by_code(self.account_code.data)
if account is None:
return ""
return str(account)
@property
def all_errors(self) -> list[str | LazyString]:
"""Returns all the errors of the form.
:return: All the errors of the form.
"""
all_errors: list[str | LazyString] = []
for key in self.errors:
if key != "csrf_token":
all_errors.extend(self.errors[key])
return all_errors
class DebitEntryForm(JournalEntryForm):
"""The form to create or edit a debit journal entry."""
eid = IntegerField()
"""The existing journal entry ID."""
no = IntegerField()
"""The order in the currency."""
account_code = StringField(
filters=[strip_text],
validators=[ACCOUNT_REQUIRED,
AccountExists(),
IsDebitAccount()])
"""The account code."""
summary = StringField(filters=[strip_text])
"""The summary."""
amount = DecimalField(validators=[PositiveAmount()])
"""The amount."""
def populate_obj(self, obj: JournalEntry) -> None:
"""Populates the form data into a journal entry object.
:param obj: The journal entry object.
:return: None.
"""
is_new: bool = obj.id is None
if is_new:
obj.id = new_id(JournalEntry)
obj.account_id = Account.find_by_code(self.account_code.data).id
obj.summary = self.summary.data
obj.is_debit = True
obj.amount = self.amount.data
if is_new:
current_user_pk: int = get_current_user_pk()
obj.created_by_id = current_user_pk
obj.updated_by_id = current_user_pk
class CreditEntryForm(JournalEntryForm):
"""The form to create or edit a credit journal entry."""
eid = IntegerField()
"""The existing journal entry ID."""
no = IntegerField()
"""The order in the currency."""
account_code = StringField(
filters=[strip_text],
validators=[ACCOUNT_REQUIRED,
AccountExists(),
IsCreditAccount()])
"""The account code."""
summary = StringField(filters=[strip_text])
"""The summary."""
amount = DecimalField(validators=[PositiveAmount()])
"""The amount."""
def populate_obj(self, obj: JournalEntry) -> None:
"""Populates the form data into a journal entry object.
:param obj: The journal entry object.
:return: None.
"""
is_new: bool = obj.id is None
if is_new:
obj.id = new_id(JournalEntry)
obj.account_id = Account.find_by_code(self.account_code.data).id
obj.summary = self.summary.data
obj.is_debit = False
obj.amount = self.amount.data
if is_new:
current_user_pk: int = get_current_user_pk()
obj.created_by_id = current_user_pk
obj.updated_by_id = current_user_pk

View File

@ -0,0 +1,91 @@
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/10
# 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 reorder forms for the transaction management.
"""
from __future__ import annotations
from datetime import date
from flask import request
from accounting import db
from accounting.models import Transaction
def sort_transactions_in(txn_date: date, exclude: int) -> None:
"""Sorts the transactions under a date after changing the date or deleting
a transaction.
:param txn_date: The date of the transaction.
:param exclude: The transaction ID to exclude.
:return: None.
"""
transactions: list[Transaction] = Transaction.query\
.filter(Transaction.date == txn_date,
Transaction.id != exclude)\
.order_by(Transaction.no).all()
for i in range(len(transactions)):
if transactions[i].no != i + 1:
transactions[i].no = i + 1
class TransactionReorderForm:
"""The form to reorder the transactions."""
def __init__(self, txn_date: date):
"""Constructs the form to reorder the transactions in a day.
:param txn_date: The date.
"""
self.date: date = txn_date
self.is_modified: bool = False
def save_order(self) -> None:
"""Saves the order of the account.
:return:
"""
transactions: list[Transaction] = Transaction.query\
.filter(Transaction.date == self.date).all()
# Collects the specified order.
orders: dict[Transaction, int] = {}
for txn in transactions:
if f"{txn.id}-no" in request.form:
try:
orders[txn] = int(request.form[f"{txn.id}-no"])
except ValueError:
pass
# Missing and invalid orders are appended to the end.
missing: list[Transaction] \
= [x for x in transactions if x not in orders]
if len(missing) > 0:
next_no: int = 1 if len(orders) == 0 else max(orders.values()) + 1
for txn in missing:
orders[txn] = next_no
# Sort by the specified order first, and their original order.
transactions.sort(key=lambda x: (orders[x], x.no))
# Update the orders.
with db.session.no_autoflush:
for i in range(len(transactions)):
if transactions[i].no != i + 1:
transactions[i].no = i + 1
self.is_modified = True

View File

@ -14,38 +14,33 @@
# 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 forms for the transaction management.
"""The transaction forms for the transaction management.
"""
from __future__ import annotations
import re
import typing as t
from abc import ABC, abstractmethod
from datetime import date
from decimal import Decimal
import sqlalchemy as sa
from flask import request
from flask_babel import LazyString
from flask_wtf import FlaskForm
from wtforms import DateField, StringField, FieldList, FormField, \
IntegerField, TextAreaField, DecimalField, BooleanField
from wtforms import DateField, FieldList, FormField, \
TextAreaField
from wtforms.validators import DataRequired, ValidationError
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
TransactionCurrency
from accounting.transaction.utils.account_option import AccountOption
from accounting.transaction.utils.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.strip_text import strip_multiline_text
from accounting.utils.user import get_current_user_pk
from .currency import CurrencyForm, IncomeCurrencyForm, ExpenseCurrencyForm, \
TransferCurrencyForm
from .journal_entry import JournalEntryForm, DebitEntryForm, CreditEntryForm
from .reorder import sort_transactions_in
MISSING_CURRENCY: LazyString = lazy_gettext("Please select the currency.")
"""The error message when the currency code is empty."""
MISSING_ACCOUNT: LazyString = lazy_gettext("Please select the account.")
"""The error message when the account code is empty."""
DATE_REQUIRED: DataRequired = DataRequired(
lazy_gettext("Please fill in the date."))
"""The validator to check if the date is empty."""
@ -54,228 +49,9 @@ DATE_REQUIRED: DataRequired = DataRequired(
class NeedSomeCurrencies:
"""The validator to check if there is any currency sub-form."""
def __call__(self, form: CurrencyForm, field: FieldList) \
-> None:
def __call__(self, form: FlaskForm, field: FieldList) -> None:
if len(field) == 0:
raise ValidationError(lazy_gettext(
"Please add some currencies."))
class CurrencyExists:
"""The validator to check if the account exists."""
def __call__(self, form: FlaskForm, field: StringField) -> None:
if field.data is None:
return
if db.session.get(Currency, field.data) is None:
raise ValidationError(lazy_gettext(
"The currency does not exist."))
class NeedSomeJournalEntries:
"""The validator to check if there is any journal entry sub-form."""
def __call__(self, form: TransferCurrencyForm, field: FieldList) \
-> None:
if len(field) == 0:
raise ValidationError(lazy_gettext(
"Please add some journal entries."))
class AccountExists:
"""The validator to check if the account exists."""
def __call__(self, form: FlaskForm, field: StringField) -> None:
if field.data is None:
return
if Account.find_by_code(field.data) is None:
raise ValidationError(lazy_gettext(
"The account does not exist."))
class PositiveAmount:
"""The validator to check if the amount is positive."""
def __call__(self, form: FlaskForm, field: DecimalField) -> None:
if field.data is None:
return
if field.data <= 0:
raise ValidationError(lazy_gettext(
"Please fill in a positive amount."))
class IsDebitAccount:
"""The validator to check if the account is for debit journal entries."""
def __call__(self, form: FlaskForm, field: StringField) -> None:
if field.data is None:
return
if re.match(r"^(?:[1235689]|7[5678])", field.data) \
and not field.data.startswith("3351-") \
and not field.data.startswith("3353-"):
return
raise ValidationError(lazy_gettext(
"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.id: str = account.id
"""The account ID."""
self.code: str = account.code
"""The account code."""
self.query_values: list[str] = account.query_values
"""The values to be queried."""
self.__str: str = str(account)
"""The string representation of the account option."""
self.is_in_use: bool = False
"""True if this account is in use, or False otherwise."""
def __str__(self) -> str:
"""Returns the string representation of the account option.
:return: The string representation of the account option.
"""
return self.__str
class JournalEntryForm(FlaskForm):
"""The base form to create or edit a journal entry."""
eid = IntegerField()
"""The existing journal entry ID."""
no = IntegerField()
"""The order in the currency."""
account_code = StringField()
"""The account code."""
amount = DecimalField()
"""The amount."""
@property
def account_text(self) -> str:
"""Returns the text representation of the account.
:return: The text representation of the account.
"""
if self.account_code.data is None:
return ""
account: Account | None = Account.find_by_code(self.account_code.data)
if account is None:
return ""
return str(account)
@property
def all_errors(self) -> list[str | LazyString]:
"""Returns all the errors of the form.
:return: All the errors of the form.
"""
all_errors: list[str | LazyString] = []
for key in self.errors:
if key != "csrf_token":
all_errors.extend(self.errors[key])
return all_errors
class DebitEntryForm(JournalEntryForm):
"""The form to create or edit a debit journal entry."""
eid = IntegerField()
"""The existing journal entry ID."""
no = IntegerField()
"""The order in the currency."""
account_code = StringField(
filters=[strip_text],
validators=[DataRequired(MISSING_ACCOUNT),
AccountExists(),
IsDebitAccount()])
"""The account code."""
summary = StringField(filters=[strip_text])
"""The summary."""
amount = DecimalField(validators=[PositiveAmount()])
"""The amount."""
def populate_obj(self, obj: JournalEntry) -> None:
"""Populates the form data into a journal entry object.
:param obj: The journal entry object.
:return: None.
"""
is_new: bool = obj.id is None
if is_new:
obj.id = new_id(JournalEntry)
obj.account_id = Account.find_by_code(self.account_code.data).id
obj.summary = self.summary.data
obj.is_debit = True
obj.amount = self.amount.data
if is_new:
current_user_pk: int = get_current_user_pk()
obj.created_by_id = current_user_pk
obj.updated_by_id = current_user_pk
class IsCreditAccount:
"""The validator to check if the account is for credit journal entries."""
def __call__(self, form: FlaskForm, field: StringField) -> None:
if field.data is None:
return
if re.match(r"^(?:[123489]|7[1234])", field.data) \
and not field.data.startswith("3351-") \
and not field.data.startswith("3353-"):
return
raise ValidationError(lazy_gettext(
"This account is not for credit entries."))
class CreditEntryForm(JournalEntryForm):
"""The form to create or edit a credit journal entry."""
eid = IntegerField()
"""The existing journal entry ID."""
no = IntegerField()
"""The order in the currency."""
account_code = StringField(
filters=[strip_text],
validators=[DataRequired(MISSING_ACCOUNT),
AccountExists(),
IsCreditAccount()])
"""The account code."""
summary = StringField(filters=[strip_text])
"""The summary."""
amount = DecimalField(validators=[PositiveAmount()])
"""The amount."""
def populate_obj(self, obj: JournalEntry) -> None:
"""Populates the form data into a journal entry object.
:param obj: The journal entry object.
:return: None.
"""
is_new: bool = obj.id is None
if is_new:
obj.id = new_id(JournalEntry)
obj.account_id = Account.find_by_code(self.account_code.data).id
obj.summary = self.summary.data
obj.is_debit = False
obj.amount = self.amount.data
if is_new:
current_user_pk: int = get_current_user_pk()
obj.created_by_id = current_user_pk
obj.updated_by_id = current_user_pk
class CurrencyForm(FlaskForm):
"""The form to create or edit a currency in a transaction."""
no = IntegerField()
"""The order in the transaction."""
code = StringField()
"""The currency code."""
whole_form = BooleanField()
"""The pseudo field for the whole form validators."""
raise ValidationError(lazy_gettext("Please add some currencies."))
class TransactionForm(FlaskForm):
@ -300,8 +76,6 @@ class TransactionForm(FlaskForm):
"""The journal entry collector. The default is the base abstract
collector only to provide the correct type. The subclass forms should
provide their own collectors."""
self.__in_use_account_id: set[int] | None = None
"""The ID of the accounts that are in use."""
def populate_obj(self, obj: Transaction) -> None:
"""Populates the form data into a transaction object.
@ -538,41 +312,6 @@ class JournalEntryCollector(t.Generic[T], ABC):
ord_by_form.get(x)))
class IncomeCurrencyForm(CurrencyForm):
"""The form to create or edit a currency in a cash income transaction."""
no = IntegerField()
"""The order in the transaction."""
code = StringField(
filters=[strip_text],
validators=[DataRequired(MISSING_CURRENCY),
CurrencyExists()])
"""The currency code."""
credit = FieldList(FormField(CreditEntryForm),
validators=[NeedSomeJournalEntries()])
"""The credit entries."""
whole_form = BooleanField()
"""The pseudo field for the whole form validators."""
@property
def credit_total(self) -> Decimal:
"""Returns the total amount of the credit journal entries.
:return: The total amount of the credit journal entries.
"""
return sum([x.amount.data for x in self.credit
if x.amount.data is not None])
@property
def credit_errors(self) -> list[str | LazyString]:
"""Returns the credit journal entry errors, without the errors in their
sub-forms.
:return:
"""
return [x for x in self.credit.errors
if isinstance(x, str) or isinstance(x, LazyString)]
class IncomeTransactionForm(TransactionForm):
"""The form to create or edit a cash income transaction."""
date = DateField(validators=[DATE_REQUIRED])
@ -611,41 +350,6 @@ class IncomeTransactionForm(TransactionForm):
self.collector = Collector
class ExpenseCurrencyForm(CurrencyForm):
"""The form to create or edit a currency in a cash expense transaction."""
no = IntegerField()
"""The order in the transaction."""
code = StringField(
filters=[strip_text],
validators=[DataRequired(MISSING_CURRENCY),
CurrencyExists()])
"""The currency code."""
debit = FieldList(FormField(DebitEntryForm),
validators=[NeedSomeJournalEntries()])
"""The debit entries."""
whole_form = BooleanField()
"""The pseudo field for the whole form validators."""
@property
def debit_total(self) -> Decimal:
"""Returns the total amount of the debit journal entries.
:return: The total amount of the debit journal entries.
"""
return sum([x.amount.data for x in self.debit
if x.amount.data is not None])
@property
def debit_errors(self) -> list[str | LazyString]:
"""Returns the debit journal entry errors, without the errors in their
sub-forms.
:return:
"""
return [x for x in self.debit.errors
if isinstance(x, str) or isinstance(x, LazyString)]
class ExpenseTransactionForm(TransactionForm):
"""The form to create or edit a cash expense transaction."""
date = DateField(validators=[DATE_REQUIRED])
@ -685,76 +389,6 @@ class ExpenseTransactionForm(TransactionForm):
self.collector = Collector
class TransferCurrencyForm(CurrencyForm):
"""The form to create or edit a currency in a transfer transaction."""
class IsBalanced:
"""The validator to check that the total amount of the debit and credit
entries are equal."""
def __call__(self, form: TransferCurrencyForm, field: BooleanField)\
-> None:
if len(form.debit) == 0 or len(form.credit) == 0:
return
if form.debit_total != form.credit_total:
raise ValidationError(lazy_gettext(
"The totals of the debit and credit amounts do not"
" match."))
no = IntegerField()
"""The order in the transaction."""
code = StringField(
filters=[strip_text],
validators=[DataRequired(MISSING_CURRENCY),
CurrencyExists()])
"""The currency code."""
debit = FieldList(FormField(DebitEntryForm),
validators=[NeedSomeJournalEntries()])
"""The debit entries."""
credit = FieldList(FormField(CreditEntryForm),
validators=[NeedSomeJournalEntries()])
"""The credit entries."""
whole_form = BooleanField(validators=[IsBalanced()])
"""The pseudo field for the whole form validators."""
@property
def debit_total(self) -> Decimal:
"""Returns the total amount of the debit journal entries.
:return: The total amount of the debit journal entries.
"""
return sum([x.amount.data for x in self.debit
if x.amount.data is not None])
@property
def credit_total(self) -> Decimal:
"""Returns the total amount of the credit journal entries.
:return: The total amount of the credit journal entries.
"""
return sum([x.amount.data for x in self.credit
if x.amount.data is not None])
@property
def debit_errors(self) -> list[str | LazyString]:
"""Returns the debit journal entry errors, without the errors in their
sub-forms.
:return:
"""
return [x for x in self.debit.errors
if isinstance(x, str) or isinstance(x, LazyString)]
@property
def credit_errors(self) -> list[str | LazyString]:
"""Returns the credit journal entry errors, without the errors in their
sub-forms.
:return:
"""
return [x for x in self.credit.errors
if isinstance(x, str) or isinstance(x, LazyString)]
class TransferTransactionForm(TransactionForm):
"""The form to create or edit a transfer transaction."""
date = DateField(validators=[DATE_REQUIRED])
@ -795,67 +429,3 @@ class TransferTransactionForm(TransactionForm):
self._credit_no = self._credit_no + 1
self.collector = Collector
def sort_transactions_in(txn_date: date, exclude: int) -> None:
"""Sorts the transactions under a date after changing the date or deleting
a transaction.
:param txn_date: The date of the transaction.
:param exclude: The transaction ID to exclude.
:return: None.
"""
transactions: list[Transaction] = Transaction.query\
.filter(Transaction.date == txn_date,
Transaction.id != exclude)\
.order_by(Transaction.no).all()
for i in range(len(transactions)):
if transactions[i].no != i + 1:
transactions[i].no = i + 1
class TransactionReorderForm:
"""The form to reorder the transactions."""
def __init__(self, txn_date: date):
"""Constructs the form to reorder the transactions in a day.
:param txn_date: The date.
"""
self.date: date = txn_date
self.is_modified: bool = False
def save_order(self) -> None:
"""Saves the order of the account.
:return:
"""
transactions: list[Transaction] = Transaction.query\
.filter(Transaction.date == self.date).all()
# Collects the specified order.
orders: dict[Transaction, int] = {}
for txn in transactions:
if f"{txn.id}-no" in request.form:
try:
orders[txn] = int(request.form[f"{txn.id}-no"])
except ValueError:
pass
# Missing and invalid orders are appended to the end.
missing: list[Transaction] \
= [x for x in transactions if x not in orders]
if len(missing) > 0:
next_no: int = 1 if len(orders) == 0 else max(orders.values()) + 1
for txn in missing:
orders[txn] = next_no
# Sort by the specified order first, and their original order.
transactions.sort(key=lambda x: (orders[x], x.no))
# Update the orders.
with db.session.no_autoflush:
for i in range(len(transactions)):
if transactions[i].no != i + 1:
transactions[i].no = i + 1
self.is_modified = True

View File

@ -0,0 +1,19 @@
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/10
# 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 utilities for the transaction management.
"""

View File

@ -0,0 +1,49 @@
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/10
# 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 account option for the transaction management.
"""
from __future__ import annotations
from accounting.models import Account
class AccountOption:
"""An account option."""
def __init__(self, account: Account):
"""Constructs an account option.
:param account: The account.
"""
self.id: str = account.id
"""The account ID."""
self.code: str = account.code
"""The account code."""
self.query_values: list[str] = account.query_values
"""The values to be queried."""
self.__str: str = str(account)
"""The string representation of the account option."""
self.is_in_use: bool = False
"""True if this account is in use, or False otherwise."""
def __str__(self) -> str:
"""Returns the string representation of the account option.
:return: The string representation of the account option.
"""
return self.__str

View File

@ -26,8 +26,8 @@ from flask_wtf import FlaskForm
from accounting.models import Transaction
from accounting.template_globals import default_currency_code
from accounting.utils.txn_types import TransactionType
from .forms import TransactionForm, IncomeTransactionForm, \
ExpenseTransactionForm, TransferTransactionForm
from accounting.transaction.forms import TransactionForm, \
IncomeTransactionForm, ExpenseTransactionForm, TransferTransactionForm
class TransactionOperator(ABC):

View File

@ -34,9 +34,9 @@ from accounting.utils.permission import has_permission, can_view, can_edit
from accounting.utils.txn_types import TransactionType
from accounting.utils.user import get_current_user_pk
from .forms import sort_transactions_in, TransactionReorderForm
from .operators import TransactionOperator, TXN_TYPE_TO_OP, get_txn_op
from .template_filters import with_type, to_transfer, format_amount_input, \
text2html
from .utils.operators import TransactionOperator, TXN_TYPE_TO_OP, get_txn_op
bp: Blueprint = Blueprint("transaction", __name__)
"""The view blueprint for the transaction management."""

View File

@ -23,7 +23,7 @@ import typing as t
from flask import abort, Blueprint
from accounting.utils.user import get_current_user
from accounting.utils.user import get_current_user, UserUtilityInterface
def has_permission(rule: t.Callable[[], bool]) -> t.Callable:
@ -87,22 +87,15 @@ def can_edit() -> bool:
return __can_edit_func()
def init_app(bp: Blueprint,
can_view_func: t.Callable[[], bool] | None = None,
can_edit_func: t.Callable[[], bool] | None = None) -> None:
def init_app(bp: Blueprint, user_utils: UserUtilityInterface) -> None:
"""Initializes the application.
:param bp: The blueprint of the accounting application.
:param can_view_func: A callback that returns whether the current user can
view the accounting data.
:param can_edit_func: A callback that returns whether the current user can
edit the accounting data.
:param user_utils: The user utilities.
:return: None.
"""
global __can_view_func, __can_edit_func
if can_view_func is not None:
__can_view_func = can_view_func
if can_edit_func is not None:
__can_edit_func = can_edit_func
bp.add_app_template_global(can_view, "accounting_can_view")
bp.add_app_template_global(can_edit, "accounting_can_edit")
__can_view_func = user_utils.can_view
__can_edit_func = user_utils.can_edit
bp.add_app_template_global(user_utils.can_view, "accounting_can_view")
bp.add_app_template_global(user_utils.can_edit, "accounting_can_edit")

View File

@ -29,15 +29,33 @@ from flask_sqlalchemy.model import Model
T = t.TypeVar("T", bound=Model)
class AbstractUserUtils(t.Generic[T], ABC):
"""The abstract user utilities."""
class UserUtilityInterface(t.Generic[T], ABC):
"""The interface for the user utilities."""
@abstractmethod
def can_view(self) -> bool:
"""Returns whether the currently logged-in user can view the accounting
data.
:return: True if the currently logged-in user can view the accounting
data, or False otherwise.
"""
@abstractmethod
def can_edit(self) -> bool:
"""Returns whether the currently logged-in user can edit the accounting
data.
:return: True if the currently logged-in user can edit the accounting
data, or False otherwise.
"""
@property
@abstractmethod
def cls(self) -> t.Type[T]:
"""Returns the user class.
"""Returns the class of the user data model.
:return: The user class.
:return: The class of the user data model.
"""
@property
@ -66,13 +84,13 @@ class AbstractUserUtils(t.Generic[T], ABC):
@abstractmethod
def get_pk(self, user: T) -> int:
"""Returns the primary key of the user.
"""Returns the primary key of the user, as an integer.
:return: The primary key of the user.
:return: The primary key of the user, as an integer.
"""
__user_utils: AbstractUserUtils
__user_utils: UserUtilityInterface
"""The user utilities."""
user_cls: t.Type[Model] = Model
"""The user class."""
@ -80,7 +98,7 @@ user_pk_column: sa.Column = sa.Column(sa.Integer)
"""The primary key column of the user class."""
def init_user_utils(utils: AbstractUserUtils) -> None:
def init_user_utils(utils: UserUtilityInterface) -> None:
"""Initializes the user utilities.
:param utils: The user utilities.

View File

@ -26,8 +26,8 @@ from click.testing import Result
from flask import Flask
from flask.testing import FlaskCliRunner
from test_site import create_app, db
from testlib import get_client, set_locale
from test_site import db
from testlib import create_test_app, get_client, set_locale
NEXT_URI: str = "/_next"
"""The next URI."""
@ -74,7 +74,7 @@ class AccountCommandTestCase(unittest.TestCase):
:return: None.
"""
self.app: Flask = create_app(is_testing=True)
self.app: Flask = create_test_app()
runner: FlaskCliRunner = self.app.test_cli_runner()
with self.app.app_context():
@ -127,7 +127,7 @@ class AccountTestCase(unittest.TestCase):
:return: None.
"""
self.app: Flask = create_app(is_testing=True)
self.app: Flask = create_test_app()
runner: FlaskCliRunner = self.app.test_cli_runner()
with self.app.app_context():
@ -372,6 +372,15 @@ class AccountTestCase(unittest.TestCase):
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri)
# A nominal account that needs offset
response = self.client.post(store_uri,
data={"csrf_token": self.csrf_token,
"base_code": "6172",
"title": stock.title,
"is_offset_needed": "yes"})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri)
# Success, with spaces to be stripped
response = self.client.post(store_uri,
data={"csrf_token": self.csrf_token,
@ -470,6 +479,15 @@ class AccountTestCase(unittest.TestCase):
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
# A nominal account that needs offset
response = self.client.post(update_uri,
data={"csrf_token": self.csrf_token,
"base_code": "6172",
"title": stock.title,
"is_offset_needed": "yes"})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
# Change the base account
response = self.client.post(update_uri,
data={"csrf_token": self.csrf_token,

View File

@ -26,8 +26,7 @@ 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 import create_test_app, get_client
LIST_URI: str = "/accounting/base-accounts"
"""The list URI."""
@ -45,7 +44,7 @@ class BaseAccountCommandTestCase(unittest.TestCase):
:return: None.
"""
from accounting.models import BaseAccount, BaseAccountL10n
self.app: Flask = create_app(is_testing=True)
self.app: Flask = create_test_app()
runner: FlaskCliRunner = self.app.test_cli_runner()
with self.app.app_context():
@ -98,7 +97,7 @@ class BaseAccountTestCase(unittest.TestCase):
:return: None.
"""
from accounting.models import BaseAccount
self.app: Flask = create_app(is_testing=True)
self.app: Flask = create_test_app()
runner: FlaskCliRunner = self.app.test_cli_runner()
with self.app.app_context():

View File

@ -27,8 +27,8 @@ from click.testing import Result
from flask import Flask
from flask.testing import FlaskCliRunner
from test_site import create_app, db
from testlib import get_client, set_locale
from test_site import db
from testlib import create_test_app, get_client, set_locale
class CurrencyData:
@ -67,7 +67,7 @@ class CurrencyCommandTestCase(unittest.TestCase):
:return: None.
"""
self.app: Flask = create_app(is_testing=True)
self.app: Flask = create_test_app()
runner: FlaskCliRunner = self.app.test_cli_runner()
with self.app.app_context():
@ -123,7 +123,7 @@ class CurrencyTestCase(unittest.TestCase):
:return: None.
"""
self.app: Flask = create_app(is_testing=True)
self.app: Flask = create_test_app()
runner: FlaskCliRunner = self.app.test_cli_runner()
with self.app.app_context():

View File

@ -29,8 +29,6 @@ from flask_sqlalchemy import SQLAlchemy
from flask_wtf import CSRFProtect
from sqlalchemy import Column
import accounting.utils.user
bp: Blueprint = Blueprint("home", __name__)
babel_js: BabelJS = BabelJS()
csrf: CSRFProtect = CSRFProtect()
@ -69,7 +67,16 @@ def create_app(is_testing: bool = False) -> Flask:
from . import auth
auth.init_app(app)
class UserUtils(accounting.utils.user.AbstractUserUtils[auth.User]):
class UserUtilities(accounting.UserUtilityInterface[auth.User]):
def can_view(self) -> bool:
return auth.current_user() is not None \
and auth.current_user().username in ["viewer", "editor",
"editor2"]
def can_edit(self) -> bool:
return auth.current_user() is not None \
and auth.current_user().username in ["editor", "editor2"]
@property
def cls(self) -> t.Type[auth.User]:
@ -90,12 +97,7 @@ def create_app(is_testing: bool = False) -> Flask:
def get_pk(self, user: auth.User) -> int:
return user.id
can_view: t.Callable[[], bool] = lambda: auth.current_user() is not None \
and auth.current_user().username in ["viewer", "editor", "editor2"]
can_edit: t.Callable[[], bool] = lambda: auth.current_user() is not None \
and auth.current_user().username in ["editor", "editor2"]
accounting.init_app(app, user_utils=UserUtils(),
can_view_func=can_view, can_edit_func=can_edit)
accounting.init_app(app, user_utils=UserUtilities())
return app

View File

@ -24,8 +24,7 @@ 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 import create_test_app, get_client
from testlib_txn import Accounts, NEXT_URI, add_txn
@ -38,7 +37,7 @@ class SummeryEditorTestCase(unittest.TestCase):
:return: None.
"""
self.app: Flask = create_app(is_testing=True)
self.app: Flask = create_test_app()
runner: FlaskCliRunner = self.app.test_cli_runner()
with self.app.app_context():
@ -66,7 +65,7 @@ class SummeryEditorTestCase(unittest.TestCase):
:return: None.
"""
from accounting.transaction.summary_editor import SummaryEditor
from accounting.transaction.utils.summary_editor import SummaryEditor
for form in get_form_data(self.csrf_token):
add_txn(self.client, form)
with self.app.app_context():
@ -79,13 +78,13 @@ class SummeryEditorTestCase(unittest.TestCase):
self.assertEqual(editor.debit.general.tags[0].accounts[0].code,
Accounts.MEAL)
self.assertEqual(editor.debit.general.tags[0].accounts[1].code,
Accounts.PAYABLE)
Accounts.PETTY_CASH)
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)
Accounts.PETTY_CASH)
# Debit-Travel
self.assertEqual(len(editor.debit.travel.tags), 3)
@ -118,7 +117,7 @@ class SummeryEditorTestCase(unittest.TestCase):
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)
Accounts.PETTY_CASH)
self.assertEqual(editor.credit.general.tags[0].accounts[1].code,
Accounts.BANK)
self.assertEqual(editor.credit.general.tags[0].accounts[2].code,
@ -128,20 +127,20 @@ class SummeryEditorTestCase(unittest.TestCase):
self.assertEqual(editor.credit.general.tags[1].accounts[0].code,
Accounts.BANK)
self.assertEqual(editor.credit.general.tags[1].accounts[1].code,
Accounts.PAYABLE)
Accounts.PETTY_CASH)
# 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)
Accounts.PETTY_CASH)
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)
Accounts.PETTY_CASH)
self.assertEqual(editor.credit.travel.tags[1].accounts[1].code,
Accounts.CASH)
@ -152,7 +151,7 @@ class SummeryEditorTestCase(unittest.TestCase):
self.assertEqual(editor.credit.bus.tags[0].accounts[0].code,
Accounts.PREPAID)
self.assertEqual(editor.credit.bus.tags[0].accounts[1].code,
Accounts.PAYABLE)
Accounts.PETTY_CASH)
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,
@ -186,7 +185,7 @@ def get_form_data(csrf_token: str) -> list[dict[str, str]]:
"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-account_code": Accounts.PETTY_CASH,
"currency-0-credit-1-summary": " Lunch—Fries ",
"currency-0-credit-1-amount": "2.15",
"currency-0-debit-2-account_code": Accounts.MEAL,
@ -208,7 +207,7 @@ def get_form_data(csrf_token: str) -> list[dict[str, str]]:
"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-account_code": Accounts.PETTY_CASH,
"currency-0-credit-1-summary": " Dinner—Steak ",
"currency-0-credit-1-amount": "8.28"},
{"csrf_token": csrf_token,
@ -218,13 +217,13 @@ def get_form_data(csrf_token: str) -> list[dict[str, str]]:
"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-account_code": Accounts.PETTY_CASH,
"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-account_code": Accounts.PETTY_CASH,
"currency-0-credit-1-summary": " Lunch—Noodles ",
"currency-0-credit-1-amount": "7.47"},
{"csrf_token": csrf_token,
@ -259,7 +258,7 @@ def get_form_data(csrf_token: str) -> list[dict[str, str]]:
"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-account_code": Accounts.PETTY_CASH,
"currency-0-credit-3-summary": " Train—Red—Mall→Museum ",
"currency-0-credit-3-amount": "4.4"},
{"csrf_token": csrf_token,
@ -275,31 +274,31 @@ def get_form_data(csrf_token: str) -> list[dict[str, str]]:
"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-account_code": Accounts.PETTY_CASH,
"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-account_code": Accounts.PETTY_CASH,
"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-account_code": Accounts.PETTY_CASH,
"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-account_code": Accounts.PETTY_CASH,
"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-account_code": Accounts.PETTY_CASH,
"currency-0-credit-5-summary": " Bike—Office→Theatre ",
"currency-0-credit-5-amount": "1.5",
"currency-0-debit-6-account_code": Accounts.TRAVEL,
@ -312,13 +311,13 @@ def get_form_data(csrf_token: str) -> list[dict[str, str]]:
"next": NEXT_URI,
"date": txn_date,
"currency-0-code": "USD",
"currency-0-debit-0-account_code": Accounts.PAYABLE,
"currency-0-debit-0-account_code": Accounts.PETTY_CASH,
"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-account_code": Accounts.PETTY_CASH,
"currency-0-debit-1-summary": " Lunch—Pizza ",
"currency-0-debit-1-amount": "5.49",
"currency-0-credit-1-account_code": Accounts.BANK,

View File

@ -26,8 +26,8 @@ from click.testing import Result
from flask import Flask
from flask.testing import FlaskCliRunner
from test_site import create_app, db
from testlib import get_client
from test_site import db
from testlib import create_test_app, 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, \
@ -48,7 +48,7 @@ class CashIncomeTransactionTestCase(unittest.TestCase):
:return: None.
"""
self.app: Flask = create_app(is_testing=True)
self.app: Flask = create_test_app()
runner: FlaskCliRunner = self.app.test_cli_runner()
with self.app.app_context():
@ -600,7 +600,7 @@ class CashExpenseTransactionTestCase(unittest.TestCase):
:return: None.
"""
self.app: Flask = create_app(is_testing=True)
self.app: Flask = create_test_app()
runner: FlaskCliRunner = self.app.test_cli_runner()
with self.app.app_context():
@ -1159,7 +1159,7 @@ class TransferTransactionTestCase(unittest.TestCase):
:return: None.
"""
self.app: Flask = create_app(is_testing=True)
self.app: Flask = create_test_app()
runner: FlaskCliRunner = self.app.test_cli_runner()
with self.app.app_context():
@ -1973,7 +1973,7 @@ class TransactionReorderTestCase(unittest.TestCase):
:return: None.
"""
self.app: Flask = create_app(is_testing=True)
self.app: Flask = create_test_app()
runner: FlaskCliRunner = self.app.test_cli_runner()
with self.app.app_context():

View File

@ -21,13 +21,12 @@ import unittest
from urllib.parse import quote_plus
import httpx
from flask import Flask, request, render_template_string
from flask import Flask, request
from accounting.utils.next_uri import append_next, inherit_next, or_next
from accounting.utils.pagination import Pagination, DEFAULT_PAGE_SIZE
from accounting.utils.query import parse_query_keywords
from test_site import create_app
from testlib import TEST_SERVER
from testlib import TEST_SERVER, create_test_app, get_csrf_token
class NextUriTestCase(unittest.TestCase):
@ -40,12 +39,7 @@ class NextUriTestCase(unittest.TestCase):
:return: None.
"""
self.app: Flask = create_app(is_testing=True)
@self.app.get("/test-csrf")
def test_csrf() -> str:
"""The test view to return the CSRF token."""
return render_template_string("{{csrf_token()}}")
self.app: Flask = create_test_app()
def test_next_uri(self) -> None:
"""Tests the next URI utilities with the next URI.
@ -69,7 +63,7 @@ class NextUriTestCase(unittest.TestCase):
methods=["GET", "POST"])
client: httpx.Client = httpx.Client(app=self.app, base_url=TEST_SERVER)
client.headers["Referer"] = TEST_SERVER
csrf_token: str = client.get("/test-csrf").text
csrf_token: str = get_csrf_token(client)
response: httpx.Response
response = client.get("/test-next?next=/next&q=abc&page-no=4")
@ -98,7 +92,7 @@ class NextUriTestCase(unittest.TestCase):
methods=["GET", "POST"])
client: httpx.Client = httpx.Client(app=self.app, base_url=TEST_SERVER)
client.headers["Referer"] = TEST_SERVER
csrf_token: str = client.get("/test-csrf").text
csrf_token: str = get_csrf_token(client)
response: httpx.Response
response = client.get("/test-no-next?q=abc&page-no=4")
@ -171,7 +165,7 @@ class PaginationTestCase(unittest.TestCase):
:return: None.
"""
self.app: Flask = create_app(is_testing=True)
self.app: Flask = create_test_app()
self.params = self.Params([], None, [], True)
@self.app.get("/test-pagination")

View File

@ -18,15 +18,41 @@
"""
import typing as t
from html.parser import HTMLParser
import httpx
from flask import Flask
from flask import Flask, render_template_string
from test_site import create_app
TEST_SERVER: str = "https://testserver"
"""The test server URI."""
def create_test_app() -> Flask:
"""Creates and returns the testing Flask application.
:return: The testing Flask application.
"""
app: Flask = create_app(is_testing=True)
@app.get("/.csrf-token")
def get_csrf_token_view() -> str:
"""The test view to return the CSRF token."""
return render_template_string("{{csrf_token()}}")
return app
def get_csrf_token(client: httpx.Client) -> str:
"""Returns the CSRF token.
:param client: The httpx client.
:return: The CSRF token.
"""
return client.get("/.csrf-token").text
def get_client(app: Flask, username: str) -> tuple[httpx.Client, str]:
"""Returns a user client.
@ -36,7 +62,7 @@ def get_client(app: Flask, username: str) -> tuple[httpx.Client, str]:
"""
client: httpx.Client = httpx.Client(app=app, base_url=TEST_SERVER)
client.headers["Referer"] = TEST_SERVER
csrf_token: str = get_csrf_token(client, "/login")
csrf_token: str = get_csrf_token(client)
response: httpx.Response = client.post("/login",
data={"csrf_token": csrf_token,
"username": username})
@ -45,38 +71,6 @@ def get_client(app: Flask, username: str) -> tuple[httpx.Client, str]:
return client, csrf_token
def get_csrf_token(client: httpx.Client, uri: str) -> str:
"""Returns the CSRF token from a form in a URI.
:param client: The httpx client.
:param uri: The URI.
:return: The CSRF token.
"""
class CsrfParser(HTMLParser):
"""The CSRF token parser."""
def __init__(self):
"""Constructs the CSRF token parser."""
super().__init__()
self.csrf_token: str | None = None
"""The CSRF token."""
def handle_starttag(self, tag: str,
attrs: list[tuple[str, str | None]]) -> None:
"""Handles when a start tag is found."""
attrs_dict: dict[str, str] = dict(attrs)
if attrs_dict.get("name") == "csrf_token":
self.csrf_token = attrs_dict["value"]
response: httpx.Response = client.get(uri)
assert response.status_code == 200
parser: CsrfParser = CsrfParser()
parser.feed(response.text)
assert parser.csrf_token is not None
return parser.csrf_token
def set_locale(client: httpx.Client, csrf_token: str,
locale: t.Literal["en", "zh_Hant", "zh_Hans"]) -> None:
"""Sets the current locale.

View File

@ -38,6 +38,7 @@ EMPTY_NOTE: str = " \n\n "
class Accounts:
"""The shortcuts to the common accounts."""
CASH: str = "1111-001"
PETTY_CASH: str = "1112-001"
BANK: str = "1113-001"
PREPAID: str = "1258-001"
PAYABLE: str = "2141-001"