Compare commits

..

No commits in common. "f3548a2327d1fb16fb9885557c3842d5efb10240" and "b28d446d07d0777f79782511f4523e5f3263aa3e" have entirely different histories.

30 changed files with 530 additions and 2233 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -46,9 +46,9 @@ function initializeBaseAccountSelector() {
const btnClear = document.getElementById("accounting-btn-clear-base"); const btnClear = document.getElementById("accounting-btn-clear-base");
selector.addEventListener("show.bs.modal", function () { selector.addEventListener("show.bs.modal", function () {
base.classList.add("accounting-not-empty"); base.classList.add("accounting-not-empty");
for (const option of options) { options.forEach(function (item) {
option.classList.remove("active"); item.classList.remove("active");
} });
const selected = document.getElementById("accounting-base-option-" + baseCode.value); const selected = document.getElementById("accounting-base-option-" + baseCode.value);
if (selected !== null) { if (selected !== null) {
selected.classList.add("active"); selected.classList.add("active");
@ -59,7 +59,7 @@ function initializeBaseAccountSelector() {
base.classList.remove("accounting-not-empty"); base.classList.remove("accounting-not-empty");
} }
}); });
for (const option of options) { options.forEach(function (option) {
option.onclick = function () { option.onclick = function () {
baseCode.value = option.dataset.code; baseCode.value = option.dataset.code;
baseContent.innerText = option.dataset.content; baseContent.innerText = option.dataset.content;
@ -69,7 +69,7 @@ function initializeBaseAccountSelector() {
validateBase(); validateBase();
bootstrap.Modal.getInstance(selector).hide(); bootstrap.Modal.getInstance(selector).hide();
}; };
} });
btnClear.onclick = function () { btnClear.onclick = function () {
baseCode.value = ""; baseCode.value = "";
baseContent.innerText = ""; baseContent.innerText = "";
@ -94,15 +94,15 @@ function initializeBaseAccountQuery() {
const queryNoResult = document.getElementById("accounting-base-option-no-result"); const queryNoResult = document.getElementById("accounting-base-option-no-result");
query.addEventListener("input", function () { query.addEventListener("input", function () {
if (query.value === "") { if (query.value === "") {
for (const option of options) { options.forEach(function (option) {
option.classList.remove("d-none"); option.classList.remove("d-none");
} });
optionList.classList.remove("d-none"); optionList.classList.remove("d-none");
queryNoResult.classList.add("d-none"); queryNoResult.classList.add("d-none");
return return
} }
let hasAnyMatched = false; let hasAnyMatched = false;
for (const option of options) { options.forEach(function (option) {
const queryValues = JSON.parse(option.dataset.queryValues); const queryValues = JSON.parse(option.dataset.queryValues);
let isMatched = false; let isMatched = false;
for (const queryValue of queryValues) { for (const queryValue of queryValues) {
@ -117,7 +117,7 @@ function initializeBaseAccountQuery() {
} else { } else {
option.classList.add("d-none"); option.classList.add("d-none");
} }
} });
if (!hasAnyMatched) { if (!hasAnyMatched) {
optionList.classList.add("d-none"); optionList.classList.add("d-none");
queryNoResult.classList.remove("d-none"); queryNoResult.classList.remove("d-none");

View File

@ -1,254 +0,0 @@
/* The Mia! Accounting Flask Project
* transaction-transfer-form.js: The JavaScript for the transfer transaction form
*/
/* Copyright (c) 2023 imacat.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/* Author: imacat@mail.imacat.idv.tw (imacat)
* First written: 2023/2/28
*/
// Initializes the page JavaScript.
document.addEventListener("DOMContentLoaded", function () {
AccountSelector.initialize();
});
/**
* The account selector.
*
*/
class AccountSelector {
/**
* The entry type
* @type {string}
*/
#entryType;
/**
* The prefix of the HTML ID and class
* @type {string}
*/
#prefix;
/**
* Constructs an account selector.
*
* @param modal {HTMLFormElement} the account selector modal
*/
constructor(modal) {
this.#entryType = modal.dataset.entryType;
this.#prefix = "accounting-account-selector-" + modal.dataset.entryType;
this.#init();
}
/**
* Initializes the account selector.
*
*/
#init() {
const formAccountControl = document.getElementById("accounting-entry-form-account-control");
const formAccount = document.getElementById("accounting-entry-form-account");
const more = document.getElementById(this.#prefix + "-more");
const btnClear = document.getElementById(this.#prefix + "-btn-clear");
const options = Array.from(document.getElementsByClassName(this.#prefix + "-option"));
const selector1 = this
more.onclick = function () {
more.classList.add("d-none");
selector1.#filterAccountOptions();
};
this.#initializeAccountQuery();
btnClear.onclick = function () {
formAccountControl.classList.remove("accounting-not-empty");
formAccount.innerText = "";
formAccount.dataset.code = "";
formAccount.dataset.text = "";
validateJournalEntryAccount();
};
for (const option of options) {
option.onclick = function () {
formAccountControl.classList.add("accounting-not-empty");
formAccount.innerText = option.dataset.content;
formAccount.dataset.code = option.dataset.code;
formAccount.dataset.text = option.dataset.content;
validateJournalEntryAccount();
};
}
}
/**
* Initializes the query on the account options.
*
*/
#initializeAccountQuery() {
const query = document.getElementById(this.#prefix + "-query");
const helper = this;
query.addEventListener("input", function () {
helper.#filterAccountOptions();
});
}
/**
* Filters the account options.
*
*/
#filterAccountOptions() {
const query = document.getElementById(this.#prefix + "-query");
const optionList = document.getElementById(this.#prefix + "-option-list");
if (optionList === null) {
console.log(this.#prefix + "-option-list");
}
const options = Array.from(document.getElementsByClassName(this.#prefix + "-option"));
const more = document.getElementById(this.#prefix + "-more");
const queryNoResult = document.getElementById(this.#prefix + "-option-no-result");
const codesInUse = this.#getAccountCodeUsedInForm();
let shouldAnyShow = false;
for (const option of options) {
const shouldShow = this.#shouldAccountOptionShow(option, more, codesInUse, query);
if (shouldShow) {
option.classList.remove("d-none");
shouldAnyShow = true;
} else {
option.classList.add("d-none");
}
}
if (!shouldAnyShow && more.classList.contains("d-none")) {
optionList.classList.add("d-none");
queryNoResult.classList.remove("d-none");
} else {
optionList.classList.remove("d-none");
queryNoResult.classList.add("d-none");
}
}
/**
* Returns the account codes that are used in the form.
*
* @return {string[]} the account codes that are used in the form
*/
#getAccountCodeUsedInForm() {
const accountCodes = Array.from(document.getElementsByClassName("accounting-" + this.#prefix + "-account-code"));
const formAccount = document.getElementById("accounting-entry-form-account");
const inUse = [formAccount.dataset.code];
for (const accountCode of accountCodes) {
inUse.push(accountCode.value);
}
return inUse
}
/**
* Returns whether an account option should show.
*
* @param option {HTMLLIElement} the account option
* @param more {HTMLLIElement} the more account element
* @param inUse {string[]} the account codes that are used in the form
* @param query {HTMLInputElement} the query element, if any
* @return {boolean} true if the account option should show, or false otherwise
*/
#shouldAccountOptionShow(option, more, inUse, query) {
const isQueryMatched = function () {
if (query.value === "") {
return true;
}
const queryValues = JSON.parse(option.dataset.queryValues);
for (const queryValue of queryValues) {
if (queryValue.includes(query.value)) {
return true;
}
}
return false;
};
const isMoreMatched = function () {
if (more.classList.contains("d-none")) {
return true;
}
return option.classList.contains("accounting-account-in-use") || inUse.includes(option.dataset.code);
};
return isMoreMatched() && isQueryMatched();
}
/**
* Initializes the account selector when it is shown.
*
*/
initShow() {
const formAccount = document.getElementById("accounting-entry-form-account");
const query = document.getElementById(this.#prefix + "-query")
const more = document.getElementById(this.#prefix + "-more");
const options = Array.from(document.getElementsByClassName(this.#prefix + "-option"));
const btnClear = document.getElementById(this.#prefix + "-btn-clear");
query.value = "";
more.classList.remove("d-none");
this.#filterAccountOptions();
for (const option of options) {
if (option.dataset.code === formAccount.dataset.code) {
option.classList.add("active");
} else {
option.classList.remove("active");
}
}
if (formAccount.dataset.code === "") {
btnClear.classList.add("btn-secondary");
btnClear.classList.remove("btn-danger");
btnClear.disabled = true;
} else {
btnClear.classList.add("btn-danger");
btnClear.classList.remove("btn-secondary");
btnClear.disabled = false;
}
}
/**
* The account selectors.
* @type {{debit: AccountSelector, credit: AccountSelector}}
*/
static #selectors = {}
/**
* Initializes the account selectors.
*
*/
static initialize() {
const modals = Array.from(document.getElementsByClassName("accounting-account-selector-modal"));
for (const modal of modals) {
const selector = new AccountSelector(modal);
this.#selectors[selector.#entryType] = selector;
}
this.#initializeTransactionForm();
}
/**
* Initializes the transaction form.
*
*/
static #initializeTransactionForm() {
const entryForm = document.getElementById("accounting-entry-form");
const formAccountControl = document.getElementById("accounting-entry-form-account-control");
const selectors = this.#selectors;
formAccountControl.onclick = function () {
selectors[entryForm.dataset.entryType].initShow();
};
}
/**
* Initializes the account selector for the journal entry form.
*x
*/
static initializeJournalEntryForm() {
const entryForm = document.getElementById("accounting-entry-form");
const formAccountControl = document.getElementById("accounting-entry-form-account-control");
formAccountControl.dataset.bsTarget = "#accounting-account-selector-" + entryForm.dataset.entryType + "-modal";
}
}

View File

@ -65,9 +65,9 @@ function validateForm() {
*/ */
function submitFormIfAllAsyncValid() { function submitFormIfAllAsyncValid() {
let isValid = true; let isValid = true;
for (const key of Object.keys(isAsyncValid)) { Object.keys(isAsyncValid).forEach(function (key) {
isValid = isAsyncValid[key] && isValid; isValid = isAsyncValid[key] && isValid;
} });
if (isValid) { if (isValid) {
document.getElementById("accounting-form").submit() document.getElementById("accounting-form").submit()
} }

View File

@ -42,7 +42,7 @@ function initializeDragAndDropReordering(list, onReorder) {
function initializeMouseDragAndDropReordering(list, onReorder) { function initializeMouseDragAndDropReordering(list, onReorder) {
const items = Array.from(list.children); const items = Array.from(list.children);
let dragged = null; let dragged = null;
for (const item of items) { items.forEach(function (item) {
item.draggable = true; item.draggable = true;
item.addEventListener("dragstart", function () { item.addEventListener("dragstart", function () {
dragged = item; dragged = item;
@ -56,7 +56,7 @@ function initializeMouseDragAndDropReordering(list, onReorder) {
dragged.classList.remove("accounting-dragged"); dragged.classList.remove("accounting-dragged");
dragged = null; dragged = null;
}); });
} });
} }
/** /**
@ -68,7 +68,7 @@ function initializeMouseDragAndDropReordering(list, onReorder) {
*/ */
function initializeTouchDragAndDropReordering(list, onReorder) { function initializeTouchDragAndDropReordering(list, onReorder) {
const items = Array.from(list.children); const items = Array.from(list.children);
for (const item of items) { items.forEach(function (item) {
item.addEventListener("touchstart", function () { item.addEventListener("touchstart", function () {
item.classList.add("accounting-dragged"); item.classList.add("accounting-dragged");
}); });
@ -81,7 +81,7 @@ function initializeTouchDragAndDropReordering(list, onReorder) {
item.addEventListener("touchend", function () { item.addEventListener("touchend", function () {
item.classList.remove("accounting-dragged"); item.classList.remove("accounting-dragged");
}); });
} });
} }
/** /**

View File

@ -1,827 +0,0 @@
/* The Mia! Accounting Flask Project
* summary-helper.js: The JavaScript for the summary helper
*/
/* Copyright (c) 2023 imacat.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/* Author: imacat@mail.imacat.idv.tw (imacat)
* First written: 2023/2/28
*/
// Initializes the page JavaScript.
document.addEventListener("DOMContentLoaded", function () {
SummaryHelper.initialize();
});
/**
* A summary helper.
*
*/
class SummaryHelper {
/**
* The entry type, either "debit" or "credit"
* @type {string}
*/
#entryType;
/**
* The prefix of the HTML ID and class
* @type {string}
*/
#prefix;
/**
* The default tab ID
* @type {string}
*/
#defaultTabId;
/**
* Constructs a summary helper.
*
* @param form {HTMLFormElement} the summary helper form
*/
constructor(form) {
this.#entryType = form.dataset.entryType;
this.#prefix = "accounting-summary-helper-" + form.dataset.entryType;
this.#defaultTabId = form.dataset.defaultTabId;
this.#init();
}
/**
* Initializes the summary helper.
*
*/
#init() {
const helper = this;
const summary = document.getElementById(this.#prefix + "-summary");
const tabs = Array.from(document.getElementsByClassName(this.#prefix + "-tab"));
for (const tab of tabs) {
tab.onclick = function () {
helper.#switchToTab(tab.dataset.tabId);
}
}
this.#initializeGeneralTagHelper();
this.#initializeGeneralTripHelper();
this.#initializeBusTripHelper();
this.#initializeNumberHelper();
this.#initializeSuggestedAccounts();
this.#initializeSubmission();
summary.onchange = function () {
summary.value = summary.value.trim();
helper.#parseAndPopulate();
};
}
/**
* Switches to a tab.
*
* @param tabId {string} the tab ID.
*/
#switchToTab(tabId) {
const tabs = Array.from(document.getElementsByClassName(this.#prefix + "-tab"));
const pages = Array.from(document.getElementsByClassName(this.#prefix + "-page"));
const tagButtons = Array.from(document.getElementsByClassName(this.#prefix + "-" + tabId + "-btn-tag"));
for (const tab of tabs) {
if (tab.dataset.tabId === tabId) {
tab.classList.add("active");
tab.ariaCurrent = "page";
} else {
tab.classList.remove("active");
tab.ariaCurrent = "false";
}
}
for (const page of pages) {
if (page.dataset.tabId === tabId) {
page.classList.remove("d-none");
page.ariaCurrent = "page";
} else {
page.classList.add("d-none");
page.ariaCurrent = "false";
}
}
let selectedBtnTag = null;
for (const tagButton of tagButtons) {
if (tagButton.classList.contains("btn-primary")) {
selectedBtnTag = tagButton;
break;
}
}
this.#filterSuggestedAccounts(selectedBtnTag);
}
/**
* Initializes the general tag helper.
*
*/
#initializeGeneralTagHelper() {
const summary = document.getElementById(this.#prefix + "-summary");
const tag = document.getElementById(this.#prefix + "-general-tag");
const helper = this;
const updateSummary = function () {
const pos = summary.value.indexOf("—");
const prefix = tag.value === ""? "": tag.value + "—";
if (pos === -1) {
summary.value = prefix + summary.value;
} else {
summary.value = prefix + summary.value.substring(pos + 1);
}
}
this.#initializeTagButtons("general", tag, updateSummary);
tag.onchange = function () {
helper.#onTagInputChange("general", tag, updateSummary);
};
}
/**
* Initializes the general trip helper.
*
*/
#initializeGeneralTripHelper() {
const summary = document.getElementById(this.#prefix + "-summary");
const tag = document.getElementById(this.#prefix + "-travel-tag");
const from = document.getElementById(this.#prefix + "-travel-from");
const directionButtons = Array.from(document.getElementsByClassName(this.#prefix + "-travel-direction"))
const to = document.getElementById(this.#prefix + "-travel-to");
const helper = this;
const updateSummary = function () {
let direction;
for (const directionButton of directionButtons) {
if (directionButton.classList.contains("btn-primary")) {
direction = directionButton.dataset.arrow;
break;
}
}
summary.value = tag.value + "—" + from.value + direction + to.value;
};
this.#initializeTagButtons("travel", tag, updateSummary);
tag.onchange = function () {
helper.#onTagInputChange("travel", tag, updateSummary);
helper.#validateGeneralTripTag();
};
from.onchange = function () {
updateSummary();
helper.#validateGeneralTripFrom();
};
for (const directionButton of directionButtons) {
directionButton.onclick = function () {
for (const otherButton of directionButtons) {
otherButton.classList.remove("btn-primary");
otherButton.classList.add("btn-outline-primary");
}
directionButton.classList.remove("btn-outline-primary");
directionButton.classList.add("btn-primary");
updateSummary();
};
}
to.onchange = function () {
updateSummary();
helper.#validateGeneralTripTo();
};
}
/**
* Initializes the bus trip helper.
*
*/
#initializeBusTripHelper() {
const summary = document.getElementById(this.#prefix + "-summary");
const tag = document.getElementById(this.#prefix + "-bus-tag");
const route = document.getElementById(this.#prefix + "-bus-route");
const from = document.getElementById(this.#prefix + "-bus-from");
const to = document.getElementById(this.#prefix + "-bus-to");
const helper = this;
const updateSummary = function () {
summary.value = tag.value + "—" + route.value + "—" + from.value + "→" + to.value;
};
this.#initializeTagButtons("bus", tag, updateSummary);
tag.onchange = function () {
helper.#onTagInputChange("bus", tag, updateSummary);
helper.#validateBusTripTag();
};
route.onchange = function () {
updateSummary();
helper.#validateBusTripRoute();
};
from.onchange = function () {
updateSummary();
helper.#validateBusTripFrom();
};
to.onchange = function () {
updateSummary();
helper.#validateBusTripTo();
};
}
/**
* Initializes the tag buttons.
*
* @param tabId {string} the tab ID
* @param tag {HTMLInputElement} the tag input
* @param updateSummary {function(): void} the callback to update the summary
*/
#initializeTagButtons(tabId, tag, updateSummary) {
const tagButtons = Array.from(document.getElementsByClassName(this.#prefix + "-" + tabId + "-btn-tag"));
const helper = this;
for (const tagButton of tagButtons) {
tagButton.onclick = function () {
for (const otherButton of tagButtons) {
otherButton.classList.remove("btn-primary");
otherButton.classList.add("btn-outline-primary");
}
tagButton.classList.remove("btn-outline-primary");
tagButton.classList.add("btn-primary");
tag.value = tagButton.dataset.value;
helper.#filterSuggestedAccounts(tagButton);
updateSummary();
};
}
}
/**
* The callback when the tag input is changed.
*
* @param tabId {string} the tab ID
* @param tag {HTMLInputElement} the tag input
* @param updateSummary {function(): void} the callback to update the summary
*/
#onTagInputChange(tabId, tag, updateSummary) {
const tagButtons = Array.from(document.getElementsByClassName(this.#prefix + "-" + tabId + "-btn-tag"));
let isMatched = false;
for (const tagButton of tagButtons) {
if (tagButton.dataset.value === tag.value) {
tagButton.classList.remove("btn-outline-primary");
tagButton.classList.add("btn-primary");
this.#filterSuggestedAccounts(tagButton);
isMatched = true;
} else {
tagButton.classList.remove("btn-primary");
tagButton.classList.add("btn-outline-primary");
}
}
if (!isMatched) {
this.#filterSuggestedAccounts(null);
}
updateSummary();
}
/**
* Filters the suggested accounts.
*
* @param tagButton {HTMLButtonElement|null} the tag button
*/
#filterSuggestedAccounts(tagButton) {
const accountButtons = Array.from(document.getElementsByClassName(this.#prefix + "-account"));
if (tagButton === null) {
for (const accountButton of accountButtons) {
accountButton.classList.add("d-none");
accountButton.classList.remove("btn-primary");
accountButton.classList.add("btn-outline-primary");
this.#selectAccount(null);
}
return;
}
const suggested = JSON.parse(tagButton.dataset.accounts);
for (const accountButton of accountButtons) {
if (suggested.includes(accountButton.dataset.code)) {
accountButton.classList.remove("d-none");
} else {
accountButton.classList.add("d-none");
}
this.#selectAccount(suggested[0]);
}
}
/**
* Initializes the number helper.
*
*/
#initializeNumberHelper() {
const summary = document.getElementById(this.#prefix + "-summary");
const number = document.getElementById(this.#prefix + "-number");
number.onchange = function () {
const found = summary.value.match(/^(.+)×(\d+)$/);
if (found !== null) {
summary.value = found[1];
}
if (number.value > 1) {
summary.value = summary.value + "×" + String(number.value);
}
};
}
/**
* Initializes the suggested accounts.
*
*/
#initializeSuggestedAccounts() {
const accountButtons = Array.from(document.getElementsByClassName(this.#prefix + "-account"));
const helper = this;
for (const accountButton of accountButtons) {
accountButton.onclick = function () {
helper.#selectAccount(accountButton.dataset.code);
};
}
}
/**
* Select a suggested account.
*
* @param selectedCode {string|null} the account code, or null to deselect the account
*/
#selectAccount(selectedCode) {
const form = document.getElementById(this.#prefix);
if (selectedCode === null) {
form.dataset.selectedAccountCode = "";
form.dataset.selectedAccountText = "";
return;
}
const accountButtons = Array.from(document.getElementsByClassName(this.#prefix + "-account"));
for (const accountButton of accountButtons) {
if (accountButton.dataset.code === selectedCode) {
accountButton.classList.remove("btn-outline-primary");
accountButton.classList.add("btn-primary");
form.dataset.selectedAccountCode = accountButton.dataset.code;
form.dataset.selectedAccountText = accountButton.dataset.text;
} else {
accountButton.classList.remove("btn-primary");
accountButton.classList.add("btn-outline-primary");
}
}
}
/**
* Initializes the summary submission
*
*/
#initializeSubmission() {
const form = document.getElementById(this.#prefix);
const helper = this;
form.onsubmit = function () {
if (helper.#validate()) {
helper.#submit();
}
return false;
};
}
/**
* Validates the form.
*
* @return {boolean} true if valid, or false otherwise
*/
#validate() {
const tabs = Array.from(document.getElementsByClassName(this.#prefix + "-tab"));
let isValid = true;
for (const tab of tabs) {
if (tab.classList.contains("active")) {
switch (tab.dataset.tabId) {
case "general":
isValid = this.#validateGeneralTag() && isValid;
break;
case "travel":
isValid = this.#validateGeneralTrip() && isValid;
break;
case "bus":
isValid = this.#validateBusTrip() && isValid;
break;
}
}
}
return isValid;
}
/**
* Validates a general tag.
*
* @return {boolean} true if valid, or false otherwise
*/
#validateGeneralTag() {
const field = document.getElementById(this.#prefix + "-general-tag");
const error = document.getElementById(this.#prefix + "-general-tag-error");
field.value = field.value.trim();
field.classList.remove("is-invalid");
error.innerText = "";
return true;
}
/**
* Validates a general trip.
*
* @return {boolean} true if valid, or false otherwise
*/
#validateGeneralTrip() {
let isValid = true;
isValid = this.#validateGeneralTripTag() && isValid;
isValid = this.#validateGeneralTripFrom() && isValid;
isValid = this.#validateGeneralTripTo() && isValid;
return isValid;
}
/**
* Validates the tag of a general trip.
*
* @return {boolean} true if valid, or false otherwise
*/
#validateGeneralTripTag() {
const field = document.getElementById(this.#prefix + "-travel-tag");
const error = document.getElementById(this.#prefix + "-travel-tag-error");
field.value = field.value.trim();
if (field.value === "") {
field.classList.add("is-invalid");
error.innerText = A_("Please fill in the tag.");
return false;
}
field.classList.remove("is-invalid");
error.innerText = "";
return true;
}
/**
* Validates the origin of a general trip.
*
* @return {boolean} true if valid, or false otherwise
*/
#validateGeneralTripFrom() {
const field = document.getElementById(this.#prefix + "-travel-from");
const error = document.getElementById(this.#prefix + "-travel-from-error");
field.value = field.value.trim();
if (field.value === "") {
field.classList.add("is-invalid");
error.innerText = A_("Please fill in the origin.");
return false;
}
field.classList.remove("is-invalid");
error.innerText = "";
return true;
}
/**
* Validates the destination of a general trip.
*
* @return {boolean} true if valid, or false otherwise
*/
#validateGeneralTripTo() {
const field = document.getElementById(this.#prefix + "-travel-to");
const error = document.getElementById(this.#prefix + "-travel-to-error");
field.value = field.value.trim();
if (field.value === "") {
field.classList.add("is-invalid");
error.innerText = A_("Please fill in the destination.");
return false;
}
field.classList.remove("is-invalid");
error.innerText = "";
return true;
}
/**
* Validates a bus trip.
*
* @return {boolean} true if valid, or false otherwise
*/
#validateBusTrip() {
let isValid = true;
isValid = this.#validateBusTripTag() && isValid;
isValid = this.#validateBusTripRoute() && isValid;
isValid = this.#validateBusTripFrom() && isValid;
isValid = this.#validateBusTripTo() && isValid;
return isValid;
}
/**
* Validates the tag of a bus trip.
*
* @return {boolean} true if valid, or false otherwise
*/
#validateBusTripTag() {
const field = document.getElementById(this.#prefix + "-bus-tag");
const error = document.getElementById(this.#prefix + "-bus-tag-error");
field.value = field.value.trim();
if (field.value === "") {
field.classList.add("is-invalid");
error.innerText = A_("Please fill in the tag.");
return false;
}
field.classList.remove("is-invalid");
error.innerText = "";
return true;
}
/**
* Validates the route of a bus trip.
*
* @return {boolean} true if valid, or false otherwise
*/
#validateBusTripRoute() {
const field = document.getElementById(this.#prefix + "-bus-route");
const error = document.getElementById(this.#prefix + "-bus-route-error");
field.value = field.value.trim();
if (field.value === "") {
field.classList.add("is-invalid");
error.innerText = A_("Please fill in the route.");
return false;
}
field.classList.remove("is-invalid");
error.innerText = "";
return true;
}
/**
* Validates the origin of a bus trip.
*
* @return {boolean} true if valid, or false otherwise
*/
#validateBusTripFrom() {
const field = document.getElementById(this.#prefix + "-bus-from");
const error = document.getElementById(this.#prefix + "-bus-from-error");
field.value = field.value.trim();
if (field.value === "") {
field.classList.add("is-invalid");
error.innerText = A_("Please fill in the origin.");
return false;
}
field.classList.remove("is-invalid");
error.innerText = "";
return true;
}
/**
* Validates the destination of a bus trip.
*
* @return {boolean} true if valid, or false otherwise
*/
#validateBusTripTo() {
const field = document.getElementById(this.#prefix + "-bus-to");
const error = document.getElementById(this.#prefix + "-bus-to-error");
field.value = field.value.trim();
if (field.value === "") {
field.classList.add("is-invalid");
error.innerText = A_("Please fill in the destination.");
return false;
}
field.classList.remove("is-invalid");
error.innerText = "";
return true;
}
/**
* Submits the summary.
*
*/
#submit() {
const form = document.getElementById(this.#prefix);
const summary = document.getElementById(this.#prefix + "-summary");
const formSummaryControl = document.getElementById("accounting-entry-form-summary-control");
const formSummary = document.getElementById("accounting-entry-form-summary");
const formAccountControl = document.getElementById("accounting-entry-form-account-control");
const formAccount = document.getElementById("accounting-entry-form-account");
const helperModal = document.getElementById(this.#prefix + "-modal");
const entryModal = document.getElementById("accounting-entry-form-modal");
if (summary.value === "") {
formSummaryControl.classList.remove("accounting-not-empty");
} else {
formSummaryControl.classList.add("accounting-not-empty");
}
if (form.dataset.selectedAccountCode !== "") {
formAccountControl.classList.add("accounting-not-empty");
formAccount.dataset.code = form.dataset.selectedAccountCode;
formAccount.dataset.text = form.dataset.selectedAccountText;
formAccount.innerText = form.dataset.selectedAccountText;
}
formSummary.dataset.value = summary.value;
formSummary.innerText = summary.value;
bootstrap.Modal.getInstance(helperModal).hide();
bootstrap.Modal.getOrCreateInstance(entryModal).show();
}
/**
* Initializes the summary helper when it is shown.
*
* @param isNew {boolean} true for adding a new journal entry, or false otherwise
*/
initShow(isNew) {
const formSummary = document.getElementById("accounting-entry-form-summary");
const summary = document.getElementById(this.#prefix + "-summary");
const closeButtons = Array.from(document.getElementsByClassName(this.#prefix + "-close"));
for (const closeButton of closeButtons) {
if (isNew) {
closeButton.dataset.bsTarget = "#" + this.#prefix + "-modal";
} else {
closeButton.dataset.bsTarget = "#accounting-entry-form-modal";
}
}
this.#reset();
if (!isNew) {
summary.value = formSummary.dataset.value;
this.#parseAndPopulate();
}
}
/**
* Resets the summary helper.
*
*/
#reset() {
const inputs = Array.from(document.getElementsByClassName(this.#prefix + "-input"));
const tagButtons = Array.from(document.getElementsByClassName(this.#prefix + "-btn-tag"));
const directionButtons = Array.from(document.getElementsByClassName(this.#prefix + "-travel-direction"));
for (const input of inputs) {
input.value = "";
input.classList.remove("is-invalid");
}
for (const tagButton of tagButtons) {
tagButton.classList.remove("btn-primary");
tagButton.classList.add("btn-outline-primary");
}
for (const directionButton of directionButtons) {
if (directionButton.classList.contains("accounting-default")) {
directionButton.classList.remove("btn-outline-primary");
directionButton.classList.add("btn-primary");
} else {
directionButton.classList.add("btn-outline-primary");
directionButton.classList.remove("btn-primary");
}
}
this.#filterSuggestedAccounts(null);
this.#switchToTab(this.#defaultTabId);
}
/**
* Parses the summary input and populates the summary helper.
*
*/
#parseAndPopulate() {
const summary = document.getElementById(this.#prefix + "-summary");
const pos = summary.value.indexOf("—");
if (pos === -1) {
return;
}
let found;
found = summary.value.match(/^([^—]+)—([^—]+)—([^—→]+)→(.+?)(?:×(\d+))?$/);
if (found !== null) {
return this.#populateBusTrip(found[1], found[2], found[3], found[4], found[5]);
}
found = summary.value.match(/^([^—]+)—([^—→↔]+)([→↔])(.+?)(?:×(\d+))?$/);
if (found !== null) {
return this.#populateGeneralTrip(found[1], found[2], found[3], found[4], found[5]);
}
found = summary.value.match(/^([^—]+)—.+?(?:×(\d+)?)?$/);
if (found !== null) {
return this.#populateGeneralTag(found[1], found[2]);
}
}
/**
* Populates a bus trip.
*
* @param tagName {string} the tag name
* @param routeName {string} the route name or route number
* @param fromName {string} the name of the origin
* @param toName {string} the name of the destination
* @param numberStr {string|undefined} the number of items, if any
*/
#populateBusTrip(tagName, routeName, fromName, toName, numberStr) {
const tag = document.getElementById(this.#prefix + "-bus-tag");
const route = document.getElementById(this.#prefix + "-bus-route");
const from = document.getElementById(this.#prefix + "-bus-from");
const to = document.getElementById(this.#prefix + "-bus-to");
const number = document.getElementById(this.#prefix + "-number");
const tagButtons = Array.from(document.getElementsByClassName(this.#prefix + "-bus-btn-tag"));
tag.value = tagName;
route.value = routeName;
from.value = fromName;
to.value = toName;
if (numberStr !== undefined) {
number.value = parseInt(numberStr);
}
for (const tagButton of tagButtons) {
if (tagButton.dataset.value === tagName) {
tagButton.classList.remove("btn-outline-primary");
tagButton.classList.add("btn-primary");
this.#filterSuggestedAccounts(tagButton);
}
}
this.#switchToTab("bus");
}
/**
* Populates a general trip.
*
* @param tagName {string} the tag name
* @param fromName {string} the name of the origin
* @param direction {string} the direction arrow, either "→" or "↔"
* @param toName {string} the name of the destination
* @param numberStr {string|undefined} the number of items, if any
*/
#populateGeneralTrip(tagName, fromName, direction, toName, numberStr) {
const tag = document.getElementById(this.#prefix + "-travel-tag");
const from = document.getElementById(this.#prefix + "-travel-from");
const to = document.getElementById(this.#prefix + "-travel-to");
const number = document.getElementById(this.#prefix + "-number");
const tagButtons = Array.from(document.getElementsByClassName(this.#prefix + "-travel-btn-tag"));
const directionButtons = Array.from(document.getElementsByClassName(this.#prefix + "-travel-direction"));
tag.value = tagName;
from.value = fromName;
for (const directionButton of directionButtons) {
if (directionButton.dataset.arrow === direction) {
directionButton.classList.remove("btn-outline-primary");
directionButton.classList.add("btn-primary");
} else {
directionButton.classList.add("btn-outline-primary");
directionButton.classList.remove("btn-primary");
}
}
to.value = toName;
if (numberStr !== undefined) {
number.value = parseInt(numberStr);
}
for (const tagButton of tagButtons) {
if (tagButton.dataset.value === tagName) {
tagButton.classList.remove("btn-outline-primary");
tagButton.classList.add("btn-primary");
this.#filterSuggestedAccounts(tagButton);
}
}
this.#switchToTab("travel");
}
/**
* Populates a general tag.
*
* @param tagName {string} the tag name
* @param numberStr {string|undefined} the number of items, if any
*/
#populateGeneralTag(tagName, numberStr) {
const tag = document.getElementById(this.#prefix + "-general-tag");
const number = document.getElementById(this.#prefix + "-number");
const tagButtons = Array.from(document.getElementsByClassName(this.#prefix + "-general-btn-tag"));
tag.value = tagName;
if (numberStr !== undefined) {
number.value = parseInt(numberStr);
}
for (const tagButton of tagButtons) {
if (tagButton.dataset.value === tagName) {
tagButton.classList.remove("btn-outline-primary");
tagButton.classList.add("btn-primary");
this.#filterSuggestedAccounts(tagButton);
}
}
this.#switchToTab("general");
}
/**
* The summary helpers.
* @type {{debit: SummaryHelper, credit: SummaryHelper}}
*/
static #helpers = {}
/**
* Initializes the summary helpers.
*
*/
static initialize() {
const forms = Array.from(document.getElementsByClassName("accounting-summary-helper"));
for (const form of forms) {
const helper = new SummaryHelper(form);
this.#helpers[helper.#entryType] = helper;
}
this.#initializeTransactionForm();
}
/**
* Initializes the transaction form.
*
*/
static #initializeTransactionForm() {
const entryForm = document.getElementById("accounting-entry-form");
const formSummaryControl = document.getElementById("accounting-entry-form-summary-control");
const helpers = this.#helpers;
formSummaryControl.onclick = function () {
helpers[entryForm.dataset.entryType].initShow(false);
};
}
/**
* Initializes the summary helper for a new journal entry.
*
* @param entryType {string} the entry type, either "debit" or "credit"
*/
static initializeNewJournalEntry(entryType) {
this.#helpers[entryType].initShow(true);
}
}

View File

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

View File

@ -70,7 +70,7 @@ First written: 2023/2/25
</div> </div>
<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 class="btn btn-primary accounting-btn-new-entry accounting-currency-{{ currency_index }}-btn-new-entry" type="button" data-currency-index="{{ currency_index }}" data-entry-type="debit" data-entry-index="new" data-account-modal="#accounting-debit-account-selector-modal" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal">
<i class="fas fa-plus"></i> <i class="fas fa-plus"></i>
{{ A_("New") }} {{ A_("New") }}
</button> </button>

View File

@ -45,12 +45,6 @@ First written: 2023/2/25
{% endif %} {% endif %}
{% endblock %} {% endblock %}
{% block form_modals %} {% block account_selector_modals %}
{% with summary_helper = form.summary_helper.debit %} {% include "accounting/transaction/include/debit-account-modal.html" %}
{% include "accounting/transaction/include/summary-helper-modal.html" %}
{% endwith %}
{% with entry_type = "debit",
account_options = form.debit_account_options %}
{% include "accounting/transaction/include/account-selector-modal.html" %}
{% endwith %}
{% endblock %} {% endblock %}

View File

@ -1,54 +0,0 @@
{#
The Mia! Accounting Flask Project
account-selector-modal.html: The modal for the account selector
Copyright (c) 2023 imacat.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/2/25
#}
<div id="accounting-account-selector-{{ entry_type }}-modal" class="modal fade accounting-account-selector-modal" data-entry-type="{{ entry_type }}" tabindex="-1" aria-labelledby="accounting-account-selector-{{ entry_type }}-modal-label" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-5" id="accounting-account-selector-{{ entry_type }}-modal-label">{{ A_("Select Account") }}</h1>
<button type="button" class="btn-close" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal" aria-label="{{ A_("Close") }}"></button>
</div>
<div class="modal-body">
<div class="input-group mb-2">
<input id="accounting-account-selector-{{ entry_type }}-query" class="form-control form-control-sm" type="search" placeholder=" " required="required">
<label class="input-group-text" for="accounting-account-selector-{{ entry_type }}-query">
<i class="fa-solid fa-magnifying-glass"></i>
{{ A_("Search") }}
</label>
</div>
<ul id="accounting-account-selector-{{ entry_type }}-option-list" class="list-group accounting-selector-list">
{% for account in account_options %}
<li id="accounting-account-selector-{{ entry_type }}-option-{{ account.code }}" class="list-group-item accounting-clickable accounting-account-selector-{{ entry_type }}-option {% if account.is_in_use %} accounting-account-in-use {% endif %}" data-code="{{ account.code }}" data-content="{{ account }}" data-query-values="{{ account.query_values|tojson|forceescape }}" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal">
{{ account }}
</li>
{% endfor %}
<li id="accounting-account-selector-{{ entry_type }}-more" class="list-group-item accounting-clickable">{{ A_("More…") }}</li>
</ul>
<p id="accounting-account-selector-{{ entry_type }}-option-no-result" class="d-none">{{ A_("There is no data.") }}</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal">{{ A_("Cancel") }}</button>
<button id="accounting-account-selector-{{ entry_type }}-btn-clear" type="button" class="btn btn-danger" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal">{{ A_("Clear") }}</button>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,54 @@
{#
The Mia! Accounting Flask Project
credit-modals.html: The modals for the credit journal entry sub-form
Copyright (c) 2023 imacat.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/2/25
#}
<div id="accounting-credit-account-selector-modal" class="modal fade accounting-selector-modal" data-prefix="accounting-credit-account" tabindex="-1" aria-labelledby="accounting-credit-account-selector-modal-label" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-5" id="accounting-credit-account-selector-modal-label">{{ A_("Select Credit Account") }}</h1>
<button type="button" class="btn-close" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal" aria-label="{{ A_("Close") }}"></button>
</div>
<div class="modal-body">
<div class="input-group mb-2">
<input id="accounting-credit-account-selector-query" class="form-control form-control-sm" type="search" placeholder=" " required="required">
<label class="input-group-text" for="accounting-credit-account-selector-query">
<i class="fa-solid fa-magnifying-glass"></i>
{{ A_("Search") }}
</label>
</div>
<ul id="accounting-credit-account-option-list" class="list-group accounting-selector-list">
{% for account in form.credit_account_options %}
<li id="accounting-credit-account-option-{{ account.code }}" class="list-group-item accounting-clickable accounting-credit-account-option {% if account.is_in_use %} accounting-account-in-use {% endif %}" data-code="{{ account.code }}" data-content="{{ account }}" data-query-values="{{ account.query_values|tojson|forceescape }}" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal">
{{ account }}
</li>
{% endfor %}
<li id="accounting-credit-account-more" class="list-group-item accounting-clickable">{{ A_("More…") }}</li>
</ul>
<p id="accounting-credit-account-option-no-result" class="d-none">{{ A_("There is no data.") }}</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal">{{ A_("Cancel") }}</button>
<button id="accounting-credit-account-btn-clear" type="button" class="btn btn-danger" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal">{{ A_("Clear") }}</button>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,54 @@
{#
The Mia! Accounting Flask Project
credit-modals.html: The modals for the debit journal entry sub-form
Copyright (c) 2023 imacat.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/2/25
#}
<div id="accounting-debit-account-selector-modal" class="modal fade accounting-selector-modal" data-prefix="accounting-debit-account" tabindex="-1" aria-labelledby="accounting-debit-account-selector-modal-label" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-5" id="accounting-debit-account-selector-modal-label">{{ A_("Select Debit Account") }}</h1>
<button type="button" class="btn-close" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal" aria-label="{{ A_("Close") }}"></button>
</div>
<div class="modal-body">
<div class="input-group mb-2">
<input id="accounting-debit-account-selector-query" class="form-control form-control-sm" type="search" placeholder=" " required="required">
<label class="input-group-text" for="accounting-debit-account-selector-query">
<i class="fa-solid fa-magnifying-glass"></i>
{{ A_("Search") }}
</label>
</div>
<ul id="accounting-debit-account-option-list" class="list-group accounting-selector-list">
{% for account in form.debit_account_options %}
<li id="accounting-debit-account-option-{{ account.code }}" class="list-group-item accounting-clickable accounting-debit-account-option {% if account.is_in_use %} accounting-account-in-use {% endif %}" data-code="{{ account.code }}" data-content="{{ account }}" data-query-values="{{ account.query_values|tojson|forceescape }}" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal">
{{ account }}
</li>
{% endfor %}
<li id="accounting-debit-account-more" class="list-group-item accounting-clickable">{{ A_("More…") }}</li>
</ul>
<p id="accounting-debit-account-option-no-result" class="d-none">{{ A_("There is no data.") }}</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal">{{ A_("Cancel") }}</button>
<button id="accounting-debit-account-btn-clear" type="button" class="btn btn-danger" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal">{{ A_("Clear") }}</button>
</div>
</div>
</div>
</div>

View File

@ -36,11 +36,9 @@ First written: 2023/2/25
<div id="accounting-entry-form-account-error" class="invalid-feedback"></div> <div id="accounting-entry-form-account-error" class="invalid-feedback"></div>
</div> </div>
<div class="mb-3"> <div class="form-floating 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=""> <input id="accounting-entry-form-summary" class="form-control" type="text" value="" placeholder=" ">
<label class="form-label" for="accounting-entry-form-summary">{{ A_("Summary") }}</label> <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 id="accounting-entry-form-summary-error" class="invalid-feedback"></div>
</div> </div>

View File

@ -20,12 +20,12 @@ Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/2/25 First written: 2023/2/25
#} #}
{# <ul> For SonarQube not to complain about incorrect HTML #} {# <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 }}" data-account-modal="#accounting-{{ entry_type }}-account-selector-modal" data-prefix="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}">
{% if entry_id %} {% if entry_id %}
<input type="hidden" name="currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-eid" value="{{ entry_id }}"> <input type="hidden" name="currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-eid" value="{{ entry_id }}">
{% endif %} {% 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 }}-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" class="accounting-account-code" type="hidden" name="currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-account_code" value="{{ account_code_data }}" data-text="{{ account_text }}">
<input id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-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 }}-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" class="accounting-currency-{{ currency_index }}-{{ entry_type }}-amount" type="hidden" name="currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-amount" value="{{ amount_data }}">
<div class="accounting-entry-content"> <div class="accounting-entry-content">

View File

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

View File

@ -1,181 +0,0 @@
{#
The Mia! Accounting Flask Project
entry-form-modal.html: The modal of the summary helper
Copyright (c) 2023 imacat.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/2/28
#}
<form id="accounting-summary-helper-{{ summary_helper.type }}" class="accounting-summary-helper" data-entry-type="{{ summary_helper.type }}" data-default-tab-id="general" data-selected-account-code="" data-selected-account-text="">
<div id="accounting-summary-helper-{{ summary_helper.type }}-modal" class="modal fade" tabindex="-1" aria-labelledby="accounting-summary-helper-{{ summary_helper.type }}-modal-label" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-5" id="accounting-summary-helper-{{ summary_helper.type }}-modal-label">
<label for="accounting-summary-helper-{{ summary_helper.type }}-summary">{{ A_("Summary") }}</label>
</h1>
<button class="btn-close accounting-summary-helper-{{ summary_helper.type }}-close" type="button" data-bs-toggle="modal" data-bs-target="" aria-label="{{ A_("Close") }}"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<input id="accounting-summary-helper-{{ summary_helper.type }}-summary" class="form-control accounting-summary-helper-{{ summary_helper.type }}-input" type="text" aria-labelledby="accounting-summary-helper-{{ summary_helper.type }}-modal-label">
</div>
<ul class="nav nav-tabs mb-2">
<li class="nav-item">
<span id="accounting-summary-helper-{{ summary_helper.type }}-tab-general" class="nav-link active accounting-clickable accounting-summary-helper-{{ summary_helper.type }}-tab" aria-current="page" data-tab-id="general">
{{ A_("General") }}
</span>
</li>
<li class="nav-item">
<span id="accounting-summary-helper-{{ summary_helper.type }}-tab-travel" class="nav-link accounting-clickable accounting-summary-helper-{{ summary_helper.type }}-tab" aria-current="false" data-tab-id="travel">
{{ A_("Travel") }}
</span>
</li>
<li class="nav-item">
<span id="accounting-summary-helper-{{ summary_helper.type }}-tab-bus" class="nav-link accounting-clickable accounting-summary-helper-{{ summary_helper.type }}-tab" aria-current="false" data-tab-id="bus">
{{ A_("Bus") }}
</span>
</li>
<li class="nav-item">
<span id="accounting-summary-helper-{{ summary_helper.type }}-tab-regular" class="nav-link accounting-clickable accounting-summary-helper-{{ summary_helper.type }}-tab" aria-current="false" data-tab-id="regular">
{{ A_("Regular") }}
</span>
</li>
<li class="nav-item">
<span id="accounting-summary-helper-{{ summary_helper.type }}-tab-number" class="nav-link accounting-clickable accounting-summary-helper-{{ summary_helper.type }}-tab" aria-current="false" data-tab-id="number">
{{ A_("Number") }}
</span>
</li>
</ul>
{# A general summary with a tag #}
<div class="accounting-summary-helper-{{ summary_helper.type }}-page" aria-current="page" aria-labelledby="accounting-summary-helper-{{ summary_helper.type }}-tab-general" data-tab-id="general">
<div class="form-floating mb-2">
<input id="accounting-summary-helper-{{ summary_helper.type }}-general-tag" class="form-control accounting-summary-helper-{{ summary_helper.type }}-input" type="text" value="" placeholder=" ">
<label class="form-label" for="accounting-summary-helper-{{ summary_helper.type }}-general-tag">{{ A_("Tag") }}</label>
<div id="accounting-summary-helper-{{ summary_helper.type }}-general-tag-error" class="invalid-feedback"></div>
</div>
<div>
{% for tag in summary_helper.general.tags %}
<button class="btn btn-outline-primary accounting-summary-helper-{{ summary_helper.type }}-btn-tag accounting-summary-helper-{{ summary_helper.type }}-general-btn-tag" type="button" tabindex="-1" data-value="{{ tag.name }}" data-accounts="{{ tag.account_codes|tojson|forceescape }}">
{{ tag }}
</button>
{% endfor %}
</div>
</div>
{# A general trip with the origin and distination #}
<div class="accounting-summary-helper-{{ summary_helper.type }}-page d-none" aria-current="false" aria-labelledby="accounting-summary-helper-{{ summary_helper.type }}-tab-travel" data-tab-id="travel">
<div class="form-floating mb-2">
<input id="accounting-summary-helper-{{ summary_helper.type }}-travel-tag" class="form-control accounting-summary-helper-{{ summary_helper.type }}-input" type="text" value="" placeholder=" ">
<label class="form-label" for="accounting-summary-helper-{{ summary_helper.type }}-travel-tag">{{ A_("Tag") }}</label>
<div id="accounting-summary-helper-{{ summary_helper.type }}-travel-tag-error" class="invalid-feedback"></div>
</div>
<div>
{% for tag in summary_helper.travel.tags %}
<button class="btn btn-outline-primary accounting-summary-helper-{{ summary_helper.type }}-btn-tag accounting-summary-helper-{{ summary_helper.type }}-travel-btn-tag" type="button" tabindex="-1" data-value="{{ tag.name }}" data-accounts="{{ tag.account_codes|tojson|forceescape }}">
{{ tag }}
</button>
{% endfor %}
</div>
<div class="d-flex justify-content-between mt-2">
<div class="form-floating">
<input id="accounting-summary-helper-{{ summary_helper.type }}-travel-from" class="form-control accounting-summary-helper-{{ summary_helper.type }}-input" type="text" value="" placeholder=" ">
<label class="form-label" for="accounting-summary-helper-{{ summary_helper.type }}-travel-from">{{ A_("From") }}</label>
<div id="accounting-summary-helper-{{ summary_helper.type }}-travel-from-error" class="invalid-feedback"></div>
</div>
<div class="btn-group-vertical ms-1 me-1">
<button class="btn btn-primary accounting-summary-helper-{{ summary_helper.type }}-travel-direction accounting-default" type="button" tabindex="-1" data-arrow="&rarr;">&rarr;</button>
<button class="btn btn-outline-primary accounting-summary-helper-{{ summary_helper.type }}-travel-direction" type="button" tabindex="-1" data-arrow="&harr;">&harr;</button>
</div>
<div class="form-floating">
<input id="accounting-summary-helper-{{ summary_helper.type }}-travel-to" class="form-control accounting-summary-helper-{{ summary_helper.type }}-input" type="text" value="" placeholder=" ">
<label class="form-label" for="accounting-summary-helper-{{ summary_helper.type }}-travel-to">{{ A_("To") }}</label>
<div id="accounting-summary-helper-{{ summary_helper.type }}-travel-to-error" class="invalid-feedback"></div>
</div>
</div>
</div>
{# A bus trip with the route name or route number, the origin and distination #}
<div class="accounting-summary-helper-{{ summary_helper.type }}-page d-none" aria-current="false" aria-labelledby="accounting-summary-helper-{{ summary_helper.type }}-tab-bus" data-tab-id="bus">
<div class="d-flex justify-content-between mb-2">
<div class="form-floating me-2">
<input id="accounting-summary-helper-{{ summary_helper.type }}-bus-tag" class="form-control accounting-summary-helper-{{ summary_helper.type }}-input" type="text" value="" placeholder=" ">
<label class="form-label" for="accounting-summary-helper-{{ summary_helper.type }}-bus-tag">{{ A_("Tag") }}</label>
<div id="accounting-summary-helper-{{ summary_helper.type }}-bus-tag-error" class="invalid-feedback"></div>
</div>
<div class="form-floating">
<input id="accounting-summary-helper-{{ summary_helper.type }}-bus-route" class="form-control accounting-summary-helper-{{ summary_helper.type }}-input" type="text" value="" placeholder=" ">
<label class="form-label" for="accounting-summary-helper-{{ summary_helper.type }}-bus-route">{{ A_("Route") }}</label>
<div id="accounting-summary-helper-{{ summary_helper.type }}-bus-route-error" class="invalid-feedback"></div>
</div>
</div>
<div>
{% for tag in summary_helper.bus.tags %}
<button class="btn btn-outline-primary accounting-summary-helper-{{ summary_helper.type }}-btn-tag accounting-summary-helper-{{ summary_helper.type }}-bus-btn-tag" type="button" tabindex="-1" data-value="{{ tag.name }}" data-accounts="{{ tag.account_codes|tojson|forceescape }}">
{{ tag }}
</button>
{% endfor %}
</div>
<div class="d-flex justify-content-between mt-2">
<div class="form-floating me-2">
<input id="accounting-summary-helper-{{ summary_helper.type }}-bus-from" class="form-control accounting-summary-helper-{{ summary_helper.type }}-input" type="text" value="" placeholder=" ">
<label class="form-label" for="accounting-summary-helper-{{ summary_helper.type }}-bus-from">{{ A_("From") }}</label>
<div id="accounting-summary-helper-{{ summary_helper.type }}-bus-from-error" class="invalid-feedback"></div>
</div>
<div class="form-floating">
<input id="accounting-summary-helper-{{ summary_helper.type }}-bus-to" class="form-control accounting-summary-helper-{{ summary_helper.type }}-input" type="text" value="" placeholder=" ">
<label class="form-label" for="accounting-summary-helper-{{ summary_helper.type }}-bus-to">{{ A_("To") }}</label>
<div id="accounting-summary-helper-{{ summary_helper.type }}-bus-to-error" class="invalid-feedback"></div>
</div>
</div>
</div>
{# A regular income/payment #}
<div class="accounting-summary-helper-{{ summary_helper.type }}-page d-none" aria-current="false" aria-labelledby="accounting-summary-helper-{{ summary_helper.type }}-tab-regular" data-tab-id="regular">
{# TODO: To be done #}
</div>
{# The number of items #}
<div class="accounting-summary-helper-{{ summary_helper.type }}-page d-none" aria-current="false" aria-labelledby="accounting-summary-helper-{{ summary_helper.type }}-tab-number" data-tab-id="number">
<div class="form-floating">
<input id="accounting-summary-helper-{{ summary_helper.type }}-number" class="form-control accounting-summary-helper-{{ summary_helper.type }}-input" type="number" min="1" value="" placeholder=" ">
<label class="form-label" for="accounting-summary-helper-{{ summary_helper.type }}-number">{{ A_("The number of items") }}</label>
<div id="accounting-summary-helper-{{ summary_helper.type }}-number-error" class="invalid-feedback"></div>
</div>
</div>
{# The suggested accounts #}
<div class="mt-3">
{% for account in summary_helper.accounts %}
<button class="btn btn-outline-primary d-none accounting-summary-helper-{{ summary_helper.type }}-account" type="button" data-code="{{ account.code }}" data-text="{{ account }}">
{{ account }}
</button>
{% endfor %}
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary accounting-summary-helper-{{ summary_helper.type }}-close" type="button" data-bs-toggle="modal" data-bs-target="">{{ A_("Cancel") }}</button>
<button id="accounting-summary-helper-{{ summary_helper.type }}-btn-save" type="submit" class="btn btn-primary">{{ A_("Save") }}</button>
</div>
</div>
</div>
</div>
</form>

View File

@ -70,7 +70,7 @@ First written: 2023/2/25
</div> </div>
<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 class="btn btn-primary accounting-btn-new-entry accounting-currency-{{ currency_index }}-btn-new-entry" type="button" data-currency-index="{{ currency_index }}" data-entry-type="credit" data-entry-index="new" data-account-modal="#accounting-credit-account-selector-modal" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal">
<i class="fas fa-plus"></i> <i class="fas fa-plus"></i>
{{ A_("New") }} {{ A_("New") }}
</button> </button>

View File

@ -45,12 +45,6 @@ First written: 2023/2/25
{% endif %} {% endif %}
{% endblock %} {% endblock %}
{% block form_modals %} {% block account_selector_modals %}
{% with summary_helper = form.summary_helper.credit %} {% include "accounting/transaction/include/credit-account-modal.html" %}
{% include "accounting/transaction/include/summary-helper-modal.html" %}
{% endwith %}
{% with entry_type = "credit",
account_options = form.credit_account_options %}
{% include "accounting/transaction/include/account-selector-modal.html" %}
{% endwith %}
{% endblock %} {% endblock %}

View File

@ -57,7 +57,7 @@ First written: 2023/2/25
account_text = entry_form.account_text, account_text = entry_form.account_text,
summary_data = "" if entry_form.summary.data is none else entry_form.summary.data, summary_data = "" if entry_form.summary.data is none else entry_form.summary.data,
summary_errors = entry_form.summary.errors, summary_errors = entry_form.summary.errors,
amount_data = "" if entry_form.amount.data is none else entry_form.amount.data|accounting_txn_format_amount_input, amount_data = "" if entry_form.amount.data is none else entry_form.amount.data,
amount_errors = entry_form.amount.errors, amount_errors = entry_form.amount.errors,
amount_text = entry_form.amount.data|accounting_txn_format_amount, amount_text = entry_form.amount.data|accounting_txn_format_amount,
entry_errors = entry_form.all_errors %} entry_errors = entry_form.all_errors %}
@ -72,7 +72,7 @@ First written: 2023/2/25
</div> </div>
<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 class="btn btn-primary accounting-btn-new-entry accounting-currency-{{ currency_index }}-btn-new-entry" type="button" data-currency-index="{{ currency_index }}" data-entry-type="debit" data-entry-index="new" data-account-modal="#accounting-debit-account-selector-modal" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal">
<i class="fas fa-plus"></i> <i class="fas fa-plus"></i>
{{ A_("New") }} {{ A_("New") }}
</button> </button>
@ -112,7 +112,7 @@ First written: 2023/2/25
</div> </div>
<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 class="btn btn-primary accounting-btn-new-entry accounting-currency-{{ currency_index }}-btn-new-entry" type="button" data-currency-index="{{ currency_index }}" data-entry-type="credit" data-entry-index="new" data-account-modal="#accounting-credit-account-selector-modal" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal">
<i class="fas fa-plus"></i> <i class="fas fa-plus"></i>
{{ A_("New") }} {{ A_("New") }}
</button> </button>

View File

@ -49,19 +49,7 @@ First written: 2023/2/25
{% endif %} {% endif %}
{% endblock %} {% endblock %}
{% block form_modals %} {% block account_selector_modals %}
{% with summary_helper = form.summary_helper.debit %} {% include "accounting/transaction/include/debit-account-modal.html" %}
{% include "accounting/transaction/include/summary-helper-modal.html" %} {% include "accounting/transaction/include/credit-account-modal.html" %}
{% endwith %}
{% with summary_helper = form.summary_helper.credit %}
{% include "accounting/transaction/include/summary-helper-modal.html" %}
{% endwith %}
{% with entry_type = "debit",
account_options = form.debit_account_options %}
{% include "accounting/transaction/include/account-selector-modal.html" %}
{% endwith %}
{% with entry_type = "credit",
account_options = form.credit_account_options %}
{% include "accounting/transaction/include/account-selector-modal.html" %}
{% endwith %}
{% endblock %} {% endblock %}

View File

@ -14,7 +14,7 @@
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
"""The view dispatcher for different transaction types. """The transaction type dispatcher.
""" """
import typing as t import typing as t

View File

@ -37,7 +37,6 @@ from accounting import db
from accounting.locale import lazy_gettext from accounting.locale import lazy_gettext
from accounting.models import Transaction, Account, JournalEntry, \ from accounting.models import Transaction, Account, JournalEntry, \
TransactionCurrency, Currency TransactionCurrency, Currency
from accounting.transaction.summary_helper import SummaryHelper
from accounting.utils.random_id import new_id 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_text, strip_multiline_text
from accounting.utils.user import get_current_user_pk from accounting.utils.user import get_current_user_pk
@ -115,35 +114,6 @@ class IsDebitAccount:
"This account is not for debit entries.")) "This account is not for debit entries."))
class AccountOption:
"""An account option."""
def __init__(self, account: Account):
"""Constructs an account option.
:param account: The account.
"""
self.__account: Account = account
self.id: str = account.id
self.code: str = account.code
self.is_in_use: bool = False
def __str__(self) -> str:
"""Returns the string representation of the account option.
:return: The string representation of the account option.
"""
return str(self.__account)
@property
def query_values(self) -> list[str]:
"""Returns the values to be queried.
:return: The values to be queried.
"""
return self.__account.query_values
class JournalEntryForm(FlaskForm): class JournalEntryForm(FlaskForm):
"""The base form to create or edit a journal entry.""" """The base form to create or edit a journal entry."""
eid = IntegerField() eid = IntegerField()
@ -350,54 +320,49 @@ class TransactionForm(FlaskForm):
obj.no = count + 1 obj.no = count + 1
@property @property
def debit_account_options(self) -> list[AccountOption]: def debit_account_options(self) -> list[Account]:
"""The selectable debit accounts. """The selectable debit accounts.
:return: The selectable debit accounts. :return: The selectable debit accounts.
""" """
accounts: list[AccountOption] \ accounts: list[Account] = Account.debit()
= [AccountOption(x) for x in Account.debit()] in_use: set[int] = self.__get_in_use_account_id()
in_use: set[int] = set(db.session.scalars(
sa.select(JournalEntry.account_id)
.filter(JournalEntry.is_debit)
.group_by(JournalEntry.account_id)).all())
for account in accounts: for account in accounts:
account.is_in_use = account.id in in_use account.is_in_use = account.id in in_use
return accounts return accounts
@property @property
def credit_account_options(self) -> list[AccountOption]: def credit_account_options(self) -> list[Account]:
"""The selectable credit accounts. """The selectable credit accounts.
:return: The selectable credit accounts. :return: The selectable credit accounts.
""" """
accounts: list[AccountOption] \ accounts: list[Account] = Account.credit()
= [AccountOption(x) for x in Account.credit()] in_use: set[int] = self.__get_in_use_account_id()
in_use: set[int] = set(db.session.scalars(
sa.select(JournalEntry.account_id)
.filter(sa.not_(JournalEntry.is_debit))
.group_by(JournalEntry.account_id)).all())
for account in accounts: for account in accounts:
account.is_in_use = account.id in in_use account.is_in_use = account.id in in_use
return accounts return accounts
def __get_in_use_account_id(self) -> set[int]:
"""Returns the ID of the accounts that are in use.
:return: The ID of the accounts that are in use.
"""
if self.__in_use_account_id is None:
self.__in_use_account_id = set(db.session.scalars(
sa.select(JournalEntry.account_id)
.group_by(JournalEntry.account_id)).all())
return self.__in_use_account_id
@property @property
def currencies_errors(self) -> list[str | LazyString]: def currencies_errors(self) -> list[str | LazyString]:
"""Returns the currency errors, without the errors in their sub-forms. """Returns the currency errors, without the errors in their sub-forms.
:return: The currency errors, without the errors in their sub-forms. :return:
""" """
return [x for x in self.currencies.errors return [x for x in self.currencies.errors
if isinstance(x, str) or isinstance(x, LazyString)] if isinstance(x, str) or isinstance(x, LazyString)]
@property
def summary_helper(self) -> SummaryHelper:
"""Returns the summary helper.
:return: The summary helper.
"""
return SummaryHelper()
T = t.TypeVar("T", bound=TransactionForm) T = t.TypeVar("T", bound=TransactionForm)
"""A transaction form variant.""" """A transaction form variant."""

View File

@ -1,255 +0,0 @@
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/27
# Copyright (c) 2023 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""The summary helper.
"""
import typing as t
import sqlalchemy as sa
from accounting import db
from accounting.models import Account, JournalEntry
class SummaryAccount:
"""An account for a summary tag."""
def __init__(self, account: Account, freq: int):
"""Constructs an account for a summary tag.
:param account: The account.
:param freq: The frequency of the tag with the account.
"""
self.account: Account = account
"""The account."""
self.id: int = account.id
"""The account ID."""
self.code: str = account.code
"""The account code."""
self.freq: int = freq
"""The frequency of the tag with the account."""
def __str__(self) -> str:
"""Returns the string representation of the account.
:return: The string representation of the account.
"""
return str(self.account)
def add_freq(self, freq: int) -> None:
"""Adds the frequency of an account.
:param freq: The frequency of the tag name with the account.
:return: None.
"""
self.freq = self.freq + freq
class SummaryTag:
"""A summary tag."""
def __init__(self, name: str):
"""Constructs a summary tag.
:param name: The tag name.
"""
self.name: str = name
"""The tag name."""
self.__account_dict: dict[int, SummaryAccount] = {}
"""The accounts that come with the tag, in the order of their
frequency."""
self.freq: int = 0
"""The frequency of the tag."""
def __str__(self) -> str:
"""Returns the string representation of the tag.
:return: The string representation of the tag.
"""
return self.name
def add_account(self, account: Account, freq: int):
"""Adds an account.
:param account: The associated account.
:param freq: The frequency of the tag name with the account.
:return: None.
"""
self.__account_dict[account.id] = SummaryAccount(account, freq)
self.freq = self.freq + freq
@property
def accounts(self) -> list[SummaryAccount]:
"""Returns the accounts by the order of their frequencies.
:return: The accounts by the order of their frequencies.
"""
return sorted(self.__account_dict.values(), key=lambda x: -x.freq)
@property
def account_codes(self) -> list[str]:
"""Returns the account codes by the order of their frequencies.
:return: The account codes by the order of their frequencies.
"""
return [x.code for x in self.accounts]
class SummaryType:
"""A summary type"""
def __init__(self, type_id: t.Literal["general", "travel", "bus"]):
"""Constructs a summary type.
:param type_id: The type ID, either "general", "travel", or "bus".
"""
self.id: t.Literal["general", "travel", "bus"] = type_id
"""The type ID."""
self.__tag_dict: dict[str, SummaryTag] = {}
"""A dictionary from the tag name to their corresponding tag."""
def add_tag(self, name: str, account: Account, freq: int) -> None:
"""Adds a tag.
:param name: The tag name.
:param account: The associated account.
:param freq: The frequency of the tag name with the account.
:return: None.
"""
if name not in self.__tag_dict:
self.__tag_dict[name] = SummaryTag(name)
self.__tag_dict[name].add_account(account, freq)
@property
def tags(self) -> list[SummaryTag]:
"""Returns the tags by the order of their frequencies.
:return: The tags by the order of their frequencies.
"""
return sorted(self.__tag_dict.values(), key=lambda x: -x.freq)
class SummaryEntryType:
"""A summary type"""
def __init__(self, entry_type_id: t.Literal["debit", "credit"]):
"""Constructs a summary entry type.
:param entry_type_id: The entry type ID, either "debit" or "credit".
"""
self.type: t.Literal["debit", "credit"] = entry_type_id
"""The entry type."""
self.general: SummaryType = SummaryType("general")
"""The general tags."""
self.travel: SummaryType = SummaryType("travel")
"""The travel tags."""
self.bus: SummaryType = SummaryType("bus")
"""The bus tags."""
self.__type_dict: dict[t.Literal["general", "travel", "bus"],
SummaryType] \
= {x.id: x for x in {self.general, self.travel, self.bus}}
"""A dictionary from the type ID to the corresponding tags."""
def add_tag(self, tag_type: t.Literal["general", "travel", "bus"],
name: str, account: Account, freq: int) -> None:
"""Adds a tag.
:param tag_type: The tag type, either "general", "travel", or "bus".
:param name: The name.
:param account: The associated account.
:param freq: The frequency of the tag name with the account.
:return: None.
"""
self.__type_dict[tag_type].add_tag(name, account, freq)
@property
def accounts(self) -> list[SummaryAccount]:
"""Returns the suggested accounts of all tags in the summary helper in
the entry type, in their frequency order.
:return: The suggested accounts of all tags, in their frequency order.
"""
accounts: dict[int, SummaryAccount] = {}
freq: dict[int, int] = {}
for tag_type in self.__type_dict.values():
for tag in tag_type.tags:
for account in tag.accounts:
accounts[account.id] = account
if account.id not in freq:
freq[account.id] = 0
freq[account.id] \
= freq[account.id] + account.freq
return [accounts[y] for y in sorted(freq.keys(),
key=lambda x: -freq[x])]
class SummaryHelper:
"""The summary helper."""
def __init__(self):
"""Constructs the summary helper."""
self.debit: SummaryEntryType = SummaryEntryType("debit")
"""The debit tags."""
self.credit: SummaryEntryType = SummaryEntryType("credit")
"""The credit tags."""
entry_type: sa.Label = sa.case((JournalEntry.is_debit, "debit"),
else_="credit").label("entry_type")
tag_type: sa.Label = sa.case(
(JournalEntry.summary.like("_%—_%—_%→_%"), "bus"),
(sa.or_(JournalEntry.summary.like("_%—_%→_%"),
JournalEntry.summary.like("_%—_%↔_%")), "travel"),
else_="general").label("tag_type")
tag: sa.Label = get_prefix(JournalEntry.summary, "").label("tag")
select: sa.Select = sa.Select(entry_type, tag_type, tag,
JournalEntry.account_id,
sa.func.count().label("freq"))\
.filter(JournalEntry.summary.is_not(None),
JournalEntry.summary.like("_%—_%"))\
.group_by(entry_type, tag_type, tag, JournalEntry.account_id)
result: list[sa.Row] = db.session.execute(select).all()
accounts: dict[int, Account] \
= {x.id: x for x in Account.query
.filter(Account.id.in_({x.account_id for x in result})).all()}
entry_type_dict: dict[t.Literal["debit", "credit"], SummaryEntryType] \
= {x.type: x for x in {self.debit, self.credit}}
for row in result:
entry_type_dict[row.entry_type].add_tag(
row.tag_type, row.tag, accounts[row.account_id], row.freq)
def get_prefix(string: str | sa.Column, separator: str | sa.Column) \
-> sa.Function:
"""Returns the SQL function to find the prefix of a string.
:param string: The string.
:param separator: The separator.
:return: The position of the substring, starting from 1.
"""
return sa.func.substr(string, 0, get_position(string, separator))
def get_position(string: str | sa.Column, substring: str | sa.Column) \
-> sa.Function:
"""Returns the SQL function to find the position of a substring.
:param string: The string.
:param substring: The substring.
:return: The position of the substring, starting from 1.
"""
if db.engine.name == "postgresql":
return sa.func.strpos(string, substring)
return sa.func.instr(string, substring)

View File

@ -8,8 +8,8 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: Mia! Accounting Flask 0.0.0\n" "Project-Id-Version: Mia! Accounting Flask 0.0.0\n"
"Report-Msgid-Bugs-To: imacat@mail.imacat.idv.tw\n" "Report-Msgid-Bugs-To: imacat@mail.imacat.idv.tw\n"
"POT-Creation-Date: 2023-03-01 00:51+0800\n" "POT-Creation-Date: 2023-02-27 18:59+0800\n"
"PO-Revision-Date: 2023-03-01 00:51+0800\n" "PO-Revision-Date: 2023-02-27 18:59+0800\n"
"Last-Translator: imacat <imacat@mail.imacat.idv.tw>\n" "Last-Translator: imacat <imacat@mail.imacat.idv.tw>\n"
"Language: zh_Hant\n" "Language: zh_Hant\n"
"Language-Team: zh_Hant <imacat@mail.imacat.idv.tw>\n" "Language-Team: zh_Hant <imacat@mail.imacat.idv.tw>\n"
@ -126,52 +126,33 @@ msgstr "貨幣刪掉了"
msgid "Please fill in the title." msgid "Please fill in the title."
msgstr "請填上標題。" msgstr "請填上標題。"
#: src/accounting/static/js/summary-helper.js:441 #: src/accounting/static/js/transaction-form.js:308
#: src/accounting/static/js/summary-helper.js:512 #: src/accounting/static/js/transaction-form.js:764
msgid "Please fill in the tag." #: src/accounting/transaction/forms.py:46
msgstr "請填上標籤。"
#: src/accounting/static/js/summary-helper.js:460
#: src/accounting/static/js/summary-helper.js:550
msgid "Please fill in the origin."
msgstr "請填上起點。"
#: src/accounting/static/js/summary-helper.js:479
#: src/accounting/static/js/summary-helper.js:569
msgid "Please fill in the destination."
msgstr "請填上終點。"
#: src/accounting/static/js/summary-helper.js:531
msgid "Please fill in the route."
msgstr "請填上路線名稱。"
#: src/accounting/static/js/transaction-form.js:289
#: src/accounting/static/js/transaction-form.js:611
#: src/accounting/transaction/forms.py:47
msgid "Please select the account." msgid "Please select the account."
msgstr "請選擇科目。" msgstr "請選擇科目。"
#: src/accounting/static/js/transaction-form.js:324 #: src/accounting/static/js/transaction-form.js:344
#: src/accounting/static/js/transaction-form.js:616 #: src/accounting/static/js/transaction-form.js:769
msgid "Please fill in the amount." msgid "Please fill in the amount."
msgstr "請填上金額。" msgstr "請填上金額。"
#: src/accounting/static/js/transaction-form.js:488 #: src/accounting/static/js/transaction-form.js:641
msgid "Please fill in the date." msgid "Please fill in the date."
msgstr "請填上日期。" msgstr "請填上日期。"
#: src/accounting/static/js/transaction-form.js:523 #: src/accounting/static/js/transaction-form.js:676
#: src/accounting/transaction/forms.py:57 #: src/accounting/transaction/forms.py:56
msgid "Please add some currencies." msgid "Please add some currencies."
msgstr "請加上貨幣。" msgstr "請加上貨幣。"
#: src/accounting/static/js/transaction-form.js:589 #: src/accounting/static/js/transaction-form.js:742
#: src/accounting/transaction/forms.py:78 #: src/accounting/transaction/forms.py:77
msgid "Please add some journal entries." msgid "Please add some journal entries."
msgstr "請加上分錄。" msgstr "請加上分錄。"
#: src/accounting/static/js/transaction-form.js:654 #: src/accounting/static/js/transaction-form.js:807
#: src/accounting/transaction/forms.py:672 #: src/accounting/transaction/forms.py:670
msgid "The totals of the debit and credit amounts do not match." msgid "The totals of the debit and credit amounts do not match."
msgstr "借方貸方合計不符。 " msgstr "借方貸方合計不符。 "
@ -186,7 +167,7 @@ msgstr "新增科目"
#: src/accounting/templates/accounting/currency/detail.html:31 #: src/accounting/templates/accounting/currency/detail.html:31
#: src/accounting/templates/accounting/currency/include/form.html:33 #: src/accounting/templates/accounting/currency/include/form.html:33
#: src/accounting/templates/accounting/transaction/include/detail.html:31 #: src/accounting/templates/accounting/transaction/include/detail.html:31
#: src/accounting/templates/accounting/transaction/include/form.html:36 #: src/accounting/templates/accounting/transaction/include/form.html:34
#: src/accounting/templates/accounting/transaction/order.html:36 #: src/accounting/templates/accounting/transaction/order.html:36
msgid "Back" msgid "Back"
msgstr "回上頁" msgstr "回上頁"
@ -215,10 +196,10 @@ msgstr "科目刪除確認"
#: src/accounting/templates/accounting/account/detail.html:70 #: src/accounting/templates/accounting/account/detail.html:70
#: src/accounting/templates/accounting/account/include/form.html:91 #: src/accounting/templates/accounting/account/include/form.html:91
#: src/accounting/templates/accounting/currency/detail.html:66 #: src/accounting/templates/accounting/currency/detail.html:66
#: src/accounting/templates/accounting/transaction/include/account-selector-modal.html:27 #: src/accounting/templates/accounting/transaction/include/credit-account-modal.html:27
#: src/accounting/templates/accounting/transaction/include/debit-account-modal.html:27
#: src/accounting/templates/accounting/transaction/include/detail.html:71 #: src/accounting/templates/accounting/transaction/include/detail.html:71
#: src/accounting/templates/accounting/transaction/include/entry-form-modal.html:28 #: src/accounting/templates/accounting/transaction/include/entry-form-modal.html:28
#: src/accounting/templates/accounting/transaction/include/summary-helper-modal.html:30
msgid "Close" msgid "Close"
msgstr "關閉" msgstr "關閉"
@ -229,10 +210,10 @@ msgstr "你確定要刪掉這個科目嗎?"
#: src/accounting/templates/accounting/account/detail.html:76 #: src/accounting/templates/accounting/account/detail.html:76
#: src/accounting/templates/accounting/account/include/form.html:112 #: src/accounting/templates/accounting/account/include/form.html:112
#: src/accounting/templates/accounting/currency/detail.html:72 #: src/accounting/templates/accounting/currency/detail.html:72
#: src/accounting/templates/accounting/transaction/include/account-selector-modal.html:49 #: src/accounting/templates/accounting/transaction/include/credit-account-modal.html:49
#: src/accounting/templates/accounting/transaction/include/debit-account-modal.html:49
#: src/accounting/templates/accounting/transaction/include/detail.html:77 #: src/accounting/templates/accounting/transaction/include/detail.html:77
#: src/accounting/templates/accounting/transaction/include/entry-form-modal.html:54 #: src/accounting/templates/accounting/transaction/include/entry-form-modal.html:52
#: src/accounting/templates/accounting/transaction/include/summary-helper-modal.html:175
msgid "Cancel" msgid "Cancel"
msgstr "取消" msgstr "取消"
@ -274,7 +255,7 @@ msgstr "科目管理"
#: src/accounting/templates/accounting/account/list.html:32 #: src/accounting/templates/accounting/account/list.html:32
#: src/accounting/templates/accounting/currency/list.html:32 #: src/accounting/templates/accounting/currency/list.html:32
#: src/accounting/templates/accounting/transaction/expense/include/form-currency-item.html:75 #: src/accounting/templates/accounting/transaction/expense/include/form-currency-item.html:75
#: src/accounting/templates/accounting/transaction/include/form.html:62 #: src/accounting/templates/accounting/transaction/include/form.html:60
#: src/accounting/templates/accounting/transaction/income/include/form-currency-item.html:75 #: src/accounting/templates/accounting/transaction/income/include/form-currency-item.html:75
#: src/accounting/templates/accounting/transaction/list.html:37 #: src/accounting/templates/accounting/transaction/list.html:37
#: src/accounting/templates/accounting/transaction/transfer/include/form-currency-item.html:77 #: src/accounting/templates/accounting/transaction/transfer/include/form-currency-item.html:77
@ -295,7 +276,8 @@ msgstr "桌機版檢索"
#: src/accounting/templates/accounting/base-account/list.html:34 #: src/accounting/templates/accounting/base-account/list.html:34
#: src/accounting/templates/accounting/currency/list.html:40 #: src/accounting/templates/accounting/currency/list.html:40
#: src/accounting/templates/accounting/currency/list.html:52 #: src/accounting/templates/accounting/currency/list.html:52
#: src/accounting/templates/accounting/transaction/include/account-selector-modal.html:34 #: src/accounting/templates/accounting/transaction/include/credit-account-modal.html:34
#: src/accounting/templates/accounting/transaction/include/debit-account-modal.html:34
#: src/accounting/templates/accounting/transaction/list.html:62 #: src/accounting/templates/accounting/transaction/list.html:62
#: src/accounting/templates/accounting/transaction/list.html:74 #: src/accounting/templates/accounting/transaction/list.html:74
msgid "Search" msgid "Search"
@ -312,7 +294,8 @@ msgstr "行動版檢索"
#: src/accounting/templates/accounting/account/order.html:81 #: src/accounting/templates/accounting/account/order.html:81
#: src/accounting/templates/accounting/base-account/list.html:51 #: src/accounting/templates/accounting/base-account/list.html:51
#: src/accounting/templates/accounting/currency/list.html:77 #: src/accounting/templates/accounting/currency/list.html:77
#: src/accounting/templates/accounting/transaction/include/account-selector-modal.html:46 #: src/accounting/templates/accounting/transaction/include/credit-account-modal.html:46
#: src/accounting/templates/accounting/transaction/include/debit-account-modal.html:46
#: src/accounting/templates/accounting/transaction/list.html:93 #: src/accounting/templates/accounting/transaction/list.html:93
#: src/accounting/templates/accounting/transaction/order.html:80 #: src/accounting/templates/accounting/transaction/order.html:80
msgid "There is no data." msgid "There is no data."
@ -326,9 +309,8 @@ msgstr "%(base)s下的科目"
#: src/accounting/templates/accounting/account/include/form.html:75 #: src/accounting/templates/accounting/account/include/form.html:75
#: src/accounting/templates/accounting/account/order.html:62 #: src/accounting/templates/accounting/account/order.html:62
#: src/accounting/templates/accounting/currency/include/form.html:57 #: src/accounting/templates/accounting/currency/include/form.html:57
#: src/accounting/templates/accounting/transaction/include/entry-form-modal.html:55 #: src/accounting/templates/accounting/transaction/include/entry-form-modal.html:53
#: src/accounting/templates/accounting/transaction/include/form.html:78 #: src/accounting/templates/accounting/transaction/include/form.html:76
#: src/accounting/templates/accounting/transaction/include/summary-helper-modal.html:176
#: src/accounting/templates/accounting/transaction/order.html:61 #: src/accounting/templates/accounting/transaction/order.html:61
msgid "Save" msgid "Save"
msgstr "儲存" msgstr "儲存"
@ -355,7 +337,8 @@ msgstr "選擇基本科目"
#: src/accounting/templates/accounting/account/include/form.html:114 #: src/accounting/templates/accounting/account/include/form.html:114
#: src/accounting/templates/accounting/account/include/form.html:116 #: src/accounting/templates/accounting/account/include/form.html:116
#: src/accounting/templates/accounting/transaction/include/account-selector-modal.html:50 #: src/accounting/templates/accounting/transaction/include/credit-account-modal.html:50
#: src/accounting/templates/accounting/transaction/include/debit-account-modal.html:50
msgid "Clear" msgid "Clear"
msgstr "清除" msgstr "清除"
@ -449,7 +432,7 @@ msgstr "改轉帳"
#: src/accounting/templates/accounting/transaction/expense/detail.html:37 #: src/accounting/templates/accounting/transaction/expense/detail.html:37
#: src/accounting/templates/accounting/transaction/expense/include/form-currency-item.html:45 #: src/accounting/templates/accounting/transaction/expense/include/form-currency-item.html:45
#: src/accounting/templates/accounting/transaction/include/form.html:54 #: src/accounting/templates/accounting/transaction/include/form.html:52
#: src/accounting/templates/accounting/transaction/income/detail.html:37 #: src/accounting/templates/accounting/transaction/income/detail.html:37
#: src/accounting/templates/accounting/transaction/income/include/form-currency-item.html:45 #: src/accounting/templates/accounting/transaction/income/include/form-currency-item.html:45
msgid "Content" msgid "Content"
@ -479,14 +462,6 @@ msgstr "編輯%(txn)s"
msgid "Currency" msgid "Currency"
msgstr "貨幣" msgstr "貨幣"
#: src/accounting/templates/accounting/transaction/include/account-selector-modal.html:26
msgid "Select Account"
msgstr "選擇科目"
#: src/accounting/templates/accounting/transaction/include/account-selector-modal.html:44
msgid "More…"
msgstr "更多…"
#: src/accounting/templates/accounting/transaction/include/add-new-material-fab.html:26 #: src/accounting/templates/accounting/transaction/include/add-new-material-fab.html:26
msgid "Cash expense" msgid "Cash expense"
msgstr "現金支出" msgstr "現金支出"
@ -495,6 +470,19 @@ msgstr "現金支出"
msgid "Cash income" msgid "Cash income"
msgstr "現金收入" msgstr "現金收入"
#: src/accounting/templates/accounting/transaction/include/credit-account-modal.html:26
msgid "Select Credit Account"
msgstr "選擇貸方科目科目"
#: src/accounting/templates/accounting/transaction/include/credit-account-modal.html:44
#: src/accounting/templates/accounting/transaction/include/debit-account-modal.html:44
msgid "More…"
msgstr "更多…"
#: src/accounting/templates/accounting/transaction/include/debit-account-modal.html:26
msgid "Select Debit Account"
msgstr "選擇借方科目"
#: src/accounting/templates/accounting/transaction/include/detail.html:70 #: src/accounting/templates/accounting/transaction/include/detail.html:70
msgid "Delete Transaction Confirmation" msgid "Delete Transaction Confirmation"
msgstr "傳票刪除確認" msgstr "傳票刪除確認"
@ -512,66 +500,21 @@ msgid "Account"
msgstr "科目" msgstr "科目"
#: src/accounting/templates/accounting/transaction/include/entry-form-modal.html:41 #: src/accounting/templates/accounting/transaction/include/entry-form-modal.html:41
#: src/accounting/templates/accounting/transaction/include/summary-helper-modal.html:28
msgid "Summary" msgid "Summary"
msgstr "摘要" msgstr "摘要"
#: src/accounting/templates/accounting/transaction/include/entry-form-modal.html:49 #: src/accounting/templates/accounting/transaction/include/entry-form-modal.html:47
msgid "Amount" msgid "Amount"
msgstr "金額" msgstr "金額"
#: src/accounting/templates/accounting/transaction/include/form.html:48 #: src/accounting/templates/accounting/transaction/include/form.html:46
msgid "Date" msgid "Date"
msgstr "日期" msgstr "日期"
#: src/accounting/templates/accounting/transaction/include/form.html:71 #: src/accounting/templates/accounting/transaction/include/form.html:69
msgid "Note" msgid "Note"
msgstr "備註" msgstr "備註"
#: src/accounting/templates/accounting/transaction/include/summary-helper-modal.html:39
msgid "General"
msgstr "一般"
#: src/accounting/templates/accounting/transaction/include/summary-helper-modal.html:44
msgid "Travel"
msgstr "差旅"
#: src/accounting/templates/accounting/transaction/include/summary-helper-modal.html:49
msgid "Bus"
msgstr "公車"
#: src/accounting/templates/accounting/transaction/include/summary-helper-modal.html:54
msgid "Regular"
msgstr "帳單"
#: src/accounting/templates/accounting/transaction/include/summary-helper-modal.html:59
msgid "Number"
msgstr "數量"
#: src/accounting/templates/accounting/transaction/include/summary-helper-modal.html:67
#: src/accounting/templates/accounting/transaction/include/summary-helper-modal.html:84
#: src/accounting/templates/accounting/transaction/include/summary-helper-modal.html:119
msgid "Tag"
msgstr "標籤"
#: src/accounting/templates/accounting/transaction/include/summary-helper-modal.html:99
#: src/accounting/templates/accounting/transaction/include/summary-helper-modal.html:140
msgid "From"
msgstr "從"
#: src/accounting/templates/accounting/transaction/include/summary-helper-modal.html:108
#: src/accounting/templates/accounting/transaction/include/summary-helper-modal.html:145
msgid "To"
msgstr "至"
#: src/accounting/templates/accounting/transaction/include/summary-helper-modal.html:124
msgid "Route"
msgstr "路線"
#: src/accounting/templates/accounting/transaction/include/summary-helper-modal.html:160
msgid "The number of items"
msgstr "數量"
#: src/accounting/templates/accounting/transaction/income/create.html:24 #: src/accounting/templates/accounting/transaction/income/create.html:24
msgid "Add a New Cash Income Transaction" msgid "Add a New Cash Income Transaction"
msgstr "新增現金收入傳票" msgstr "新增現金收入傳票"
@ -590,27 +533,27 @@ msgstr "借方"
msgid "Credit" msgid "Credit"
msgstr "貸方" msgstr "貸方"
#: src/accounting/transaction/forms.py:45 #: src/accounting/transaction/forms.py:44
msgid "Please select the currency." msgid "Please select the currency."
msgstr "請選擇貨幣。" msgstr "請選擇貨幣。"
#: src/accounting/transaction/forms.py:68 #: src/accounting/transaction/forms.py:67
msgid "The currency does not exist." msgid "The currency does not exist."
msgstr "沒有這個貨幣。" msgstr "沒有這個貨幣。"
#: src/accounting/transaction/forms.py:89 #: src/accounting/transaction/forms.py:88
msgid "The account does not exist." msgid "The account does not exist."
msgstr "沒有這個科目。" msgstr "沒有這個科目。"
#: src/accounting/transaction/forms.py:100 #: src/accounting/transaction/forms.py:99
msgid "Please fill in a positive amount." msgid "Please fill in a positive amount."
msgstr "金額請填正數。" msgstr "金額請填正數。"
#: src/accounting/transaction/forms.py:114 #: src/accounting/transaction/forms.py:113
msgid "This account is not for debit entries." msgid "This account is not for debit entries."
msgstr "科目不是借方科目。" msgstr "科目不是借方科目。"
#: src/accounting/transaction/forms.py:201 #: src/accounting/transaction/forms.py:200
msgid "This account is not for credit entries." msgid "This account is not for credit entries."
msgstr "科目不是貸方科目。" msgstr "科目不是貸方科目。"

View File

@ -1,327 +0,0 @@
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/28
# Copyright (c) 2023 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""The test for the summary helper.
"""
import unittest
from datetime import date
from click.testing import Result
from flask import Flask
from flask.testing import FlaskCliRunner
from test_site import create_app
from testlib import get_client
from testlib_txn import Accounts, NEXT_URI, add_txn
class SummeryHelperTestCase(unittest.TestCase):
"""The summary helper test case."""
def setUp(self) -> None:
"""Sets up the test.
This is run once per test.
:return: None.
"""
self.app: Flask = create_app(is_testing=True)
runner: FlaskCliRunner = self.app.test_cli_runner()
with self.app.app_context():
from accounting.models import BaseAccount, Transaction, \
JournalEntry
result: Result
result = runner.invoke(args="init-db")
self.assertEqual(result.exit_code, 0)
if BaseAccount.query.first() is None:
result = runner.invoke(args="accounting-init-base")
self.assertEqual(result.exit_code, 0)
result = runner.invoke(args=["accounting-init-currencies",
"-u", "editor"])
self.assertEqual(result.exit_code, 0)
result = runner.invoke(args=["accounting-init-accounts",
"-u", "editor"])
self.assertEqual(result.exit_code, 0)
Transaction.query.delete()
JournalEntry.query.delete()
self.client, self.csrf_token = get_client(self.app, "editor")
def test_summary_helper(self) -> None:
"""Test the summary helper.
:return: None.
"""
from accounting.transaction.summary_helper import SummaryHelper
for form in get_form_data(self.csrf_token):
add_txn(self.client, form)
with self.app.app_context():
helper: SummaryHelper = SummaryHelper()
# Debit-General
self.assertEqual(len(helper.debit.general.tags), 2)
self.assertEqual(helper.debit.general.tags[0].name, "Lunch")
self.assertEqual(len(helper.debit.general.tags[0].accounts), 2)
self.assertEqual(helper.debit.general.tags[0].accounts[0].code,
Accounts.MEAL)
self.assertEqual(helper.debit.general.tags[0].accounts[1].code,
Accounts.PAYABLE)
self.assertEqual(helper.debit.general.tags[1].name, "Dinner")
self.assertEqual(len(helper.debit.general.tags[1].accounts), 2)
self.assertEqual(helper.debit.general.tags[1].accounts[0].code,
Accounts.MEAL)
self.assertEqual(helper.debit.general.tags[1].accounts[1].code,
Accounts.PAYABLE)
# Debit-Travel
self.assertEqual(len(helper.debit.travel.tags), 3)
self.assertEqual(helper.debit.travel.tags[0].name, "Bike")
self.assertEqual(len(helper.debit.travel.tags[0].accounts), 1)
self.assertEqual(helper.debit.travel.tags[0].accounts[0].code,
Accounts.TRAVEL)
self.assertEqual(helper.debit.travel.tags[1].name, "Taxi")
self.assertEqual(len(helper.debit.travel.tags[1].accounts), 1)
self.assertEqual(helper.debit.travel.tags[1].accounts[0].code,
Accounts.TRAVEL)
self.assertEqual(helper.debit.travel.tags[2].name, "Airplane")
self.assertEqual(len(helper.debit.travel.tags[2].accounts), 1)
self.assertEqual(helper.debit.travel.tags[2].accounts[0].code,
Accounts.TRAVEL)
# Debit-Bus
self.assertEqual(len(helper.debit.bus.tags), 2)
self.assertEqual(helper.debit.bus.tags[0].name, "Train")
self.assertEqual(len(helper.debit.bus.tags[0].accounts), 1)
self.assertEqual(helper.debit.bus.tags[0].accounts[0].code,
Accounts.TRAVEL)
self.assertEqual(helper.debit.bus.tags[1].name, "Bus")
self.assertEqual(len(helper.debit.bus.tags[1].accounts), 1)
self.assertEqual(helper.debit.bus.tags[1].accounts[0].code,
Accounts.TRAVEL)
# Credit-General
self.assertEqual(len(helper.credit.general.tags), 2)
self.assertEqual(helper.credit.general.tags[0].name, "Lunch")
self.assertEqual(len(helper.credit.general.tags[0].accounts), 3)
self.assertEqual(helper.credit.general.tags[0].accounts[0].code,
Accounts.PAYABLE)
self.assertEqual(helper.credit.general.tags[0].accounts[1].code,
Accounts.BANK)
self.assertEqual(helper.credit.general.tags[0].accounts[2].code,
Accounts.CASH)
self.assertEqual(helper.credit.general.tags[1].name, "Dinner")
self.assertEqual(len(helper.credit.general.tags[1].accounts), 2)
self.assertEqual(helper.credit.general.tags[1].accounts[0].code,
Accounts.BANK)
self.assertEqual(helper.credit.general.tags[1].accounts[1].code,
Accounts.PAYABLE)
# Credit-Travel
self.assertEqual(len(helper.credit.travel.tags), 2)
self.assertEqual(helper.credit.travel.tags[0].name, "Bike")
self.assertEqual(len(helper.credit.travel.tags[0].accounts), 2)
self.assertEqual(helper.credit.travel.tags[0].accounts[0].code,
Accounts.PAYABLE)
self.assertEqual(helper.credit.travel.tags[0].accounts[1].code,
Accounts.PREPAID)
self.assertEqual(helper.credit.travel.tags[1].name, "Taxi")
self.assertEqual(len(helper.credit.travel.tags[1].accounts), 2)
self.assertEqual(helper.credit.travel.tags[1].accounts[0].code,
Accounts.PAYABLE)
self.assertEqual(helper.credit.travel.tags[1].accounts[1].code,
Accounts.CASH)
# Credit-Bus
self.assertEqual(len(helper.credit.bus.tags), 2)
self.assertEqual(helper.credit.bus.tags[0].name, "Train")
self.assertEqual(len(helper.credit.bus.tags[0].accounts), 2)
self.assertEqual(helper.credit.bus.tags[0].accounts[0].code,
Accounts.PREPAID)
self.assertEqual(helper.credit.bus.tags[0].accounts[1].code,
Accounts.PAYABLE)
self.assertEqual(helper.credit.bus.tags[1].name, "Bus")
self.assertEqual(len(helper.credit.bus.tags[1].accounts), 1)
self.assertEqual(helper.credit.bus.tags[1].accounts[0].code,
Accounts.PREPAID)
def get_form_data(csrf_token: str) -> list[dict[str, str]]:
"""Returns the form data for multiple transaction forms.
:param csrf_token: The CSRF token.
:return: A list of the form data.
"""
txn_date: str = date.today().isoformat()
return [{"csrf_token": csrf_token,
"next": NEXT_URI,
"date": txn_date,
"currency-0-code": "USD",
"currency-0-credit-0-account_code": Accounts.SERVICE,
"currency-0-credit-0-summary": " Salary ",
"currency-0-credit-0-amount": "2500"},
{"csrf_token": csrf_token,
"next": NEXT_URI,
"date": txn_date,
"currency-0-code": "USD",
"currency-0-debit-0-account_code": Accounts.MEAL,
"currency-0-debit-0-summary": " Lunch—Fish ",
"currency-0-debit-0-amount": "4.7",
"currency-0-credit-0-account_code": Accounts.BANK,
"currency-0-credit-0-summary": " Lunch—Fish ",
"currency-0-credit-0-amount": "4.7",
"currency-0-debit-1-account_code": Accounts.MEAL,
"currency-0-debit-1-summary": " Lunch—Fries ",
"currency-0-debit-1-amount": "2.15",
"currency-0-credit-1-account_code": Accounts.PAYABLE,
"currency-0-credit-1-summary": " Lunch—Fries ",
"currency-0-credit-1-amount": "2.15",
"currency-0-debit-2-account_code": Accounts.MEAL,
"currency-0-debit-2-summary": " Dinner—Hamburger ",
"currency-0-debit-2-amount": "4.25",
"currency-0-credit-2-account_code": Accounts.BANK,
"currency-0-credit-2-summary": " Dinner—Hamburger ",
"currency-0-credit-2-amount": "4.25"},
{"csrf_token": csrf_token,
"next": NEXT_URI,
"date": txn_date,
"currency-0-code": "USD",
"currency-0-debit-0-account_code": Accounts.MEAL,
"currency-0-debit-0-summary": " Lunch—Salad ",
"currency-0-debit-0-amount": "4.99",
"currency-0-credit-0-account_code": Accounts.CASH,
"currency-0-credit-0-summary": " Lunch—Salad ",
"currency-0-credit-0-amount": "4.99",
"currency-0-debit-1-account_code": Accounts.MEAL,
"currency-0-debit-1-summary": " Dinner—Steak ",
"currency-0-debit-1-amount": "8.28",
"currency-0-credit-1-account_code": Accounts.PAYABLE,
"currency-0-credit-1-summary": " Dinner—Steak ",
"currency-0-credit-1-amount": "8.28"},
{"csrf_token": csrf_token,
"next": NEXT_URI,
"date": txn_date,
"currency-0-code": "USD",
"currency-0-debit-0-account_code": Accounts.MEAL,
"currency-0-debit-0-summary": " Lunch—Pizza ",
"currency-0-debit-0-amount": "5.49",
"currency-0-credit-0-account_code": Accounts.PAYABLE,
"currency-0-credit-0-summary": " Lunch—Pizza ",
"currency-0-credit-0-amount": "5.49",
"currency-0-debit-1-account_code": Accounts.MEAL,
"currency-0-debit-1-summary": " Lunch—Noodles ",
"currency-0-debit-1-amount": "7.47",
"currency-0-credit-1-account_code": Accounts.PAYABLE,
"currency-0-credit-1-summary": " Lunch—Noodles ",
"currency-0-credit-1-amount": "7.47"},
{"csrf_token": csrf_token,
"next": NEXT_URI,
"date": txn_date,
"currency-0-code": "USD",
"currency-0-debit-0-account_code": Accounts.TRAVEL,
"currency-0-debit-0-summary": " Airplane—Lake City↔Hill Town ",
"currency-0-debit-0-amount": "800"},
{"csrf_token": csrf_token,
"next": NEXT_URI,
"date": txn_date,
"currency-0-code": "USD",
"currency-0-debit-0-account_code": Accounts.TRAVEL,
"currency-0-debit-0-summary": " Bus—323—Downtown→Museum ",
"currency-0-debit-0-amount": "2.5",
"currency-0-credit-0-account_code": Accounts.PREPAID,
"currency-0-credit-0-summary": " Bus—323—Downtown→Museum ",
"currency-0-credit-0-amount": "2.5",
"currency-0-debit-1-account_code": Accounts.TRAVEL,
"currency-0-debit-1-summary": " Train—Blue—Museum→Central ",
"currency-0-debit-1-amount": "3.2",
"currency-0-credit-1-account_code": Accounts.PREPAID,
"currency-0-credit-1-summary": " Train—Blue—Museum→Central ",
"currency-0-credit-1-amount": "3.2",
"currency-0-debit-2-account_code": Accounts.TRAVEL,
"currency-0-debit-2-summary": " Train—Green—Central→Mall ",
"currency-0-debit-2-amount": "3.2",
"currency-0-credit-2-account_code": Accounts.PREPAID,
"currency-0-credit-2-summary": " Train—Green—Central→Mall ",
"currency-0-credit-2-amount": "3.2",
"currency-0-debit-3-account_code": Accounts.TRAVEL,
"currency-0-debit-3-summary": " Train—Red—Mall→Museum ",
"currency-0-debit-3-amount": "4.4",
"currency-0-credit-3-account_code": Accounts.PAYABLE,
"currency-0-credit-3-summary": " Train—Red—Mall→Museum ",
"currency-0-credit-3-amount": "4.4"},
{"csrf_token": csrf_token,
"next": NEXT_URI,
"date": txn_date,
"currency-0-code": "USD",
"currency-0-debit-0-account_code": Accounts.TRAVEL,
"currency-0-debit-0-summary": " Taxi—Museum→Office ",
"currency-0-debit-0-amount": "15.5",
"currency-0-credit-0-account_code": Accounts.CASH,
"currency-0-credit-0-summary": " Taxi—Museum→Office ",
"currency-0-credit-0-amount": "15.5",
"currency-0-debit-1-account_code": Accounts.TRAVEL,
"currency-0-debit-1-summary": " Taxi—Office→Restaurant ",
"currency-0-debit-1-amount": "12",
"currency-0-credit-1-account_code": Accounts.PAYABLE,
"currency-0-credit-1-summary": " Taxi—Office→Restaurant ",
"currency-0-credit-1-amount": "12",
"currency-0-debit-2-account_code": Accounts.TRAVEL,
"currency-0-debit-2-summary": " Taxi—Restaurant→City Hall ",
"currency-0-debit-2-amount": "8",
"currency-0-credit-2-account_code": Accounts.PAYABLE,
"currency-0-credit-2-summary": " Taxi—Restaurant→City Hall ",
"currency-0-credit-2-amount": "8",
"currency-0-debit-3-account_code": Accounts.TRAVEL,
"currency-0-debit-3-summary": " Bike—City Hall→Office ",
"currency-0-debit-3-amount": "3.5",
"currency-0-credit-3-account_code": Accounts.PAYABLE,
"currency-0-credit-3-summary": " Bike—City Hall→Office ",
"currency-0-credit-3-amount": "3.5",
"currency-0-debit-4-account_code": Accounts.TRAVEL,
"currency-0-debit-4-summary": " Bike—Restaurant→Office ",
"currency-0-debit-4-amount": "4",
"currency-0-credit-4-account_code": Accounts.PAYABLE,
"currency-0-credit-4-summary": " Bike—Restaurant→Office ",
"currency-0-credit-4-amount": "4",
"currency-0-debit-5-account_code": Accounts.TRAVEL,
"currency-0-debit-5-summary": " Bike—Office→Theatre ",
"currency-0-debit-5-amount": "1.5",
"currency-0-credit-5-account_code": Accounts.PAYABLE,
"currency-0-credit-5-summary": " Bike—Office→Theatre ",
"currency-0-credit-5-amount": "1.5",
"currency-0-debit-6-account_code": Accounts.TRAVEL,
"currency-0-debit-6-summary": " Bike—Theatre→Home ",
"currency-0-debit-6-amount": "5.5",
"currency-0-credit-6-account_code": Accounts.PREPAID,
"currency-0-credit-6-summary": " Bike—Theatre→Home ",
"currency-0-credit-6-amount": "5.5"},
{"csrf_token": csrf_token,
"next": NEXT_URI,
"date": txn_date,
"currency-0-code": "USD",
"currency-0-debit-0-account_code": Accounts.PAYABLE,
"currency-0-debit-0-summary": " Dinner—Steak ",
"currency-0-debit-0-amount": "8.28",
"currency-0-credit-0-account_code": Accounts.BANK,
"currency-0-credit-0-summary": " Dinner—Steak ",
"currency-0-credit-0-amount": "8.28",
"currency-0-debit-1-account_code": Accounts.PAYABLE,
"currency-0-debit-1-summary": " Lunch—Pizza ",
"currency-0-debit-1-amount": "5.49",
"currency-0-credit-1-account_code": Accounts.BANK,
"currency-0-credit-1-summary": " Lunch—Pizza ",
"currency-0-credit-1-amount": "5.49"}]

View File

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

View File

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